From 908d9d1e689dba27832343f8771605ad46d046bd Mon Sep 17 00:00:00 2001 From: Ville Pihlava Date: Fri, 26 Sep 2025 12:39:58 +0300 Subject: [PATCH 1/6] Add vertical transportation use steps to indoor routing. --- app/component/itinerary/IndoorRouteInfo.js | 78 ++ app/component/itinerary/IndoorRouteStep.js | 136 ++++ .../itinerary/ItineraryCircleLineWithIcon.js | 57 +- app/component/itinerary/ItineraryPage.js | 1 + app/component/itinerary/Legs.js | 8 +- app/component/itinerary/SubwayEntranceInfo.js | 9 +- app/component/itinerary/WalkLeg.js | 83 +- app/component/itinerary/itinerary.scss | 118 +++ app/component/itinerary/navigator/NaviCard.js | 29 +- .../itinerary/navigator/NaviCardContainer.js | 4 + .../itinerary/navigator/NaviCardExtension.js | 141 +++- .../itinerary/navigator/NaviContainer.js | 3 + .../itinerary/navigator/NaviInstructions.js | 39 +- .../indoorroute/NaviIndoorRouteButton.js | 63 ++ .../indoorroute/NaviIndoorRouteContainer.js | 88 +++ .../indoorroute/NaviIndoorRouteStepInfo.js | 64 ++ .../itinerary/navigator/navigator.scss | 417 ++++++---- .../queries/ItineraryDetailsFragment.js | 34 + .../itinerary/queries/PlanConnection.js | 34 + app/component/map/ClusterNumberMarker.js | 64 ++ app/component/map/EntranceMarker.js | 59 +- app/component/map/GenericMarker.js | 11 +- app/component/map/IndoorRouteStepMarker.js | 149 ++++ app/component/map/ItineraryLine.js | 463 +++++++---- app/component/map/WalkQuery.js | 34 + app/component/map/map.scss | 88 +++ .../map/tile-layer/RentalVehicles.js | 31 +- .../default/indoor-dotted-line-horizontal.svg | 21 + .../images/default/indoor-dotted-line.svg | 21 + app/constants.js | 45 ++ app/translations.js | 30 + app/util/clusterUtils.js | 13 + app/util/indoorUtils.js | 154 ++++ app/util/mapIconUtils.js | 10 +- app/util/shapes.js | 54 +- build/schema.graphql | 733 ++++++++++++++---- .../schema/schema.graphql | 732 +++++++++++++---- docs/ZIndex.md | 2 + static/assets/svg-sprite.default.svg | 74 ++ static/assets/svg-sprite.hsl.svg | 74 ++ test/unit/WalkLeg.test.js | 60 ++ 41 files changed, 3647 insertions(+), 681 deletions(-) create mode 100644 app/component/itinerary/IndoorRouteInfo.js create mode 100644 app/component/itinerary/IndoorRouteStep.js create mode 100644 app/component/itinerary/navigator/indoorroute/NaviIndoorRouteButton.js create mode 100644 app/component/itinerary/navigator/indoorroute/NaviIndoorRouteContainer.js create mode 100644 app/component/itinerary/navigator/indoorroute/NaviIndoorRouteStepInfo.js create mode 100644 app/component/map/ClusterNumberMarker.js create mode 100644 app/component/map/IndoorRouteStepMarker.js create mode 100644 app/configurations/images/default/indoor-dotted-line-horizontal.svg create mode 100644 app/configurations/images/default/indoor-dotted-line.svg create mode 100644 app/util/clusterUtils.js diff --git a/app/component/itinerary/IndoorRouteInfo.js b/app/component/itinerary/IndoorRouteInfo.js new file mode 100644 index 0000000000..152182b5fc --- /dev/null +++ b/app/component/itinerary/IndoorRouteInfo.js @@ -0,0 +1,78 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import { FormattedMessage } from 'react-intl'; +import { configShape } from '../../util/shapes'; +import { isKeyboardSelectionEvent } from '../../util/browser'; +import Icon from '../Icon'; + +export default function IndoorRouteInfo( + { intermediateStepCount, showIntermediateSteps, toggleFunction }, + { config }, +) { + const message = (showIntermediateSteps && ( + + )) || ( + + ); + return ( +
0, + })} + onClick={e => { + e.stopPropagation(); + if (intermediateStepCount > 0) { + toggleFunction(); + } + }} + onKeyPress={e => { + if (isKeyboardSelectionEvent(e)) { + e.stopPropagation(); + toggleFunction(); + } + }} + > +
+ {intermediateStepCount === 0 ? ( + {message} + ) : ( + {message} + )}{' '} + {intermediateStepCount !== 0 && ( + + )} +
+
+ ); +} + +IndoorRouteInfo.contextTypes = { + config: configShape.isRequired, +}; + +IndoorRouteInfo.propTypes = { + intermediateStepCount: PropTypes.number.isRequired, + toggleFunction: PropTypes.func.isRequired, + showIntermediateSteps: PropTypes.bool, +}; + +IndoorRouteInfo.defaultProps = { + showIntermediateSteps: false, +}; diff --git a/app/component/itinerary/IndoorRouteStep.js b/app/component/itinerary/IndoorRouteStep.js new file mode 100644 index 0000000000..e02034f2a1 --- /dev/null +++ b/app/component/itinerary/IndoorRouteStep.js @@ -0,0 +1,136 @@ +import PropTypes from 'prop-types'; +import React, { useEffect, useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import cx from 'classnames'; +import { configShape } from '../../util/shapes'; +import Icon from '../Icon'; +import { + getIndoorRouteTranslationId, + getVerticalTransportationUseIconId, +} from '../../util/indoorUtils'; +import { + IndoorRouteLegType, + RelativeDirection, + VerticalDirection, +} from '../../constants'; +import ItineraryMapAction from './ItineraryMapAction'; + +function IndoorRouteStep({ + focusAction, + relativeDirection, + verticalDirection, + toLevelName, + isLastPlace, + onlyOneStep, + indoorRouteLegType, +}) { + const [defaultBackgroundImageUrl, setDefaultBackgroundImageUrl] = useState(); + const [indoorBackgroundImageUrl, setIndoorBackgroundImageUrl] = useState(); + useEffect(() => { + Promise.all([ + import( + /* webpackChunkName: "dotted-line" */ `../../configurations/images/default/dotted-line.svg` + ), + import( + /* webpackChunkName: "indoor-dotted-line" */ `../../configurations/images/default/indoor-dotted-line.svg` + ), + ]).then(([defaultImageUrl, insideImageUrl]) => { + setDefaultBackgroundImageUrl(`url(${defaultImageUrl.default})`); + setIndoorBackgroundImageUrl(`url(${insideImageUrl.default})`); + }); + }, []); + + const indoorTranslationId = getIndoorRouteTranslationId( + relativeDirection, + verticalDirection, + toLevelName, + ); + + return ( +
+
+
+ + + +
+
+
+
+
+ +
+ +
+ +
+
+
+ ); +} + +IndoorRouteStep.propTypes = { + focusAction: PropTypes.func.isRequired, + relativeDirection: PropTypes.oneOf(Object.values(RelativeDirection)) + .isRequired, + verticalDirection: PropTypes.oneOf(Object.values(VerticalDirection)), + toLevelName: PropTypes.string, + isLastPlace: PropTypes.bool, + onlyOneStep: PropTypes.bool, + indoorRouteLegType: PropTypes.oneOf(Object.values(IndoorRouteLegType)), +}; + +IndoorRouteStep.defaultProps = { + verticalDirection: undefined, + toLevelName: undefined, + isLastPlace: false, + onlyOneStep: false, + indoorRouteLegType: IndoorRouteLegType.NoStepsInside, +}; + +IndoorRouteStep.contextTypes = { + config: configShape.isRequired, +}; + +export default IndoorRouteStep; diff --git a/app/component/itinerary/ItineraryCircleLineWithIcon.js b/app/component/itinerary/ItineraryCircleLineWithIcon.js index 6921e9981e..4555fb81a4 100644 --- a/app/component/itinerary/ItineraryCircleLineWithIcon.js +++ b/app/component/itinerary/ItineraryCircleLineWithIcon.js @@ -3,12 +3,15 @@ import React from 'react'; import cx from 'classnames'; import Icon from '../Icon'; import RouteNumber from '../RouteNumber'; +import { IndoorRouteLegType } from '../../constants'; class ItineraryCircleLineWithIcon extends React.Component { static propTypes = { index: PropTypes.number.isRequired, modeClassName: PropTypes.string.isRequired, isVia: PropTypes.bool, + indoorRouteLegType: PropTypes.oneOf(Object.values(IndoorRouteLegType)), + showIntermediateSteps: PropTypes.bool, bikePark: PropTypes.bool, carPark: PropTypes.bool, color: PropTypes.string, @@ -16,10 +19,13 @@ class ItineraryCircleLineWithIcon extends React.Component { icon: PropTypes.string, style: PropTypes.shape({}), isNotFirstLeg: PropTypes.bool, + onlyOneStep: PropTypes.bool, }; static defaultProps = { isVia: false, + indoorRouteLegType: IndoorRouteLegType.NoStepsInside, + showIntermediateSteps: false, color: null, bikePark: false, carPark: false, @@ -27,10 +33,12 @@ class ItineraryCircleLineWithIcon extends React.Component { icon: undefined, style: {}, isNotFirstLeg: undefined, + onlyOneStep: false, }; state = { - imageUrl: 'none', + defaultImageUrl: 'none', + insideImageUrl: 'none', }; isFirstChild = () => { @@ -42,10 +50,18 @@ class ItineraryCircleLineWithIcon extends React.Component { }; componentDidMount() { - import( - /* webpackChunkName: "dotted-line" */ `../../configurations/images/default/dotted-line.svg` - ).then(imageUrl => { - this.setState({ imageUrl: `url(${imageUrl.default})` }); + Promise.all([ + import( + /* webpackChunkName: "dotted-line" */ `../../configurations/images/default/dotted-line.svg` + ), + import( + /* webpackChunkName: "indoor-dotted-line" */ `../../configurations/images/default/indoor-dotted-line.svg` + ), + ]).then(([defaultImageUrl, insideImageUrl]) => { + this.setState({ + defaultImageUrl: `url(${defaultImageUrl.default})`, + insideImageUrl: `url(${insideImageUrl.default})`, + }); }); } @@ -106,16 +122,43 @@ class ItineraryCircleLineWithIcon extends React.Component { const topMarker = this.getMarker(true); const bottomMarker = this.getMarker(false); const legBeforeLineStyle = { color: this.props.color, ...this.props.style }; + const legBeforeLineBottomStyle = { + color: this.props.color, + ...this.props.style, + }; if ( this.props.modeClassName === 'walk' || this.props.modeClassName === 'bicycle_walk' ) { - legBeforeLineStyle.backgroundImage = this.state.imageUrl; + legBeforeLineStyle.backgroundImage = this.state.defaultImageUrl; + switch (this.props.indoorRouteLegType) { + case IndoorRouteLegType.StepsAfterEntranceInside: + legBeforeLineStyle.backgroundImage = this.state.defaultImageUrl; + legBeforeLineBottomStyle.backgroundImage = this.state.insideImageUrl; + break; + case IndoorRouteLegType.StepsBeforeEntranceInside: + if (this.props.showIntermediateSteps) { + legBeforeLineStyle.backgroundImage = this.state.insideImageUrl; + legBeforeLineBottomStyle.backgroundImage = + this.state.insideImageUrl; + } else { + legBeforeLineStyle.backgroundImage = this.state.insideImageUrl; + legBeforeLineBottomStyle.backgroundImage = + this.state.defaultImageUrl; + } + break; + default: + legBeforeLineStyle.backgroundImage = this.state.defaultImageUrl; + legBeforeLineBottomStyle.backgroundImage = this.state.defaultImageUrl; + } } return (
@@ -118,7 +136,7 @@ export default function NaviCard(
setShowIndoorRoute(!showIndoorRoute)} />
@@ -139,6 +161,8 @@ export default function NaviCard( } NaviCard.propTypes = { + focusToPoint: PropTypes.func.isRequired, + previousLeg: legShape, leg: legShape, nextLeg: legShape, legType: PropTypes.string.isRequired, @@ -152,6 +176,7 @@ NaviCard.propTypes = { platformUpdated: PropTypes.bool, }; NaviCard.defaultProps = { + previousLeg: undefined, leg: undefined, nextLeg: undefined, position: undefined, diff --git a/app/component/itinerary/navigator/NaviCardContainer.js b/app/component/itinerary/navigator/NaviCardContainer.js index 968ae3d91c..3cd0259eb1 100644 --- a/app/component/itinerary/navigator/NaviCardContainer.js +++ b/app/component/itinerary/navigator/NaviCardContainer.js @@ -43,6 +43,7 @@ const getLegType = (leg, firstLeg, time, interlineWithPreviousLeg) => { function NaviCardContainer( { focusToLeg, + focusToPoint, time, legs, position, @@ -222,6 +223,8 @@ function NaviCardContainer( aria-hidden={legChanging ? 'true' : 'false'} > { const { stop, name, rentalVehicle, vehicleParking, vehicleRentalStation } = @@ -79,36 +96,69 @@ const NaviCardExtension = ( ); } - const stopInformation = (expandIcon = false) => { + const stopInformation = ( + expandIcon = false, + showIndoorRouteButton = false, + ) => { + let indoorRouteSteps = []; + if (showIndoorRouteButton) { + indoorRouteSteps = getIndoorStepsWithVerticalTransportationUse( + previousLeg, + leg, + ); + } return (
- {expandIcon && } - -
- {destination.name} -
- {!stop && address &&
{address}
} - {code && } - {platformCode && ( - + {expandIcon && } + +
+ {destination.name} +
+ {!stop && address &&
{address}
} + {code && } + {platformCode && ( + + )} + - )} - +
+ {indoorRouteSteps.length === 1 && ( +
+ + +
+ )} + {indoorRouteSteps.length > 1 && ( + + )}
); }; @@ -136,6 +186,30 @@ const NaviCardExtension = ( ); } if (legType === LEGTYPE.MOVE && nextLeg?.transitLeg) { + if (showIndoorRoute) { + const indoorRouteSteps = getIndoorStepsWithVerticalTransportationUse( + previousLeg, + leg, + ); + return ( +
+
+
+ +
+
+
+ +
+
+ ); + } const { headsign, route, start } = nextLeg; const hs = headsign || nextLeg.trip?.tripHeadsign; const remainingDuration = ; @@ -146,8 +220,9 @@ const NaviCardExtension = ( }; const routeMode = getRouteMode(route, config); return ( -
- {stopInformation()} +
+
+ {stopInformation(false, true)}
+
{stopInformation(true)} - +
); }; NaviCardExtension.propTypes = { + focusToPoint: PropTypes.func.isRequired, + previousLeg: legShape, leg: legShape, nextLeg: legShape, legType: PropTypes.string, time: PropTypes.number.isRequired, platformUpdated: PropTypes.bool, + showIndoorRoute: PropTypes.bool, + toggleShowIndoorRoute: PropTypes.func.isRequired, }; NaviCardExtension.defaultProps = { legType: '', + previousLeg: undefined, leg: undefined, nextLeg: undefined, platformUpdated: false, + showIndoorRoute: false, }; NaviCardExtension.contextTypes = { diff --git a/app/component/itinerary/navigator/NaviContainer.js b/app/component/itinerary/navigator/NaviContainer.js index 03dccacbfc..7b63b3bb76 100644 --- a/app/component/itinerary/navigator/NaviContainer.js +++ b/app/component/itinerary/navigator/NaviContainer.js @@ -25,6 +25,7 @@ const START_BUFFER = 120000; // 2 min in ms function NaviContainer( { focusToLeg, + focusToPoint, relayEnvironment, setNavigation, isNavigatorIntroDismissed, @@ -154,6 +155,7 @@ function NaviContainer( -
- -   - {legDestination(intl, leg, null, nextLeg)} -   - - {displayDistance(tailLength, config, intl.formatNumber)}  - - {nextLeg?.transitLeg && ( - - )} -
+ {!showIndoorRoute && ( +
+ +   + {legDestination(intl, leg, null, nextLeg)} +   + + {displayDistance(tailLength, config, intl.formatNumber)}  + + {nextLeg?.transitLeg && ( + + )} +
+ )} {nextLeg?.transitLeg && ( { + e.stopPropagation(); + toggleShowIndoorRoute(); + }} + onKeyPress={e => { + if (isKeyboardSelectionEvent(e)) { + e.stopPropagation(); + toggleShowIndoorRoute(); + } + }} + > + {showIndoorRoute ? ( + <> +
+ +
+
+ +
+ + ) : ( + <> +
+ +
+
+ +
+ + )} +
+ ); +} + +NaviIndoorRouteButton.propTypes = { + showIndoorRoute: PropTypes.bool, + toggleShowIndoorRoute: PropTypes.func.isRequired, +}; +NaviIndoorRouteButton.defaultProps = { + showIndoorRoute: false, +}; diff --git a/app/component/itinerary/navigator/indoorroute/NaviIndoorRouteContainer.js b/app/component/itinerary/navigator/indoorroute/NaviIndoorRouteContainer.js new file mode 100644 index 0000000000..f9646b215e --- /dev/null +++ b/app/component/itinerary/navigator/indoorroute/NaviIndoorRouteContainer.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React, { useEffect, useState } from 'react'; +import { configShape } from '../../../../util/shapes'; +import { RelativeDirection, VerticalDirection } from '../../../../constants'; +import NaviIndoorRouteStepInfo from './NaviIndoorRouteStepInfo'; +import { getStepFocusAction } from '../../../../util/indoorUtils'; + +function NaviIndoorRouteContainer({ focusToPoint, indoorRouteSteps }) { + const [indoorBackgroundImageUrl, setIndoorBackgroundImageUrl] = useState(); + useEffect(() => { + import( + /* webpackChunkName: "indoor-dotted-line" */ `../../../../configurations/images/default/indoor-dotted-line.svg` + ).then(insideImageUrl => { + setIndoorBackgroundImageUrl(`url(${insideImageUrl.default})`); + }); + }, []); + + return ( +
+
+
+ {indoorRouteSteps.map((step, i) => ( + +
+ + + +
+
+ ))} +
+
+
+
+ {indoorRouteSteps.map((step, i) => ( + + ))} +
+
+ ); +} + +NaviIndoorRouteContainer.propTypes = { + focusToPoint: PropTypes.func.isRequired, + indoorRouteSteps: PropTypes.arrayOf( + PropTypes.shape({ + relativeDirection: PropTypes.oneOf(Object.values(RelativeDirection)), + feature: PropTypes.shape({ + verticalDirection: PropTypes.oneOf(Object.values(VerticalDirection)), + to: PropTypes.shape({ + name: PropTypes.string, + }), + }), + }), + ), +}; + +NaviIndoorRouteContainer.defaultProps = { + indoorRouteSteps: [], +}; + +NaviIndoorRouteContainer.contextTypes = { + config: configShape.isRequired, +}; + +export default NaviIndoorRouteContainer; diff --git a/app/component/itinerary/navigator/indoorroute/NaviIndoorRouteStepInfo.js b/app/component/itinerary/navigator/indoorroute/NaviIndoorRouteStepInfo.js new file mode 100644 index 0000000000..40ef6d0326 --- /dev/null +++ b/app/component/itinerary/navigator/indoorroute/NaviIndoorRouteStepInfo.js @@ -0,0 +1,64 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { configShape } from '../../../../util/shapes'; +import Icon from '../../../Icon'; +import { + getIndoorRouteTranslationId, + getVerticalTransportationUseIconId, +} from '../../../../util/indoorUtils'; +import { RelativeDirection, VerticalDirection } from '../../../../constants'; +import ItineraryMapAction from '../../ItineraryMapAction'; + +function NaviIndoorRouteStepInfo({ + focusAction, + relativeDirection, + verticalDirection, + toLevelName, +}) { + const indoorTranslationId = getIndoorRouteTranslationId( + relativeDirection, + verticalDirection, + toLevelName, + ); + + return ( +
+ +
+ +
+ +
+ ); +} + +NaviIndoorRouteStepInfo.propTypes = { + focusAction: PropTypes.func.isRequired, + relativeDirection: PropTypes.oneOf(Object.values(RelativeDirection)) + .isRequired, + verticalDirection: PropTypes.oneOf(Object.values(VerticalDirection)), + toLevelName: PropTypes.string, +}; + +NaviIndoorRouteStepInfo.defaultProps = { + verticalDirection: undefined, + toLevelName: undefined, +}; + +NaviIndoorRouteStepInfo.contextTypes = { + config: configShape.isRequired, +}; + +export default NaviIndoorRouteStepInfo; diff --git a/app/component/itinerary/navigator/navigator.scss b/app/component/itinerary/navigator/navigator.scss index 25dd01818c..d87d471c0e 100644 --- a/app/component/itinerary/navigator/navigator.scss +++ b/app/component/itinerary/navigator/navigator.scss @@ -138,18 +138,6 @@ display: flex; align-self: flex-start; margin-bottom: var(--space-xs); - - .expand { - margin-left: var(--space-m); - display: flex; - - .icon { - margin-right: var(--space-s); - margin-top: 5px; - width: 16px; - height: 16px; - } - } } .extension-routenumber, @@ -195,8 +183,6 @@ flex-direction: column; &.with-icon { - margin-left: 40px; - .wait-duration { margin-left: var(--space-l); } @@ -292,6 +278,7 @@ flex-direction: column; width: 100%; margin-right: var(--space-m); + justify-content: center; &.expanded { margin-bottom: 0; @@ -313,192 +300,316 @@ display: flex; } - .extension { + .extension-container { flex-direction: column; transition: all 0.4s ease; overflow-y: hidden; - &.no-gap { - margin-top: 0; - margin-bottom: 0; - } + .extension { + margin-left: calc(32px + var(--space-s)); - .extension-divider { - height: 1px; - background: #ddd; - width: 85%; - margin-left: 35px; - margin-top: var(--space-s); - margin-bottom: var(--space-s); - } + &.no-vertical-margin { + margin-top: 0; + margin-bottom: 0; + } - .stop-count { - display: flex; - margin-left: 35px; + .extension-divider { + height: 1px; + background: #ddd; + width: 95%; + margin-top: var(--space-s); + margin-bottom: var(--space-s); + } - .icon-container { + .stop-count { display: flex; - align-items: center; - .icon { - height: 16px; - width: 16px; + .icon-container { + display: flex; + align-items: center; + + .icon { + height: 16px; + width: 16px; + } } } - } - .extension-routenumber { - display: flex; - flex-direction: row; - margin-left: 40px; - margin-bottom: var(--space-s); - text-align: left; - margin-top: var(--space-m); + .extension-routenumber { + display: flex; + flex-direction: row; + margin-bottom: var(--space-s); + text-align: left; + margin-top: var(--space-m); + + .bar { + border-radius: $border-radius; + } + + .headsign { + display: flex; + flex-direction: column; + margin-left: var(--space-xs); + justify-content: center; + font-size: $font-size-small; + max-width: 85%; + line-height: 100%; + } + } - .bar { - border-radius: $border-radius; + .extension-indoor-route-button, + .extension-indoor-route-container, + .extension-walk { + margin-bottom: var(--space-s); + margin-top: var(--space-xs); } - .headsign { + .wait-in-vehicle { display: flex; - flex-direction: column; - margin-left: var(--space-xs); - justify-content: center; - font-size: $font-size-small; - max-width: 85%; - line-height: 100%; + align-items: flex-start; + text-align: start; } - } - .extension-walk { - display: flex; - margin-left: var(--space-xl); - margin-bottom: var(--space-s); - margin-top: var(--space-xs); - } + .icon-expand { + margin-top: 5px; + width: 24px; + height: 24px; + margin-right: 10px; + } - .wait-in-vehicle { - display: flex; - align-items: flex-start; - text-align: start; - margin-left: var(--space-xl); - } + .icon-expand-small { + margin-top: 5px; + width: 16px; + height: 16px; + margin-right: var(--space-s); + } - .wait-leg { - display: flex; - flex-direction: column; - align-items: flex-start; - margin-left: var(--space-m); + .destination-container { + display: flex; - .icon { - margin-top: 2px; - height: 25px; - width: 25px; - } - } + .destination-icon { + margin-right: 10px; - .icon-expand { - margin-top: 5px; - width: 24px; - height: 24px; - } + &.place { + fill: $to-color; + } - .icon-expand-small { - margin-top: 5px; - width: 16px; - height: 16px; - margin-right: var(--space-s); - } + &.bus-stop { + color: $bus-color; + } - .destination-icon { - margin: 0 10px; + &.bus-express { + color: $bus-express-color; + } - &.place { - fill: $to-color; - } + &.speedtram { + color: $speedtram-color; + } - &.bus-stop { - color: $bus-color; - } + &.replacement-bus { + color: $replacement-bus-color; + } - &.bus-express { - color: $bus-express-color; - } + &.tram-stop { + color: $tram-color; + } - &.speedtram { - color: $speedtram-color; - } + &.subway-stop { + color: $metro-color; + } - &.replacement-bus { - color: $replacement-bus-color; - } + &.rail-stop { + color: $rail-color; + } - &.tram-stop { - color: $tram-color; - } + &.ferry-stop { + color: $ferry-color; + } - &.subway-stop { - color: $metro-color; - } + &.ferry-external-stop { + color: $external-feed-color; + } - &.rail-stop { - color: $rail-color; - } + &.funicular-stop { + color: $funicular-color; + } - &.ferry-stop { - color: $ferry-color; - } + &.speedtram-stop { + color: $speedtram-color; + } + } - &.ferry-external-stop { - color: $external-feed-color; - } + .destination { + text-align: left; - &.funicular-stop { - color: $funicular-color; - } + .details { + display: flex; + flex-direction: row; + align-items: center; - &.speedtram-stop { - color: $speedtram-color; - } - } + .address { + color: #888; + } - .destination { - text-align: left; + .platform-short { + width: unset; + font-family: $font-family; + font-size: $font-size-small; + letter-spacing: $letter-spacing; + display: inline-flex; + align-items: center; + + .platform-number-wrapper { + padding: 0 var(--space-xxs); + min-height: 16px; + display: inline-flex; + justify-content: center; + align-items: center; + line-height: 13px; + font-size: 11px; + } + } - .details { + .zone-icon-container { + .circle { + width: 16px; + height: 16px; + font-size: 0.9rem; + padding: 0 2px 0 2px; + } + } + } + } + } + + .indoor-route-container-clickable { display: flex; - flex-direction: row; - align-items: center; + margin-top: 10px; + margin-bottom: 10px; - .address { - color: #888; + .indoor-route-text { + display: flex; + color: $primary-color; + font-weight: $font-weight-medium; + line-height: 1.2; + flex: 1; } - .platform-short { - width: unset; - font-family: $font-family; - font-size: $font-size-small; - letter-spacing: $letter-spacing; - display: inline-flex; - align-items: center; + .indoor-route-arrow-icon { + margin-right: 11px; + + span { + display: flex; + align-items: center; + + svg { + color: $primary-color; + + &.open { + transform: rotate(180deg); + } + } + } + } + } + + .navi-indoor-route-one-step-info-container { + display: flex; + align-items: center; + margin-top: 4px; + + .navi-indoor-route-step-info { + display: flex; + flex: 1; + font-size: 0.9375rem; + + .navi-indoor-route-step-icon { + width: 24px; + height: 100%; + vertical-align: middle; + display: flex; + margin-right: 8px; + } - .platform-number-wrapper { - padding: 0 var(--space-xxs); - min-height: 16px; - display: inline-flex; - justify-content: center; + .navi-indoor-route-step-text { align-items: center; - line-height: 13px; - font-size: 11px; + display: flex; + flex: 1; + } + + .itinerary-map-action { + padding-bottom: 0; + + .icon-container { + margin-top: 0; + } } } + } - .zone-icon-container { - .circle { - width: 16px; - height: 16px; - font-size: 0.9rem; - padding: 0 2px 0 2px; + .extension-indoor-route-container { + max-height: 130px; + overflow-y: auto; + overflow-x: hidden; + + .navi-indoor-route-step-container { + display: flex; + + .navi-indoor-route-step-info-container { + flex: 1; + + .navi-indoor-route-step-info { + display: flex; + font-size: 0.9375rem; + + .navi-indoor-route-step-icon { + width: 24px; + height: 100%; + vertical-align: middle; + display: flex; + margin-right: 8px; + } + + .navi-indoor-route-step-text { + align-items: center; + display: flex; + flex: 1; + } + } + } + + .navi-indoor-route-step-line-container { + min-width: 28px; + position: relative; + + .navi-indoor-route-step-line { + position: relative; + background-size: 100% auto; + background-position-y: 0; + background-position-x: 0; + background-repeat: no-repeat repeat; + border: none; + border-radius: 3px; + width: 6px; + left: 8px; + height: 100%; + top: 0; + } + + .navi-indoor-route-step-line-circle-container { + position: absolute; + + .navi-indoor-route-step-line-circle { + position: relative; + z-index: 9; + min-height: 37px; + + > svg > circle.indoor-route-step-marker { + fill: #fff; + stroke: #666; + } + } + } } } } diff --git a/app/component/itinerary/queries/ItineraryDetailsFragment.js b/app/component/itinerary/queries/ItineraryDetailsFragment.js index 14aba88b45..4a06f740cd 100644 --- a/app/component/itinerary/queries/ItineraryDetailsFragment.js +++ b/app/component/itinerary/queries/ItineraryDetailsFragment.js @@ -37,7 +37,41 @@ export const ItineraryDetailsFragment = graphql` publicCode wheelchairAccessible } + ... on ElevatorUse { + from { + level + name + } + verticalDirection + to { + level + name + } + } + ... on EscalatorUse { + from { + level + name + } + verticalDirection + to { + level + name + } + } + ... on StairsUse { + from { + level + name + } + verticalDirection + to { + level + name + } + } } + relativeDirection lat lon } diff --git a/app/component/itinerary/queries/PlanConnection.js b/app/component/itinerary/queries/PlanConnection.js index c237f953e6..e790020e09 100644 --- a/app/component/itinerary/queries/PlanConnection.js +++ b/app/component/itinerary/queries/PlanConnection.js @@ -125,7 +125,41 @@ export const planConnection = graphql` publicCode wheelchairAccessible } + ... on ElevatorUse { + from { + level + name + } + verticalDirection + to { + level + name + } + } + ... on EscalatorUse { + from { + level + name + } + verticalDirection + to { + level + name + } + } + ... on StairsUse { + from { + level + name + } + verticalDirection + to { + level + name + } + } } + relativeDirection lat lon } diff --git a/app/component/map/ClusterNumberMarker.js b/app/component/map/ClusterNumberMarker.js new file mode 100644 index 0000000000..1205cd0b4e --- /dev/null +++ b/app/component/map/ClusterNumberMarker.js @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { default as L } from 'leaflet'; + +import { configShape, locationShape } from '../../util/shapes'; +import GenericMarker from './GenericMarker'; + +export default function ClusterNumberMarker({ position, number }, { config }) { + const objs = []; + + const getIcon = () => { + const radius = 20; + const iconSvg = ` + + + + ${number} + + `; + + return L.divIcon({ + html: iconSvg, + iconSize: [radius * 2, radius * 2], + className: 'map-cluster-number-marker disable-icon-border', + }); + }; + + objs.push( + , + ); + + return
{objs}
; +} + +ClusterNumberMarker.contextTypes = { + config: configShape.isRequired, +}; + +ClusterNumberMarker.propTypes = { + position: locationShape.isRequired, + number: PropTypes.number.isRequired, +}; diff --git a/app/component/map/EntranceMarker.js b/app/component/map/EntranceMarker.js index 3d96aeb028..2054e5da16 100644 --- a/app/component/map/EntranceMarker.js +++ b/app/component/map/EntranceMarker.js @@ -2,50 +2,71 @@ import React from 'react'; import PropTypes from 'prop-types'; import { default as L } from 'leaflet'; -import Icon from '../Icon'; import { locationShape } from '../../util/shapes'; import GenericMarker from './GenericMarker'; -import { getCaseRadius, renderAsString } from '../../util/mapIconUtils'; +import { renderAsString, getIndexedIconFields } from '../../util/mapIconUtils'; +import { WheelchairBoarding } from '../../constants'; +import Icon from '../Icon'; -export default function EntranceMarker({ position, code }) { +export default function EntranceMarker({ position, code, entranceAccessible }) { const objs = []; - const getSubwayIcon = zoom => { - const iconSize = Math.max(getCaseRadius(zoom) * 2, 8); + const codeIndex = entranceAccessible === WheelchairBoarding.Possible ? 1 : 0; + const entranceIndex = (code ? 1 : 0) + codeIndex; + const getSubwayEntranceIcon = zoom => { + const { iconSize, iconAnchor } = getIndexedIconFields(zoom, entranceIndex); return L.divIcon({ html: renderAsString(), - iconSize: [iconSize, iconSize], - iconAnchor: [iconSize / 2, 2.5 * iconSize + 1], + iconSize, + iconAnchor, className: 'map-subway-entrance-info-icon-metro', }); }; - - const getCodeIcon = zoom => { - const iconSize = Math.max(getCaseRadius(zoom) * 2, 8); - + const getSubwayEntranceCodeIcon = zoom => { + const { iconSize, iconAnchor } = getIndexedIconFields(zoom, codeIndex); return L.divIcon({ html: renderAsString(), - iconSize: [iconSize, iconSize], - iconAnchor: [iconSize / 2, 1.5 * iconSize], + iconSize, + iconAnchor, + className: 'map-subway-entrance-info-icon-metro', + }); + }; + const getSubwayEntranceAccessibleIcon = zoom => { + const { iconSize, iconAnchor } = getIndexedIconFields(zoom, 0); + return L.divIcon({ + html: renderAsString(), + iconSize, + iconAnchor, className: 'map-subway-entrance-info-icon-metro', }); }; objs.push( , ); - if (code) { objs.push( , + ); + } + if (entranceAccessible === WheelchairBoarding.Possible) { + objs.push( + , ); } @@ -55,8 +76,10 @@ export default function EntranceMarker({ position, code }) { EntranceMarker.propTypes = { position: locationShape.isRequired, code: PropTypes.string, + entranceAccessible: PropTypes.oneOf(Object.values(WheelchairBoarding)), }; EntranceMarker.defaultProps = { code: undefined, + entranceAccessible: WheelchairBoarding.NoInformation, }; diff --git a/app/component/map/GenericMarker.js b/app/component/map/GenericMarker.js index be722b9509..3815f1979b 100644 --- a/app/component/map/GenericMarker.js +++ b/app/component/map/GenericMarker.js @@ -21,6 +21,7 @@ class GenericMarker extends React.Component { renderName: PropTypes.bool, name: PropTypes.string, maxWidth: PropTypes.number, + minWidth: PropTypes.number, children: PropTypes.node, leaflet: PropTypes.shape({ map: PropTypes.shape({ @@ -30,6 +31,7 @@ class GenericMarker extends React.Component { }).isRequired, }).isRequired, onClick: PropTypes.func, + zIndexOffset: PropTypes.number, }; static defaultProps = { @@ -38,7 +40,9 @@ class GenericMarker extends React.Component { renderName: false, name: '', maxWidth: undefined, + minWidth: undefined, children: undefined, + zIndexOffset: undefined, }; state = { zoom: this.props.leaflet.map.getZoom() }; @@ -59,6 +63,7 @@ class GenericMarker extends React.Component { icon={this.props.getIcon(this.state.zoom)} onClick={this.props.onClick} keyboard={false} + zIndexOffset={this.props.zIndexOffset} > {this.props.children && ( {this.props.children} @@ -98,6 +106,7 @@ class GenericMarker extends React.Component { iconAnchor: [-8, 7], })} keyboard={false} + zIndexOffset={this.props.zIndexOffset} /> ); } diff --git a/app/component/map/IndoorRouteStepMarker.js b/app/component/map/IndoorRouteStepMarker.js new file mode 100644 index 0000000000..679f23e782 --- /dev/null +++ b/app/component/map/IndoorRouteStepMarker.js @@ -0,0 +1,149 @@ +import React, { useEffect, useState } from 'react'; +/* eslint-disable react/no-array-index-key */ + +import PropTypes from 'prop-types'; +import { default as L } from 'leaflet'; + +import { intlShape } from 'react-intl'; +import cx from 'classnames'; +import { configShape, locationShape } from '../../util/shapes'; +import GenericMarker from './GenericMarker'; + +import Card from '../Card'; +import PopupHeader from './PopupHeader'; +import Icon from '../Icon'; +import { RelativeDirection, VerticalDirection } from '../../constants'; +import { getVerticalTransportationUseIconId } from '../../util/indoorUtils'; + +export default function IndoorRouteStepMarker( + { position, index, indoorRouteSteps }, + { intl }, +) { + const [indoorBackgroundImageUrl, setIndoorBackgroundImageUrl] = useState(); + useEffect(() => { + import( + /* webpackChunkName: "indoor-dotted-line-horizontal" */ `../../configurations/images/default/indoor-dotted-line-horizontal.svg` + ).then(insideImageUrl => { + setIndoorBackgroundImageUrl(`url(${insideImageUrl.default})`); + }); + }, []); + + const objs = []; + + const getIcon = () => { + const radius = 10; + const iconSvg = ` + + + `; + + return L.divIcon({ + html: iconSvg, + iconSize: [radius * 2, radius * 2], + className: 'map-vertical-transportation-use-marker disable-icon-border', + }); + }; + + objs.push( + + + +
+
+ {indoorRouteSteps.map((obj, i, filteredObjs) => ( + + + {filteredObjs.length !== i + 1 ? ( + + ) : null} + + ))} +
+
+
+ {indoorRouteSteps.map((obj, i) => ( + +
+ + + +
+
+ ))} +
+
+
+
+ + , + ); + + return
{objs}
; +} + +IndoorRouteStepMarker.contextTypes = { + config: configShape.isRequired, + intl: intlShape.isRequired, +}; + +IndoorRouteStepMarker.propTypes = { + position: locationShape.isRequired, + index: PropTypes.number.isRequired, + indoorRouteSteps: PropTypes.arrayOf( + PropTypes.shape({ + relativeDirection: PropTypes.oneOf(Object.values(RelativeDirection)), + feature: PropTypes.shape({ + verticalDirection: PropTypes.oneOf(Object.values(VerticalDirection)), + }), + }), + ), +}; + +IndoorRouteStepMarker.defaultProps = { + indoorRouteSteps: [], +}; diff --git a/app/component/map/ItineraryLine.js b/app/component/map/ItineraryLine.js index 1fa1954801..ce3674af1e 100644 --- a/app/component/map/ItineraryLine.js +++ b/app/component/map/ItineraryLine.js @@ -1,6 +1,8 @@ import PropTypes from 'prop-types'; /* eslint-disable react/no-array-index-key */ +import Supercluster from 'supercluster'; +import { withLeaflet } from 'react-leaflet'; import polyUtil from 'polyline-encoded'; import React from 'react'; import { getMiddleOf } from '../../util/geo-utils'; @@ -14,6 +16,15 @@ import TransitLegMarkers from './non-tile-layer/TransitLegMarkers'; import VehicleMarker from './non-tile-layer/VehicleMarker'; import SpeechBubble from './SpeechBubble'; import EntranceMarker from './EntranceMarker'; +import ClusterNumberMarker from './ClusterNumberMarker'; +import IndoorRouteStepMarker from './IndoorRouteStepMarker'; +import { createFeatureObjects } from '../../util/clusterUtils'; +import { ClusterMarkerType, WheelchairBoarding } from '../../constants'; +import { + getEntranceObject, + getEntranceWheelchairAccessibility, + getIndoorStepsWithVerticalTransportationUse, +} from '../../util/indoorUtils'; class ItineraryLine extends React.Component { static contextTypes = { @@ -28,6 +39,13 @@ class ItineraryLine extends React.Component { showDurationBubble: PropTypes.bool, streetMode: PropTypes.string, realtimeTransfers: PropTypes.bool, + leaflet: PropTypes.shape({ + map: PropTypes.shape({ + getZoom: PropTypes.func.isRequired, + on: PropTypes.func.isRequired, + off: PropTypes.func.isRequired, + }).isRequired, + }).isRequired, }; static defaultProps = { @@ -39,6 +57,10 @@ class ItineraryLine extends React.Component { realtimeTransfers: false, }; + state = { + zoom: this.props.leaflet.map.getZoom(), + }; + checkStreetMode(leg) { if (this.props.streetMode === 'walk') { return leg.mode === 'WALK'; @@ -49,11 +71,291 @@ class ItineraryLine extends React.Component { return false; } + handleEntrance( + leg, + nextLeg, + mode, + i, + geometry, + objs, + clusterObjs, + entranceObject, + ) { + const entranceCoordinates = [entranceObject.lat, entranceObject.lon]; + const getDistance = (coord1, coord2) => { + const [lat1, lon1] = coord1; + const [lat2, lon2] = coord2; + return Math.sqrt((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2); + }; + + const entranceIndex = geometry.reduce( + (closestIndex, currentCoord, currentIndex) => { + const currentDistance = getDistance(entranceCoordinates, currentCoord); + const closestDistance = getDistance( + entranceCoordinates, + geometry[closestIndex], + ); + return currentDistance < closestDistance ? currentIndex : closestIndex; + }, + 0, + ); + + if ( + entranceCoordinates[0] && + entranceCoordinates[1] && + !this.props.passive + ) { + clusterObjs.push({ + lat: entranceCoordinates[0], + lon: entranceCoordinates[1], + properties: { + iconCount: + 1 + + (entranceObject.feature.publicCode ? 1 : 0) + + (entranceObject.feature.wheelchairAccessible === + WheelchairBoarding.Possible + ? 1 + : 0), + type: ClusterMarkerType.Entrance, + code: entranceObject.feature.publicCode?.toLowerCase(), + }, + }); + } + + objs.push( + , + ); + objs.push( + , + ); + } + + handleLine(previousLeg, leg, nextLeg, mode, i, geometry, objs, clusterObjs) { + const entranceObject = getEntranceObject(previousLeg, leg); + if ( + leg.mode === 'WALK' && + (nextLeg?.mode === 'SUBWAY' || previousLeg?.mode === 'SUBWAY') && + entranceObject + ) { + this.handleEntrance( + leg, + nextLeg, + mode, + i, + geometry, + objs, + clusterObjs, + entranceObject, + ); + } else { + objs.push( + , + ); + } + } + + handleDurationBubble(leg, mode, i, objs, middle) { + if ( + this.props.showDurationBubble || + (this.checkStreetMode(leg) && leg.distance > 100) + ) { + const duration = durationToString(leg.duration * 1000); + objs.push( + , + ); + } + } + + handleIntermediateStops(leg, mode, objs) { + if ( + !this.props.passive && + this.props.showIntermediateStops && + leg.intermediatePlaces != null + ) { + leg.intermediatePlaces + .filter(place => place.stop) + .forEach(place => + objs.push( + , + ), + ); + } + } + + /** + * Add dynamic transit leg and transfer stop markers. + */ + handleTransitLegMarkers(transitLegs, objs) { + if (!this.props.passive) { + objs.push( + , + ); + } + } + + handleIndoorRouteStepMarkers(previousLeg, leg, clusterObjs) { + if (!this.props.passive) { + const indoorRouteSteps = getIndoorStepsWithVerticalTransportationUse( + previousLeg, + leg, + ); + + if (indoorRouteSteps) { + indoorRouteSteps.forEach((indoorRouteStep, i) => { + if (indoorRouteStep.lat && indoorRouteStep.lon) { + clusterObjs.push({ + lat: indoorRouteStep.lat, + lon: indoorRouteStep.lon, + properties: { + iconCount: 1, + type: ClusterMarkerType.VerticalTransportationUse, + relativeDirection: indoorRouteStep.relativeDirection, + verticalDirection: indoorRouteStep.feature?.verticalDirection, + index: i, + }, + }); + } + }); + } + } + } + + componentDidMount() { + this.props.leaflet.map.on('zoomend', this.onMapZoom); + } + + componentWillUnmount() { + this.props.leaflet.map.off('zoomend', this.onMapZoom); + } + + onMapZoom = () => { + const zoom = this.props.leaflet.map.getZoom(); + this.setState({ zoom }); + }; + + handleClusterObjects(previousLeg, leg, objs, clusterObjs) { + if (!this.props.passive) { + const index = new Supercluster({ + radius: 60, // in pixels + maxZoom: 15, // TODO if this is greater than max zoom (17) then max zoom icon can be number, dispay better icon than number + minPoints: 2, + extent: 512, // tile size (512) + // minZoom: 13, + // TODO maybe draw cluster icons based on what they have + map: properties => ({ + iconCount: properties.iconCount, + }), + reduce: (accumulated, properties) => { + // eslint-disable-next-line no-param-reassign + accumulated.iconCount += properties.iconCount; + }, + }); + + index.load(createFeatureObjects(clusterObjs)); + const bbox = [-180, -85, 180, 85]; // Bounding box covers the entire world + // TODO fix to use correct bbox, probably requires moveend event listening?: + /* + const bounds = this.props.leaflet.map.getBounds(); + const bbox = [ + bounds.getWest(), + bounds.getSouth(), + bounds.getEast(), + bounds.getNorth(), + ]; */ + const clusters = index.getClusters(bbox, this.state.zoom); + clusters.forEach(clusterFeature => { + const { coordinates } = clusterFeature.geometry; + const { properties } = clusterFeature; + if (properties.cluster) { + // Handle a cluster. + objs.push( + , + ); + } else { + // Handle a single point. + // eslint-disable-next-line no-lonely-if + if (properties.type === ClusterMarkerType.Entrance) { + objs.push( + , + ); + } else if ( + properties.type === ClusterMarkerType.VerticalTransportationUse + ) { + objs.push( + , + ); + } + } + }); + } + } + render() { const objs = []; const transitLegs = []; this.props.legs.forEach((leg, i) => { + const clusterObjs = []; + if (!leg || leg.mode === LegMode.Wait) { return; } @@ -103,142 +405,22 @@ class ItineraryLine extends React.Component { end = interliningLegs[interliningLegs.length - 1].end; } - if ( - leg.mode === 'WALK' && - (nextLeg?.mode === 'SUBWAY' || previousLeg?.mode === 'SUBWAY') - ) { - const entranceObjects = leg?.steps?.filter( - step => - // eslint-disable-next-line no-underscore-dangle - step?.feature?.__typename === 'Entrance' || step?.feature?.code, - ); - - // Select the entrance to the outside if there are multiple entrances - const entranceObject = - previousLeg?.mode === 'SUBWAY' - ? entranceObjects[entranceObjects.length - 1] - : entranceObjects[0]; - - if (entranceObject) { - const entranceCoordinates = [entranceObject.lat, entranceObject.lon]; - const getDistance = (coord1, coord2) => { - const [lat1, lon1] = coord1; - const [lat2, lon2] = coord2; - return Math.sqrt((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2); - }; - - const entranceIndex = geometry.reduce( - (closestIndex, currentCoord, currentIndex) => { - const currentDistance = getDistance( - entranceCoordinates, - currentCoord, - ); - const closestDistance = getDistance( - entranceCoordinates, - geometry[closestIndex], - ); - return currentDistance < closestDistance - ? currentIndex - : closestIndex; - }, - 0, - ); - - if (entranceCoordinates && !this.props.passive) { - objs.push( - , - ); - } - - objs.push( - , - ); - objs.push( - , - ); - } else { - objs.push( - , - ); - } - } else { - objs.push( - , - ); - } - - if ( - this.props.showDurationBubble || - (this.checkStreetMode(leg) && leg.distance > 100) - ) { - const duration = durationToString(leg.duration * 1000); - objs.push( - , - ); - } + this.handleLine( + previousLeg, + leg, + nextLeg, + mode, + i, + geometry, + objs, + clusterObjs, + ); + this.handleDurationBubble(leg, mode, i, objs, middle); + this.handleIntermediateStops(leg, mode, objs); + this.handleIndoorRouteStepMarkers(previousLeg, leg, clusterObjs); + this.handleClusterObjects(previousLeg, leg, objs, clusterObjs); if (!this.props.passive) { - if ( - this.props.showIntermediateStops && - leg.intermediatePlaces != null - ) { - leg.intermediatePlaces - .filter(place => place.stop) - .forEach(place => - objs.push( - , - ), - ); - } - if (rentalId) { objs.push( , - ); - } + this.handleTransitLegMarkers(transitLegs, objs); return
{objs}
; } } -export default ItineraryLine; +export default withLeaflet(ItineraryLine); diff --git a/app/component/map/WalkQuery.js b/app/component/map/WalkQuery.js index 4fda6c94ea..891dfe121f 100644 --- a/app/component/map/WalkQuery.js +++ b/app/component/map/WalkQuery.js @@ -46,7 +46,41 @@ const walkQuery = graphql` publicCode wheelchairAccessible } + ... on ElevatorUse { + from { + level + name + } + verticalDirection + to { + level + name + } + } + ... on EscalatorUse { + from { + level + name + } + verticalDirection + to { + level + name + } + } + ... on StairsUse { + from { + level + name + } + verticalDirection + to { + level + name + } + } } + relativeDirection lat lon } diff --git a/app/component/map/map.scss b/app/component/map/map.scss index 9be6630fe2..f35a27efc3 100644 --- a/app/component/map/map.scss +++ b/app/component/map/map.scss @@ -259,6 +259,26 @@ div.leaflet-marker-icon.parking { div.leaflet-marker-icon.map-subway-entrance-info-icon-metro { color: #0074bf; + cursor: grab; +} + +div.leaflet-marker-icon.map-cluster-number-marker { + cursor: grab; +} + +div.leaflet-marker-icon.map-vertical-transportation-use-marker { + > svg > circle.vertical-transportation-use-marker { + fill: #fff; + stroke: #666; + transition: all 0.3s ease; + transform-origin: center; + } + + > svg > circle.vertical-transportation-use-marker:hover { + transform: scale(1.3); + fill: #666; + stroke: #fff; + } } div.leaflet-marker-icon.via { @@ -632,6 +652,74 @@ div.leaflet-marker-icon.vehicle-icon { background: $white; border: solid 1px #ddd; + &.vertical-transportation-use-popup-container { + line-height: 0; + margin: 0; + padding: 10px 0 10px; + border: none; + border-top: 1px solid #ddd; + border-radius: 0; + flex-direction: column; + + .vertical-transportation-use-popup-icons { + flex-direction: row; + font-size: 20px; + + .icon-container { + display: inline-flex; + align-items: center; + justify-content: center; + + > svg.icon.vertical-transportation-use-popup-icon { + width: 2em; + } + + > svg.icon.arrow-popup-icon { + color: black; + height: 0.5em; + width: 0.5em; + } + } + } + + .vertical-transportation-use-popup-line-container { + .vertical-transportation-use-popup-line { + background-size: auto 100%; + background-position-y: 0; + background-position-x: 0; + background-repeat: repeat no-repeat; + border: none; + border-radius: 3px; + margin: 10%; + height: 6px; + min-width: 50px; + } + + .vertical-transportation-use-popup-line-circle-container { + position: absolute; + min-width: 0; + + .vertical-transportation-use-popup-line-circle { + border: none; + min-width: 0; + margin: 11px; + + > svg > circle.vertical-transportation-use-marker { + fill: #fff; + stroke: #666; + + &.selected { + fill: #666; + stroke: #fff; + filter: drop-shadow(0 1px 1px rgba(51, 51, 51, 1)); + r: 8; + } + } + } + } + } + } + div, a, button { diff --git a/app/component/map/tile-layer/RentalVehicles.js b/app/component/map/tile-layer/RentalVehicles.js index 44a0af5fd3..38c05cc0fa 100644 --- a/app/component/map/tile-layer/RentalVehicles.js +++ b/app/component/map/tile-layer/RentalVehicles.js @@ -13,6 +13,7 @@ import { fetchWithLanguageAndSubscription } from '../../../util/fetchUtils'; import { getLayerBaseUrl } from '../../../util/mapLayerUtils'; import { TransportMode } from '../../../constants'; import { getSettings } from '../../../util/planParamUtil'; +import { createFeatureObjects } from '../../../util/clusterUtils'; class RentalVehicles { constructor(tile, config, mapLayers, relayEnvironment) { @@ -170,22 +171,20 @@ class RentalVehicles { static getName = () => 'scooter'; pointsInSuperclusterFormat = () => { - return this.features.map(feature => { - // Convert the feature's x/y to lat/lon for clustering - const latLon = this.tile.project({ - x: feature.geom.x, - y: feature.geom.y, - }); - return { - type: 'Feature', - properties: { ...feature.properties }, - geom: { ...feature.geom }, - geometry: { - type: 'Point', - coordinates: [latLon.lat, latLon.lon], - }, - }; - }); + return createFeatureObjects( + this.features.map(feature => { + // Convert the feature's x/y to lat/lon for clustering + const coordinates = this.tile.project({ + x: feature.geom.x, + y: feature.geom.y, + }); + return { + properties: feature.properties, + lat: coordinates.lat, + lon: coordinates.lon, + }; + }), + ); }; featureWithGeom = clusterFeature => { diff --git a/app/configurations/images/default/indoor-dotted-line-horizontal.svg b/app/configurations/images/default/indoor-dotted-line-horizontal.svg new file mode 100644 index 0000000000..d4d2c228ed --- /dev/null +++ b/app/configurations/images/default/indoor-dotted-line-horizontal.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/app/configurations/images/default/indoor-dotted-line.svg b/app/configurations/images/default/indoor-dotted-line.svg new file mode 100644 index 0000000000..9cf130fc10 --- /dev/null +++ b/app/configurations/images/default/indoor-dotted-line.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/app/constants.js b/app/constants.js index e2c768c7be..69e4244d97 100644 --- a/app/constants.js +++ b/app/constants.js @@ -128,3 +128,48 @@ export const PlannerMessageType = Object.freeze({ NoStopsInRange: 'NO_STOPS_IN_RANGE', SystemError: 'SYSTEM_ERROR', }); + +export const VerticalDirection = Object.freeze({ + Up: 'UP', + Down: 'DOWN', + Unknown: 'UNKNOWN', +}); + +export const RelativeDirection = Object.freeze({ + CircleClockwise: 'CIRCLE_CLOCKWISE', + CircleCounterclockwise: 'CIRCLE_COUNTERCLOCKWISE', + Continue: 'CONTINUE', + Depart: 'DEPART', + Elevator: 'ELEVATOR', + EnterStation: 'ENTER_STATION', + Escalator: 'ESCALATOR', + ExitStation: 'EXIT_STATION', + FollowSigns: 'FOLLOW_SIGNS', + HardLeft: 'HARD_LEFT', + HardRight: 'HARD_RIGHT', + Left: 'LEFT', + Right: 'RIGHT', + SlightyLeft: 'SLIGHTLY_LEFT', + SlightlyRight: 'SLIGHTLY_RIGHT', + Stairs: 'STAIRS', + UturnLeft: 'UTURN_LEFT', + UturnRight: 'UTURN_RIGHT', +}); + +export const ClusterMarkerType = Object.freeze({ + Entrance: 'ENTRANCE', + VerticalTransportationUse: 'VERTICAL_TRANSPORTATION_USE', +}); + +export const IndoorRouteLegType = Object.freeze({ + AllStepsInside: 'ALL_STEPS_INSIDE', + StepsAfterEntranceInside: 'STEPS_AFTER_ENTRANCE_INSIDE', + StepsBeforeEntranceInside: 'STEPS_BEFORE_ENTRANCE_INSIDE', + NoStepsInside: 'NO_STEPS_INSIDE', +}); + +export const WheelchairBoarding = Object.freeze({ + NotPossible: 'NOT_POSSIBLE', + NoInformation: 'NO_INFORMATION', + Possible: 'POSSIBLE', +}); diff --git a/app/translations.js b/app/translations.js index b18094f85f..5cc38b9ea2 100644 --- a/app/translations.js +++ b/app/translations.js @@ -1120,6 +1120,14 @@ const translations = { 'in-addition': 'In addition', 'include-estonia': 'Include Estonia', 'index.title': 'Journey Planner', + 'indoor-step-message-elevator': 'Elevator', + 'indoor-step-message-elevator-to-floor': 'Elevator to floor {toLevelName}', + 'indoor-step-message-escalator': 'Escalator', + 'indoor-step-message-escalator-down': 'Escalator down', + 'indoor-step-message-escalator-up': 'Escalator up', + 'indoor-step-message-stairs': 'Stairs', + 'indoor-step-message-stairs-down': 'Stairs down', + 'indoor-step-message-stairs-up': 'Stairs up', inquiry: 'How did you find the new Journey Planner? Please tell us!', instructions: 'Instructions', 'is-open': 'Open:', @@ -1157,11 +1165,13 @@ const translations = { 'itinerary-feedback-message': "Couldn't find what you were looking for?", 'itinerary-feedback-placeholder': 'Description (optional)', 'itinerary-hide-alternative-legs': 'Hide alternatives', + 'itinerary-hide-indoor-route': 'Hide the indoor route', 'itinerary-hide-stops': 'Hide stops', 'itinerary-in-the-past': 'The route search falls within a period that is in the past.', 'itinerary-in-the-past-link': 'Depart now ›', 'itinerary-in-the-past-title': 'The route options cannot be displayed', + 'itinerary-indoor-route': 'Indoor route', 'itinerary-page.description': 'Itinerary', 'itinerary-page.hide-details': 'Hide itinerary details', 'itinerary-page.itineraries-loaded': 'Search results downloaded', @@ -2450,6 +2460,14 @@ const translations = { 'in-addition': 'Lisäksi', 'include-estonia': 'Sisällytä Viron liikenne', 'index.title': 'Reittiopas', + 'indoor-step-message-elevator': 'Hissi', + 'indoor-step-message-elevator-to-floor': 'Hissi kerrokseen {toLevelName}', + 'indoor-step-message-escalator': 'Liukuportaat', + 'indoor-step-message-escalator-down': 'Liukuportaat alaspäin', + 'indoor-step-message-escalator-up': 'Liukuportaat ylöspäin', + 'indoor-step-message-stairs': 'Portaat', + 'indoor-step-message-stairs-down': 'Portaat alaspäin', + 'indoor-step-message-stairs-up': 'Portaat ylöspäin', inquiry: 'Mitä pidät uudesta Reittioppaasta? Kerro se meille! ', instructions: 'Ohjeet', 'is-open': 'Avoinna:', @@ -2486,10 +2504,12 @@ const translations = { 'itinerary-feedback-message': 'Etkö löytänyt mitä etsit?', 'itinerary-feedback-placeholder': 'Kuvaus (valinnainen)', 'itinerary-hide-alternative-legs': 'Piilota vaihtoehdot', + 'itinerary-hide-indoor-route': 'Piilota kulkureitti sisällä', 'itinerary-hide-stops': 'Piilota pysäkit', 'itinerary-in-the-past': 'Reittihaun ajankohta on menneisyydessä.', 'itinerary-in-the-past-link': 'Muuta lähtöajaksi nyt ›', 'itinerary-in-the-past-title': 'Reittivaihtoehtoja ei voida näyttää', + 'itinerary-indoor-route': 'Kulkureitti sisällä', 'itinerary-page.description': 'Reittiohje', 'itinerary-page.hide-details': 'Piilota reittiohje', 'itinerary-page.itineraries-loaded': 'Hakutulokset ladattu', @@ -5402,6 +5422,14 @@ const translations = { 'in-addition': 'Även', 'include-estonia': 'Inkludera Estland', 'index.title': 'Reseplaneraren', + 'indoor-step-message-elevator': 'Hiss', + 'indoor-step-message-elevator-to-floor': 'Hiss till våning {toLevelName}', + 'indoor-step-message-escalator': 'Rulltrappa', + 'indoor-step-message-escalator-down': 'Rulltrappa nedåt', + 'indoor-step-message-escalator-up': 'Rulltrappa uppåt', + 'indoor-step-message-stairs': 'Trappa', + 'indoor-step-message-stairs-down': 'Trappa nedåt', + 'indoor-step-message-stairs-up': 'Trappa uppåt', inquiry: 'Vad tycker du om den nya Reseplaneraren. Berätta för oss!', instructions: 'Anvisningar', 'is-open': 'Öppet:', @@ -5440,10 +5468,12 @@ const translations = { 'itinerary-feedback-message': 'Hittade du inte vad du sökte?', 'itinerary-feedback-placeholder': 'Beskrivning (valfri)', 'itinerary-hide-alternative-legs': 'Dölj alternativen', + 'itinerary-hide-indoor-route': 'Dölj gångrutt inomhus', 'itinerary-hide-stops': 'Dölj hållplatserna', 'itinerary-in-the-past': 'Datumet kan inte vara i det förflutna.', 'itinerary-in-the-past-link': 'Jag vill åka nu ›', 'itinerary-in-the-past-title': 'Ruttalternativen kan inte visas', + 'itinerary-indoor-route': 'Gångrutt inomhus', 'itinerary-page.description': 'Ruttinformation', 'itinerary-page.hide-details': 'Göm ruttbeskrivningen', 'itinerary-page.itineraries-loaded': 'Ruttbeskrivningen laddade', diff --git a/app/util/clusterUtils.js b/app/util/clusterUtils.js new file mode 100644 index 0000000000..1c2d4081bc --- /dev/null +++ b/app/util/clusterUtils.js @@ -0,0 +1,13 @@ +/** + * Create Features for a given list of objects + */ +export function createFeatureObjects(objects) { + return objects.map(object => ({ + type: 'Feature', + properties: { ...object.properties }, + geometry: { + type: 'Point', + coordinates: [object.lat, object.lon], + }, + })); +} diff --git a/app/util/indoorUtils.js b/app/util/indoorUtils.js index 127e601fa8..9d4560c4ab 100644 --- a/app/util/indoorUtils.js +++ b/app/util/indoorUtils.js @@ -1,3 +1,10 @@ +import { + IndoorRouteLegType, + RelativeDirection, + VerticalDirection, +} from '../constants'; +import { addAnalyticsEvent } from './analyticsUtils'; + export function subwayTransferUsesSameStation(prevLeg, nextLeg) { return ( prevLeg?.mode === 'SUBWAY' && @@ -6,3 +13,150 @@ export function subwayTransferUsesSameStation(prevLeg, nextLeg) { nextLeg.from.stop.parentStation?.gtfsId ); } + +const iconMappings = { + elevator: 'icon_elevator', + 'elevator-filled': 'icon_elevator_filled', + escalator: 'icon_escalator_down', + 'escalator-filled': 'icon_escalator_down_filled', + stairs: 'icon_stairs_down', + 'stairs-filled': 'icon_stairs_down_filled', + 'stairs-down': 'icon_stairs_down', + 'stairs-down-filled': 'icon_stairs_down_filled', + 'stairs-up': 'icon_stairs_up', + 'stairs-up-filled': 'icon_stairs_up_filled', + 'escalator-down': 'icon_escalator_down_arrow', + 'escalator-down-filled': 'icon_escalator_down_arrow_filled', + 'escalator-up': 'icon_escalator_up_arrow', + 'escalator-up-filled': 'icon_escalator_up_arrow_filled', +}; + +export function getVerticalTransportationUseIconId( + verticalDirection, + relativeDirection, + filled, +) { + if ( + verticalDirection === undefined || + verticalDirection === VerticalDirection.Unknown || + relativeDirection === RelativeDirection.Elevator + ) { + return iconMappings[ + `${relativeDirection.toLowerCase()}${filled ? '-filled' : ''}` + ]; + } + return iconMappings[ + `${relativeDirection.toLowerCase()}-${verticalDirection.toLowerCase()}${ + filled ? '-filled' : '' + }` + ]; +} + +export function getEntranceObject(previousLeg, leg) { + const entranceObjects = leg?.steps + ?.map((step, index) => ({ ...step, index })) + .filter( + step => + // eslint-disable-next-line no-underscore-dangle + step?.feature?.__typename === 'Entrance' || step?.feature?.code, + ); + // Select the entrance to the outside if there are multiple entrances + const entranceObject = + previousLeg?.mode === 'SUBWAY' + ? entranceObjects[entranceObjects.length - 1] + : entranceObjects[0]; + + return entranceObject; +} + +export function getEntranceStepIndex(previousLeg, leg) { + return getEntranceObject(previousLeg, leg)?.index; +} + +export function getIndoorSteps(previousLeg, leg) { + const entranceIndex = getEntranceStepIndex(previousLeg, leg); + if (!entranceIndex) { + return []; + } + // Outdoor routing starts from entrance. + if (leg.mode === 'WALK' && previousLeg?.mode === 'SUBWAY') { + return leg.steps.slice(0, entranceIndex + 1); + } + // Indoor routing starts from entrance. + return leg.steps.slice(entranceIndex); +} + +export function isVerticalTransportationUse(relativeDirection) { + return ( + relativeDirection === RelativeDirection.Elevator || + relativeDirection === RelativeDirection.Escalator || + relativeDirection === RelativeDirection.Stairs + ); +} + +export function getIndoorStepsWithVerticalTransportationUse(previousLeg, leg) { + return getIndoorSteps(previousLeg, leg).filter(step => + isVerticalTransportationUse(step?.relativeDirection), + ); +} + +export function getIndoorRouteLegType(previousLeg, leg, nextLeg) { + const indoorSteps = getIndoorStepsWithVerticalTransportationUse( + previousLeg, + leg, + ); + if ( + indoorSteps.length > 0 && + leg.mode === 'WALK' && + previousLeg?.mode === 'SUBWAY' + ) { + return IndoorRouteLegType.StepsBeforeEntranceInside; + } + if ( + indoorSteps.length > 0 && + leg.mode === 'WALK' && + nextLeg?.mode === 'SUBWAY' + ) { + return IndoorRouteLegType.StepsAfterEntranceInside; + } + return IndoorRouteLegType.NoStepsInside; +} + +export function getIndoorRouteTranslationId( + relativeDirection, + verticalDirection, + toLevelName, +) { + if (relativeDirection === RelativeDirection.Elevator && toLevelName) { + return 'indoor-step-message-elevator-to-floor'; + } + return `indoor-step-message-${relativeDirection.toLowerCase()}${ + verticalDirection && + verticalDirection !== VerticalDirection.Unknown && + relativeDirection !== RelativeDirection.Elevator + ? `-${verticalDirection.toLowerCase()}` + : '' + }`; +} + +export function getEntranceWheelchairAccessibility(leg) { + return leg?.steps?.find( + // eslint-disable-next-line no-underscore-dangle + step => + // eslint-disable-next-line no-underscore-dangle + step?.feature?.__typename === 'Entrance' || + step?.feature?.wheelchairAccessible, + )?.feature?.wheelchairAccessible; +} + +export function getStepFocusAction(lat, lon, focusToPoint) { + return e => { + e.stopPropagation(); + focusToPoint(lat, lon); + addAnalyticsEvent({ + category: 'Itinerary', + action: 'ZoomMapToStep', + name: null, + }); + }; +} diff --git a/app/util/mapIconUtils.js b/app/util/mapIconUtils.js index 789ad693bd..bfe4791199 100644 --- a/app/util/mapIconUtils.js +++ b/app/util/mapIconUtils.js @@ -3,8 +3,8 @@ import ReactDOM from 'react-dom'; import ReactDOMServer from 'react-dom/server'; import glfun from './glfun'; import { transitIconName } from './modeUtils'; -import { ParkTypes, TransportMode } from '../constants'; import { getModeIconColor } from './colorUtils'; +import { ParkTypes, TransportMode } from '../constants'; /** * Corresponds to an arc forming a full circle (Math.PI * 2). @@ -850,3 +850,11 @@ export function renderAsString(children) { ReactDOM.unmountComponentAtNode(div); return html; } + +export function getIndexedIconFields(zoom, index) { + const iconEdgeSize = Math.max(getCaseRadius(zoom) * 2, 8); + const iconSize = [iconEdgeSize, iconEdgeSize]; + const iconAnchor = [iconEdgeSize / 2, (1.5 + index) * iconEdgeSize + index]; + + return { iconSize, iconAnchor }; +} diff --git a/app/util/shapes.js b/app/util/shapes.js index b49c7ed20e..358c58ca87 100644 --- a/app/util/shapes.js +++ b/app/util/shapes.js @@ -1,5 +1,9 @@ import PropTypes from 'prop-types'; -import { PlannerMessageType } from '../constants'; +import { + VerticalDirection, + PlannerMessageType, + RelativeDirection, +} from '../constants'; export const agencyShape = PropTypes.shape({ name: PropTypes.string, @@ -211,10 +215,50 @@ export const legTimeShape = PropTypes.shape({ }); export const entranceShape = PropTypes.shape({ + __typename: PropTypes.oneOf(['Entrance']).isRequired, publicCode: PropTypes.string, wheelchairAccessible: PropTypes.string, }); +export const elevatorUseShape = PropTypes.shape({ + __typename: PropTypes.oneOf(['ElevatorUse']).isRequired, + from: PropTypes.shape({ + level: PropTypes.number, + name: PropTypes.string, + }), + verticalDirection: PropTypes.oneOf(Object.values(VerticalDirection)), + to: PropTypes.shape({ + level: PropTypes.number, + name: PropTypes.string, + }), +}); + +export const escalatorUseShape = PropTypes.shape({ + __typename: PropTypes.oneOf(['EscalatorUse']).isRequired, + from: PropTypes.shape({ + level: PropTypes.number, + name: PropTypes.string, + }), + verticalDirection: PropTypes.oneOf(Object.values(VerticalDirection)), + to: PropTypes.shape({ + level: PropTypes.number, + name: PropTypes.string, + }), +}); + +export const stairsUseShape = PropTypes.shape({ + __typename: PropTypes.oneOf(['StairsUse']).isRequired, + from: PropTypes.shape({ + level: PropTypes.number, + name: PropTypes.string, + }), + verticalDirection: PropTypes.oneOf(Object.values(VerticalDirection)), + to: PropTypes.shape({ + level: PropTypes.number, + name: PropTypes.string, + }), +}); + export const legShape = PropTypes.shape({ start: legTimeShape, end: legTimeShape, @@ -229,7 +273,13 @@ export const legShape = PropTypes.shape({ fare: fareShape, steps: PropTypes.arrayOf( PropTypes.shape({ - entrance: entranceShape, + feature: PropTypes.oneOfType([ + entranceShape, + elevatorUseShape, + escalatorUseShape, + stairsUseShape, + ]), + relativeDirection: PropTypes.oneOf(Object.values(RelativeDirection)), lat: PropTypes.number, lon: PropTypes.number, }), diff --git a/build/schema.graphql b/build/schema.graphql index d088f56ca7..2d91baa330 100644 --- a/build/schema.graphql +++ b/build/schema.graphql @@ -12,12 +12,23 @@ This is only worth it when the execution is long running, i.e. more than ~50 mil """ directive @async on FIELD_DEFINITION +"This directive allows results to be deferred during execution" +directive @defer( + "Deferred behaviour is controlled by this argument" + if: Boolean! = true, + "A unique label that represents the fragment being deferred" + label: String + ) on FRAGMENT_SPREAD | INLINE_FRAGMENT + "Marks the field, argument, input field or enum value as deprecated" directive @deprecated( "The reason for the deprecation" - reason: String = "No longer supported" + reason: String! = "No longer supported" ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION +"This directive disables error propagation when a non nullable field returns null for the given operation." +directive @experimental_disableErrorPropagation on QUERY | MUTATION | SUBSCRIPTION + "Directs the executor to include this field or fragment only when the `if` argument is true" directive @include( "Included when true." @@ -39,6 +50,9 @@ directive @specifiedBy( url: String! ) on SCALAR +"Add timing data to prometheus, if Actuator API is enabled" +directive @timingData on FIELD_DEFINITION + "A fare product (a ticket) to be bought by a passenger" interface FareProduct { "Identifier for the fare product." @@ -73,9 +87,18 @@ interface PlaceInterface { "Entity related to an alert" union AlertEntity = Agency | Pattern | Route | RouteType | Stop | StopOnRoute | StopOnTrip | Trip | Unknown +"Scheduled times for a trip on a service date for a stop location." +union CallScheduledTime = ArrivalDepartureTime | TimeWindow + +"Location where a transit vehicle stops at." +union CallStopLocation = Location | LocationGroup | Stop + "Rental place union that represents either a VehicleRentalStation or a RentalVehicle" union RentalPlace = RentalVehicle | VehicleRentalStation +"A feature for a step" +union StepFeature = ElevatorUse | Entrance | EscalatorUse | StairsUse + union StopPosition = PositionAtStop | PositionBetweenStops "A public transport agency" @@ -116,7 +139,7 @@ type Alert implements Node { Agency affected by the disruption. Note that this value is present only if the disruption has an effect on all operations of the agency (e.g. in case of a strike). """ - agency: Agency @deprecated(reason : "Alert can have multiple affected entities now instead of there being duplicate alerts\nfor different entities. This will return only one of the affected agencies.\nUse entities instead.") + agency: Agency @deprecated(reason : "Alert can have multiple affected entities now instead of there being duplicate alerts\nfor different entities. This will return only one of the affected agencies.\nUse entities instead.") @timingData "Alert cause" alertCause: AlertCauseType "Long description of the alert" @@ -125,7 +148,7 @@ type Alert implements Node { language: String ): String! "Long descriptions of the alert in all different available languages" - alertDescriptionTextTranslations: [TranslatedString!]! @deprecated(reason : "Use `alertDescriptionText` instead. `accept-language` header can be used for translations or the `language` parameter on the `alertDescriptionText` field.") + alertDescriptionTextTranslations: [TranslatedString!]! @deprecated(reason : "Use `alertDescriptionText` instead. `accept-language` header can be used for translations or the `language` parameter on the `alertDescriptionText` field.") @timingData "Alert effect" alertEffect: AlertEffectType "hashcode from the original GTFS-RT alert" @@ -136,7 +159,7 @@ type Alert implements Node { language: String ): String "Header of the alert in all different available languages" - alertHeaderTextTranslations: [TranslatedString!]! @deprecated(reason : "Use `alertHeaderText` instead. `accept-language` header can be used for translations or the `language` parameter on the `alertHeaderText` field.") + alertHeaderTextTranslations: [TranslatedString!]! @deprecated(reason : "Use `alertHeaderText` instead. `accept-language` header can be used for translations or the `language` parameter on the `alertHeaderText` field.") @timingData "Alert severity level" alertSeverityLevel: AlertSeverityLevelType "Url with more information" @@ -145,7 +168,7 @@ type Alert implements Node { language: String ): String "Url with more information in all different available languages" - alertUrlTranslations: [TranslatedString!]! @deprecated(reason : "Use `alertUrl` instead. `accept-language` header can be used for translations or the `language` parameter on the `alertUrl` field.") + alertUrlTranslations: [TranslatedString!]! @deprecated(reason : "Use `alertUrl` instead. `accept-language` header can be used for translations or the `language` parameter on the `alertUrl` field.") @timingData "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" @@ -157,13 +180,21 @@ type Alert implements Node { "Global object ID provided by Relay. This value can be used to refetch this object using **node** query." id: ID! "Patterns affected by the disruption" - patterns: [Pattern] @deprecated(reason : "This will always return an empty list. Use entities instead.") + patterns: [Pattern] @deprecated(reason : "This will always return an empty list. Use entities instead.") @timingData "Route affected by the disruption" - route: Route @deprecated(reason : "Alert can have multiple affected entities now instead of there being duplicate alerts\nfor different entities. This will return only one of the affected routes.\nUse entities instead.") + route: Route @deprecated(reason : "Alert can have multiple affected entities now instead of there being duplicate alerts\nfor different entities. This will return only one of the affected routes.\nUse entities instead.") @timingData "Stop affected by the disruption" - stop: Stop @deprecated(reason : "Alert can have multiple affected entities now instead of there being duplicate alerts\nfor different entities. This will return only one of the affected stops.\nUse entities instead.") + stop: Stop @deprecated(reason : "Alert can have multiple affected entities now instead of there being duplicate alerts\nfor different entities. This will return only one of the affected stops.\nUse entities instead.") @timingData "Trip affected by the disruption" - trip: Trip @deprecated(reason : "Alert can have multiple affected entities now instead of there being duplicate alerts\nfor different entities. This will return only one of the affected trips.\nUse entities instead.") + trip: Trip @deprecated(reason : "Alert can have multiple affected entities now instead of there being duplicate alerts\nfor different entities. This will return only one of the affected trips.\nUse entities instead.") @timingData +} + +"Arrival and departure time (not relative to midnight)." +type ArrivalDepartureTime { + "Arrival time as an ISO-8601-formatted datetime." + arrival: OffsetDateTime + "Departure time as an ISO-8601-formatted datetime." + departure: OffsetDateTime } "Bike park represents a location where bicycles can be parked." @@ -241,7 +272,7 @@ type BikeRentalStation implements Node & PlaceInterface { """ spacesAvailable: Int "A description of the current state of this bike rental station, e.g. \"Station on\"" - state: String @deprecated(reason : "Use operative instead") + state: String @deprecated(reason : "Use operative instead") @timingData "ID of the bike rental station" stationId: String } @@ -279,12 +310,16 @@ type BookingInfo { earliestBookingTime: BookingTime "When is the latest time the service can be booked" latestBookingTime: BookingTime + "Maximum duration before travel to make the request." + maximumBookingNotice: Duration "Maximum number of seconds before travel to make the request" - maximumBookingNoticeSeconds: Long + maximumBookingNoticeSeconds: Long @deprecated(reason : "Use `maximumBookingNotice`") @timingData "A general message for those booking the service" message: String + "Minimum duration before travel to make the request" + minimumBookingNotice: Duration "Minimum number of seconds before travel to make the request" - minimumBookingNoticeSeconds: Long + minimumBookingNoticeSeconds: Long @deprecated(reason : "Use `minimumBookingNotice`") @timingData "A message specific to the pick up" pickupMessage: String } @@ -297,6 +332,20 @@ type BookingTime { time: String } +"Real-time estimates for arrival and departure times for a stop location." +type CallRealTime { + "Real-time estimates for the arrival." + arrival: EstimatedTime + "Real-time estimates for the departure." + departure: EstimatedTime +} + +"What is scheduled for a trip on a service date for a stop location." +type CallSchedule { + "Scheduled time for a trip on a service date for a stop location." + time: CallScheduledTime +} + "Car park represents a location where cars can be parked." type CarPark implements Node & PlaceInterface { "ID of the car park" @@ -330,17 +379,17 @@ type CarPark implements Node & PlaceInterface { "Cluster is a list of stops grouped by name and proximity" type Cluster implements Node { "ID of the cluster" - gtfsId: String! + gtfsId: String! @deprecated(reason : "Not implemented") "Global object ID provided by Relay. This value can be used to refetch this object using **node** query." - id: ID! + id: ID! @deprecated(reason : "Not implemented") "Latitude of the center of this cluster (i.e. average latitude of stops in this cluster)" - lat: Float! + lat: Float! @deprecated(reason : "Not implemented") "Longitude of the center of this cluster (i.e. average longitude of stops in this cluster)" - lon: Float! + lon: Float! @deprecated(reason : "Not implemented") "Name of the cluster" - name: String! + name: String! @deprecated(reason : "Not implemented") "List of stops in the cluster" - stops: [Stop!] + stops: [Stop!] @deprecated(reason : "Not implemented") } "Contact information for booking an on-demand or flexible service." @@ -451,11 +500,78 @@ type DepartureRow implements Node & PlaceInterface { ): [Stoptime] } +""" +A (possibly discounted) fare product that requires another fare product to be purchased previously +in order to be valid. + +For example, when taking the train into a city, you might get a discounted "transfer fare" when +switching to the bus for the second leg. +""" +type DependentFareProduct implements FareProduct { + "The fare product is _not_ valid without purchasing at least _one_ of" + dependencies(filter: DependentFareProductFilter = ALL): [FareProduct!]! + id: String! + """ + The 'medium' that this product applies to, for example "Oyster Card" or "Berlin Ticket App". + + This communicates to riders that a specific way of buying or keeping this product is required. + """ + medium: FareMedium + "Human readable name of the product, for example example \"Day pass\" or \"Single ticket\"." + name: String! + "The price of the product" + price: Money! + "The category of riders this product applies to, for example students or pensioners." + riderCategory: RiderCategory +} + +"A single use of an elevator." +type ElevatorUse { + "The level the use begins at." + from: Level + "The level the use ends at." + to: Level + verticalDirection: VerticalDirection! +} + type Emissions { "CO₂ emissions in grams." co2: Grams } +"Station entrance or exit, originating from OSM or GTFS data." +type Entrance { + "ID of the entrance in the format of `FeedId:EntranceId`. If the `FeedId` is `osm`, the entrance originates from OSM data." + entranceId: String! + "Name of the entrance or exit." + name: String + "Short text or a number that identifies the entrance or exit for passengers. For example, `A` or `B`." + publicCode: String + "Whether the entrance or exit is accessible by wheelchair" + wheelchairAccessible: WheelchairBoarding +} + +"A single use of an escalator." +type EscalatorUse { + "The level the use begins at." + from: Level + "The level the use ends at." + to: Level + verticalDirection: VerticalDirection! +} + +"Real-time estimates for an arrival or departure at a certain place." +type EstimatedTime { + """ + The delay or "earliness" of the vehicle at a certain place. This estimate can change quite often. + + If the vehicle is early then this is a negative duration. + """ + delay: Duration! + "The estimate for a call event (such as arrival or departure) at a certain place. This estimate can change quite often." + time: OffsetDateTime! +} + "A 'medium' that a fare product applies to, for example cash, 'Oyster Card' or 'DB Navigator App'." type FareMedium { "ID of the medium" @@ -581,12 +697,12 @@ type Itinerary { "Time when the user leaves arrives at the destination." end: OffsetDateTime "Time when the user arrives to the destination. Format: Unix timestamp in milliseconds." - endTime: Long @deprecated(reason : "Use `end` instead which includes timezone information.") + endTime: Long @deprecated(reason : "Use `end` instead which includes timezone information.") @timingData """ Information about the fares for this itinerary. This is primarily a GTFS Fares V1 interface and always returns an empty list. Use the leg's `fareProducts` instead. """ - fares: [fare] @deprecated(reason : "Use the leg's `fareProducts`.") + fares: [fare] @deprecated(reason : "Use the leg's `fareProducts`.") @timingData "Generalized cost of the itinerary. Used for debugging search results." generalizedCost: Int """ @@ -607,7 +723,7 @@ type Itinerary { "Time when the user leaves from the origin." start: OffsetDateTime "Time when the user leaves from the origin. Format: Unix timestamp in milliseconds." - startTime: Long @deprecated(reason : "Use `start` instead which includes timezone information.") + startTime: Long @deprecated(reason : "Use `start` instead which includes timezone information.") @timingData """ A list of system notices. Contains debug information for itineraries. One use-case is to run a routing search with 'debugItineraryFilter: true'. @@ -642,13 +758,13 @@ type Leg { stop in this leg, i.e. scheduled time of arrival at alighting stop = `endTime - arrivalDelay` """ - arrivalDelay: Int @deprecated(reason : "Use `start.estimated.delay` instead.") + arrivalDelay: Int @deprecated(reason : "Use `start.estimated.delay` instead.") @timingData """ For transit leg, the offset from the scheduled departure time of the boarding stop in this leg, i.e. scheduled time of departure at boarding stop = `startTime - departureDelay` """ - departureDelay: Int @deprecated(reason : "Use `end.estimated.delay` instead.") + departureDelay: Int @deprecated(reason : "Use `end.estimated.delay` instead.") @timingData "The distance traveled while traversing the leg in meters." distance: Float """ @@ -663,7 +779,7 @@ type Leg { "The time when the leg ends including real-time information, if available." end: LegTime! "The date and time when this leg ends. Format: Unix timestamp in milliseconds." - endTime: Long @deprecated(reason : "Use `end.estimated.time` instead which contains timezone information.") + endTime: Long @deprecated(reason : "Use `end.estimated.time` instead which contains timezone information.") @timingData """ Fare products are purchasable tickets which may have an optional fare container or rider category that limits who can buy them or how. @@ -686,6 +802,9 @@ type Leg { 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. + + The identifier is valid for a maximum of 2 years, but sometimes it will fail after a few hours. + We do not recommend storing IDs for a long time. """ id: String """ @@ -695,18 +814,20 @@ type Leg { """ interlineWithPreviousLeg: Boolean "Whether the destination of this leg (field `to`) is one of the intermediate places specified in the query." - intermediatePlace: Boolean + intermediatePlace: Boolean @deprecated(reason : "Not implemented") """ For transit legs, intermediate stops between the Place where the leg originates and the Place where the leg ends. For non-transit legs, null. - Returns Place type, which has fields for e.g. departure and arrival times """ - intermediatePlaces: [Place] + intermediatePlaces: [Place] @deprecated(reason : "Use `leg.stopCalls` instead") """ For transit legs, intermediate stops between the Place where the leg originates and the Place where the leg ends. For non-transit legs, null. + + The `include` parameter allows filtering of the returned places by stop type. If not provided, the + field returns all types. An empty list is not permitted. """ - intermediateStops: [Stop] + intermediateStops(include: [StopType!]): [Stop] @deprecated(reason : "Use `leg.stopCalls` instead") "The leg's geometry." legGeometry: Geometry "The mode (e.g. `WALK`) used when traversing this leg." @@ -758,7 +879,7 @@ type Leg { realTime: Boolean "State of real-time data" realtimeState: RealtimeState - "Whether this leg is traversed with a rented bike." + "Whether this leg is traversed with a rented vehicle." rentedBike: Boolean "Estimate of a hailed ride like Uber." rideHailingEstimate: RideHailingEstimate @@ -769,9 +890,15 @@ type Leg { "The time when the leg starts including real-time information, if available." start: LegTime! "The date and time when this leg begins. Format: Unix timestamp in milliseconds." - startTime: Long @deprecated(reason : "Use `start.estimated.time` instead which contains timezone information.") + startTime: Long @deprecated(reason : "Use `start.estimated.time` instead which contains timezone information.") @timingData "The turn-by-turn navigation instructions." steps: [step] + """ + All the stop calls (stop times) of this _leg_ (but not trip) including the boarding and alighting one. + + Non-transit legs return an empty list. + """ + stopCalls: [StopCall!]! "The Place where the leg ends." to: Place! "Whether this leg is a transit leg or not." @@ -793,6 +920,14 @@ type LegTime { scheduledTime: OffsetDateTime! } +"A level with a name and comparable number. Levels can sometimes contain half levels, e.g. '1.5'." +type Level { + "0-based comparable number where 0 is the ground level." + level: Float! + "Name of the level, e.g. 'M', 'P1', or '1'. Can be equal or different to the numerical representation." + name: String! +} + "A span of time." type LocalTimeSpan { "The start of the time timespan as seconds from midnight." @@ -809,6 +944,34 @@ type LocalTimeSpanDate { timeSpans: [LocalTimeSpan] } +""" +A stop that isn't a fixed point but zone where passengers can board or alight anywhere. + +This is mostly used by demand-responsive services. +""" +type Location { + "The geometry representing the geographic extend of the location." + geometry: StopGeometries! + "ÌD of the location in format `FeedId:LocationId`" + gtfsId: String! + "Optional name of the location." + name: String +} + +""" +A group of fixed stops that are visited in an arbitrary order. + +This is mostly used by demand-responsive services. +""" +type LocationGroup { + "ÌD of the location group in format `FeedId:LocationGroupId`" + gtfsId: String! + "The stops that are part of the group (cannot be stations)." + members: [Stop!]! + "Optional name of the group." + name: String +} + "An amount of money." type Money { """ @@ -919,20 +1082,20 @@ type Place { """ arrival: LegTime "The time the rider will arrive at the place. Format: Unix timestamp in milliseconds." - arrivalTime: Long! @deprecated(reason : "Use `arrival` which includes timezone information.") + arrivalTime: Long! @deprecated(reason : "Use `arrival` which includes timezone information.") @timingData "The bike parking related to the place" - bikePark: BikePark @deprecated(reason : "bikePark is deprecated. Use vehicleParking instead.") + bikePark: BikePark @deprecated(reason : "bikePark is deprecated. Use vehicleParking instead.") @timingData "The bike rental station related to the place" - bikeRentalStation: BikeRentalStation @deprecated(reason : "Use vehicleRentalStation and rentalVehicle instead") + bikeRentalStation: BikeRentalStation @deprecated(reason : "Use vehicleRentalStation and rentalVehicle instead") @timingData "The car parking related to the place" - carPark: CarPark @deprecated(reason : "carPark is deprecated. Use vehicleParking instead.") + carPark: CarPark @deprecated(reason : "carPark is deprecated. Use vehicleParking instead.") @timingData """ The time the rider will depart the place. This also includes real-time information if available. """ departure: LegTime "The time the rider will depart the place. Format: Unix timestamp in milliseconds." - departureTime: Long! @deprecated(reason : "Use `departure` which includes timezone information.") + departureTime: Long! @deprecated(reason : "Use `departure` which includes timezone information.") @timingData "Latitude of the place (WGS 84)" lat: Float! "Longitude of the place (WGS 84)" @@ -966,7 +1129,7 @@ type Place { Type of vertex. (Normal, Bike sharing station, Bike P+R, Transit stop) Mostly used for better localization of bike sharing and P+R station names """ - vertexType: VertexType + vertexType: VertexType @deprecated(reason : "Unmaintained. Use `stop`, `rentalVehicle`, `vehicleParking` or `vehicleRentalStation` to tell which type it is.") @timingData } type Plan { @@ -982,13 +1145,8 @@ type Plan { messageEnums: [String]! "A list of possible error messages in cleartext" messageStrings: [String]! - """ - This is the suggested search time for the "next page" or time window. Insert it together - with the searchWindowUsed in the request to get a new set of trips following in the - search-window AFTER the current search. No duplicate trips should be returned, unless a trip - is delayed and new real-time data is available. - """ - nextDateTime: Long @deprecated(reason : "Use nextPageCursor instead") + "This will not be available after Match 2026." + nextDateTime: Long @deprecated(reason : "Use nextPageCursor instead") @timingData """ Use the cursor to go to the next "page" of itineraries. Copy the cursor from the last response to the pageCursor query parameter and keep the original request as is. This will enable you to @@ -997,13 +1155,8 @@ type Plan { This is only usable when public transportation mode(s) are included in the query. """ nextPageCursor: String - """ - This is the suggested search time for the "previous page" or time window. Insert it together - with the searchWindowUsed in the request to get a new set of trips preceding in the - search-window BEFORE the current search. No duplicate trips should be returned, unless a trip - is delayed and new real-time data is available. - """ - prevDateTime: Long @deprecated(reason : "Use previousPageCursor instead") + "This will not be available after Match 2026." + prevDateTime: Long @deprecated(reason : "Use previousPageCursor instead") @timingData """ Use the cursor to go to the previous "page" of itineraries. Copy the cursor from the last response to the pageCursor query parameter and keep the original request otherwise as is. @@ -1021,7 +1174,7 @@ type Plan { The unit is seconds. """ - searchWindowUsed: Long + searchWindowUsed: Long @deprecated(reason : "This is not needed for debugging, and is misleading if the window is cropped.") "The destination" to: Place! } @@ -1116,11 +1269,11 @@ type QueryType { stop: [String!] ): [Alert] "Get a single bike park based on its ID, i.e. value of field `bikeParkId`" - bikePark(id: String!): BikePark @deprecated(reason : "bikePark is deprecated. Use vehicleParking instead.") + bikePark(id: String!): BikePark @deprecated(reason : "bikePark is deprecated. Use vehicleParking instead.") @timingData "Get all bike parks" - bikeParks: [BikePark] @deprecated(reason : "bikeParks is deprecated. Use vehicleParkings instead.") + bikeParks: [BikePark] @deprecated(reason : "bikeParks is deprecated. Use vehicleParkings instead.") @timingData "Get a single bike rental station based on its ID, i.e. value of field `stationId`" - bikeRentalStation(id: String!): BikeRentalStation @deprecated(reason : "Use rentalVehicle or vehicleRentalStation instead") + bikeRentalStation(id: String!): BikeRentalStation @deprecated(reason : "Use rentalVehicle or vehicleRentalStation instead") @timingData "Get all bike rental stations" bikeRentalStations( """ @@ -1129,32 +1282,61 @@ type QueryType { the returned list will contain `null` values. """ ids: [String] - ): [BikeRentalStation] @deprecated(reason : "Use rentalVehicles or vehicleRentalStations instead") - "Get cancelled TripTimes." + ): [BikeRentalStation] @deprecated(reason : "Use rentalVehicles or vehicleRentalStations instead") @timingData + """ + Get pages of canceled trips. Planned cancellations are not currently supported. Limiting the number of + returned trips with either `first` or `last` is highly recommended since the number of returned trips + can be really high when there is a strike affecting the transit services, for example. Follows the + [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm). + """ + canceledTrips( + """ + This parameter is part of the [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm) + and should be used together with the `first` parameter. + """ + after: String, + """ + This parameter is part of the [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm) + and should be used together with the `last` parameter. + """ + before: String, + """ + Limits how many trips are returned. This parameter is part of the + [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm) and can be used together with + the `after` parameter. + """ + first: Int, + """ + This parameter is part of the [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm) + and should be used together with the `before` parameter. + """ + last: Int + ): TripOnServiceDateConnection + "Get canceled TripTimes." cancelledTripTimes( "Feed feedIds (e.g. [\"HSL\"])." feeds: [String], """ - Only cancelled trip times that have last stop arrival time at maxArrivalTime + Only canceled trip times that have last stop arrival time at maxArrivalTime or before are returned. Format: seconds since midnight of maxDate. """ maxArrivalTime: Int, - "Only cancelled trip times scheduled to run on maxDate or before are returned. Format: \"2019-12-23\" or \"20191223\"." + "Only canceled trip times scheduled to run on maxDate or before are returned. Format: \"2019-12-23\" or \"20191223\"." maxDate: String, """ - Only cancelled trip times that have first stop departure time at + Only canceled trip times that have first stop departure time at maxDepartureTime or before are returned. Format: seconds since midnight of maxDate. """ maxDepartureTime: Int, """ - Only cancelled trip times that have last stop arrival time at minArrivalTime + Only canceled trip times that have last stop arrival time at minArrivalTime or after are returned. Format: seconds since midnight of minDate. """ minArrivalTime: Int, - "Only cancelled trip times scheduled to run on minDate or after are returned. Format: \"2019-12-23\" or \"20191223\"." + "Only canceled trip times scheduled to run on minDate or after are returned. Format: \"2019-12-23\" or \"20191223\"." minDate: String, """ - Only cancelled trip times that have first stop departure time at + Only canceled trip times that have first stop departure time at minDepartureTime or after are returned. Format: seconds since midnight of minDate. """ minDepartureTime: Int, @@ -1164,9 +1346,9 @@ type QueryType { routes: [String], "Trip gtfsIds (e.g. [\"HSL:1098_20190405_Ma_2_1455\"])." trips: [String] - ): [Stoptime] + ): [Stoptime] @deprecated(reason : "`cancelledTripTimes` is not implemented. Use `canceledTrips` instead.") @timingData "Get a single car park based on its ID, i.e. value of field `carParkId`" - carPark(id: String!): CarPark @deprecated(reason : "carPark is deprecated. Use vehicleParking instead.") + carPark(id: String!): CarPark @deprecated(reason : "carPark is deprecated. Use vehicleParking instead.") @timingData "Get all car parks" carParks( """ @@ -1174,11 +1356,11 @@ type QueryType { **Note:** if an id is invalid (or the car park service is unavailable) the returned list will contain `null` values. """ ids: [String] - ): [CarPark] @deprecated(reason : "carParks is deprecated. Use vehicleParkings instead.") + ): [CarPark] @deprecated(reason : "carParks is deprecated. Use vehicleParkings instead.") @timingData "Get a single cluster based on its ID, i.e. value of field `gtfsId`" - cluster(id: String!): Cluster + cluster(id: String!): Cluster @deprecated(reason : "Not implemented") "Get all clusters" - clusters: [Cluster] + clusters: [Cluster] @deprecated(reason : "Not implemented") "Get a single departure row based on its ID (ID format is `FeedId:StopId:PatternId`)" departureRow(id: String!): DepartureRow "Get all available feeds" @@ -1244,7 +1426,7 @@ type QueryType { maxDistance: Int = 2000, "Maximum number of results. Search is stopped when this limit is reached. Default is 20." maxResults: Int = 20 - ): placeAtDistanceConnection @async + ): placeAtDistanceConnection @async @timingData "Fetches an object given its ID" node( "The ID of an object" @@ -1357,7 +1539,7 @@ type QueryType { "When true, real-time updates are ignored during this search. Default value: false" ignoreRealtimeUpdates: Boolean, "An ordered list of intermediate locations to be visited." - intermediatePlaces: [InputCoordinates] @deprecated(reason : "Not implemented in OTP2."), + intermediatePlaces: [InputCoordinates] @deprecated(reason : "Not implemented"), """ How easily bad itineraries are filtered from results. Value 0 (default) disables filtering. Itineraries are filtered if they are worse than another @@ -1424,7 +1606,7 @@ type QueryType { "Preferences for vehicle parking" parking: VehicleParkingInput, "List of routes and agencies which are given higher preference when planning the itinerary" - preferred: InputPreferred, + preferred: InputPreferred @deprecated(reason : "Not implemented"), """ **Consider this argument experimental** – setting this argument to true causes timeouts and unoptimal routes in many cases. @@ -1476,7 +1658,7 @@ type QueryType { travel time of the trip (and therefore arguments `time` and `from` must be used correctly to get meaningful itineraries). """ - startTransitTripId: String @deprecated(reason : "Not implemented in OTP2"), + startTransitTripId: String @deprecated(reason : "Not implemented"), "Time of departure or arrival in format hh:mm:ss. Default value: current time" time: String, """ @@ -1560,7 +1742,7 @@ type QueryType { walkSpeed: Float, "Whether the itinerary must be wheelchair accessible. Default value: false" wheelchair: Boolean - ): Plan @async @deprecated(reason : "Use `planConnection` instead.") + ): Plan @async @deprecated(reason : "Use `planConnection` instead.") @timingData """ Plan (itinerary) search that follows [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm). @@ -1632,8 +1814,8 @@ type QueryType { preferences: PlanPreferencesInput, """ Duration of the search window. This either starts at the defined earliest departure - time or ends at the latest arrival time. If this is not provided, a reasonable - search window is automatically generated. When searching for earlier or later itineraries + time or ends at the latest arrival time. If this is not provided or the value is set as null, + a reasonable search window is automatically generated. When searching for earlier or later itineraries with paging, this search window is no longer used and the new window will be based on how many suggestions were returned in the previous search. The new search window can be shorter or longer than the original search window. Note, itineraries are returned faster @@ -1647,7 +1829,7 @@ type QueryType { searchWindow: Duration, "The list of points the itinerary is required to pass through." via: [PlanViaLocationInput!] - ): PlanConnection @async + ): PlanConnection @async @timingData "Get a single rental vehicle based on its ID, i.e. value of field `vehicleId`" rentalVehicle(id: String!): RentalVehicle "Get all rental vehicles" @@ -1683,7 +1865,7 @@ type QueryType { serviceDates: LocalDateRangeInput, "Only include routes, which use one of these modes" transportModes: [Mode] - ): [Route] + ): [Route] @timingData "Get the time range for which the API has data available" serviceTimeRange: serviceTimeRange "Get a single station based on its ID, i.e. value of field `gtfsId` (format is `FeedId:StopId`)" @@ -1801,6 +1983,10 @@ type RealTimeEstimate { type RentalVehicle implements Node & PlaceInterface { "If true, vehicle is currently available for renting." allowPickupNow: Boolean + "The vehicle should be returned before this deadline." + availableUntil: OffsetDateTime + "Fuel or battery status of the rental vehicle" + fuel: RentalVehicleFuel "Global object ID provided by Relay. This value can be used to refetch this object using **node** query." id: ID! "Latitude of the vehicle (WGS 84)" @@ -1810,7 +1996,7 @@ type RentalVehicle implements Node & PlaceInterface { "Name of the vehicle" name: String! "ID of the rental network." - network: String @deprecated(reason : "Use `networkId` from `rentalNetwork` instead.") + network: String @deprecated(reason : "Use `networkId` from `rentalNetwork` instead.") @timingData "If true, vehicle is not disabled." operative: Boolean "The vehicle rental network information. This is referred as system in the GBFS terminology." @@ -1830,6 +2016,14 @@ type RentalVehicleEntityCounts { total: Int! } +"Rental vehicle fuel represent the current status of the battery or fuel of a rental vehicle" +type RentalVehicleFuel { + "Fuel or battery power remaining in the vehicle. Expressed from 0 to 1." + percent: Ratio + "Range in meters that the vehicle can travel with the current charge or fuel." + range: Int +} + type RentalVehicleType { "The vehicle's general form factor" formFactor: FormFactor @@ -1867,6 +2061,13 @@ type RideHailingProvider { type RiderCategory { "ID of the category" id: String! + """ + If this category is considered the "default" one. In most places this means "Adult" or + "Regular". + Frontends can use this property to display this category more prominently or pre-select this + in a UI. + """ + isDefault: Boolean! "Human readable name of the category." name: String } @@ -1992,6 +2193,15 @@ type RoutingError { inputField: InputField } +"A single use of a set of stairs." +type StairsUse { + "The level the use begins at." + from: Level + "The level the use ends at." + to: Level + verticalDirection: VerticalDirection! +} + """ Stop can represent either a single public transport stop, where passengers can board and/or disembark vehicles, or a station, which contains multiple stops. @@ -2010,7 +2220,7 @@ type Stop implements Node & PlaceInterface { types: [StopAlertType] ): [Alert] "The cluster which this stop is part of" - cluster: Cluster + cluster: Cluster @deprecated(reason : "Not implemented") "Stop code which is visible at the stop" code: String "Description of the stop, usually a street name" @@ -2152,13 +2362,28 @@ type Stop implements Node & PlaceInterface { https://developers.google.com/transit/gtfs/reference/#routestxt and https://developers.google.com/transit/gtfs/reference/extended-route-types """ - vehicleType: Int + vehicleType: Int @deprecated(reason : "Not implemented. Use `vehicleMode`.") @timingData "Whether wheelchair boarding is possible for at least some of vehicles on this stop" wheelchairBoarding: WheelchairBoarding "ID of the zone where this stop is located" zoneId: String } +""" +Represents the time or time window when a specific trip on a specific date arrives to and/or departs +from a specific stop location. + +This may contain real-time information, if available. +""" +type StopCall { + "Real-time estimates for arrival and departure times for this stop location." + realTime: CallRealTime + "Scheduled arrival and departure times for this stop location." + schedule: CallSchedule + "The stop where this arrival/departure happens." + stopLocation: CallStopLocation! +} + type StopGeometries { "Representation of the stop geometries as GeoJSON (https://geojson.org/)" geoJson: GeoJson @@ -2314,6 +2539,12 @@ type TicketType implements Node { zones: [String!] } +"A time window when a vehicle visits a stop, area or group of stops." +type TimeWindow { + end: OffsetDateTime! + start: OffsetDateTime! +} + "Text with language" type TranslatedString { "Two-letter language code (ISO 639-1)" @@ -2336,7 +2567,11 @@ type Trip implements Node { """ types: [TripAlertType] ): [Alert] - "Arrival time to the final stop" + """ + Arrival time to the final stop. If the trip does not run on the given date, + it will return scheduled times from another date. This field is slightly + confusing and will be deprecated when a better replacement is implemented. + """ arrivalStoptime( """ Date for which the arrival time is returned. Format: YYYYMMDD. If this @@ -2347,7 +2582,11 @@ type Trip implements Node { "Whether bikes are allowed on board the vehicle running this trip" bikesAllowed: BikesAllowed blockId: String - "Departure time from the first stop" + """ + Departure time from the first stop. If the trip does not run on the given date, + it will return scheduled times from another date. This field is slightly + confusing and will be deprecated when a better replacement is implemented. + """ departureStoptime( """ Date for which the departure time is returned. Format: YYYYMMDD. If this @@ -2386,6 +2625,12 @@ type Trip implements Node { stops: [Stop!]! "List of times when this trip arrives to or departs from a stop" stoptimes: [Stoptime] + """ + List of times when this trip arrives to or departs from each stop on a given date, or + today if the date is not given. If the trip does not run on the given date, it will + return scheduled times from another date. This field is slightly confusing and + will be deprecated when a better replacement is implemented. + """ stoptimesForDate( "Date for which stoptimes are returned. Format: YYYYMMDD" serviceDate: String @@ -2414,6 +2659,60 @@ type TripOccupancy { occupancyStatus: OccupancyStatus } +"A trip on a specific service date." +type TripOnServiceDate { + "Information related to trip's scheduled arrival to the final stop location. Can contain real-time information." + end: StopCall! + """ + The service date when the trip occurs. + + **Note**: A service date is a technical term useful for transit planning purposes and might not + correspond to a how a passenger thinks of a calendar date. For example, a night bus running + on Sunday morning at 1am to 3am, might have the previous Saturday's service date. + """ + serviceDate: LocalDate! + "Information related to trip's scheduled departure from the first stop location. Can contain real-time information." + start: StopCall! + "List of times when this trip arrives to or departs from a stop location and information related to the visit to the stop location." + stopCalls: [StopCall!]! + "This trip on service date is an instance of this trip." + trip: Trip +} + +""" +A connection to a list of trips on service dates that follows +[GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm). +""" +type TripOnServiceDateConnection { + """ + Edges which contain the trips. Part of the + [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm). + """ + edges: [TripOnServiceDateEdge] + """ + Contains cursors to fetch more pages of trips. + Part of the [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm). + """ + pageInfo: PageInfo! +} + +""" +An edge for TripOnServiceDate connection. Part of the +[GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm). +""" +type TripOnServiceDateEdge { + """ + The cursor of the edge. Part of the + [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm). + """ + cursor: String! + """ + Trip on a service date as a node. Part of the + [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm). + """ + node: TripOnServiceDate +} + "This is used for alert entities that we don't explicitly handle or they are missing." type Unknown { "Entity's description" @@ -2497,8 +2796,10 @@ type VehiclePosition { heading: Float "Human-readable label of the vehicle, eg. a publicly visible number or a license plate" label: String + "When the position of the vehicle was recorded." + lastUpdate: OffsetDateTime "When the position of the vehicle was recorded in seconds since the UNIX epoch." - lastUpdated: Long + lastUpdated: Long @deprecated(reason : "Use `lastUpdate` instead.") @timingData "Latitude of the vehicle" lat: Float "Longitude of the vehicle" @@ -2558,7 +2859,7 @@ type VehicleRentalStation implements Node & PlaceInterface { "Name of the vehicle rental station" name: String! "ID of the rental network." - network: String @deprecated(reason : "Use `networkId` from `rentalNetwork` instead.") + network: String @deprecated(reason : "Use `networkId` from `rentalNetwork` instead.") @timingData "If true, station is on and in service." operative: Boolean """ @@ -2578,14 +2879,14 @@ type VehicleRentalStation implements Node & PlaceInterface { the rental station, even if the vehicle racks don't have any spaces available. See field `allowDropoffNow` to know if is currently possible to return a vehicle. """ - spacesAvailable: Int @deprecated(reason : "Use `availableSpaces` instead, which also contains the space vehicle types") + spacesAvailable: Int @deprecated(reason : "Use `availableSpaces` instead, which also contains the space vehicle types") @timingData "ID of the vehicle in the format of network:id" stationId: String """ Number of vehicles currently available on the rental station. See field `allowPickupNow` to know if is currently possible to pick up a vehicle. """ - vehiclesAvailable: Int @deprecated(reason : "Use `availableVehicles` instead, which also contains vehicle types") + vehiclesAvailable: Int @deprecated(reason : "Use `availableVehicles` instead, which also contains vehicle types") @timingData } type VehicleRentalUris { @@ -2631,12 +2932,12 @@ type fare { Fare price in cents. **Note:** this value is dependent on the currency used, as one cent is not necessarily ¹/₁₀₀ of the basic monerary unit. """ - cents: Int @deprecated(reason : "No longer supported") + cents: Int @deprecated(reason : "No longer supported") @timingData "Components which this fare is composed of" - components: [fareComponent] @deprecated(reason : "No longer supported") + components: [fareComponent] @deprecated(reason : "No longer supported") @timingData "ISO 4217 currency code" - currency: String @deprecated(reason : "No longer supported") - type: String @deprecated(reason : "No longer supported") + currency: String @deprecated(reason : "No longer supported") @timingData + type: String @deprecated(reason : "No longer supported") @timingData } """ @@ -2648,13 +2949,13 @@ type fareComponent { Fare price in cents. **Note:** this value is dependent on the currency used, as one cent is not necessarily ¹/₁₀₀ of the basic monerary unit. """ - cents: Int @deprecated(reason : "No longer supported") + cents: Int @deprecated(reason : "No longer supported") @timingData "ISO 4217 currency code" - currency: String @deprecated(reason : "No longer supported") + currency: String @deprecated(reason : "No longer supported") @timingData "ID of the ticket type. Corresponds to `fareId` in **TicketType**." - fareId: String @deprecated(reason : "No longer supported") + fareId: String @deprecated(reason : "No longer supported") @timingData "List of routes which use this fare component" - routes: [Route] @deprecated(reason : "No longer supported") + routes: [Route] @deprecated(reason : "No longer supported") @timingData } type placeAtDistance implements Node { @@ -2686,13 +2987,6 @@ type serviceTimeRange { start: Long } -type Entrance { - publicCode: String - wheelchairAccessible: Boolean -} - -union StepFeature = Entrance - type step { "The cardinal (compass) direction (e.g. north, northeast) taken when engaging this step." absoluteDirection: AbsoluteDirection @@ -2714,6 +3008,8 @@ type step { elevationProfile: [elevationProfileComponent] "When exiting a highway or traffic circle, the exit name/number." exit: String + "Information about a feature associated with a step e.g. a station entrance or exit." + feature: StepFeature "The latitude of the start of the step." lat: Float "The longitude of the start of the step." @@ -2726,8 +3022,6 @@ type step { streetName: String "Is this step walking with a bike?" walkingBike: Boolean - "Feature of a step" - feature: StepFeature } type stopAtDistance implements Node { @@ -2840,7 +3134,7 @@ enum AlertSeverityLevelType { INFO """ Severe alerts are used when a significant part of public transport services is - affected, for example: All train services are cancelled due to technical problems. + affected, for example: All train services are canceled due to technical problems. """ SEVERE "Severity of alert is unknown" @@ -2891,6 +3185,22 @@ enum CyclingOptimizationType { SHORTEST_DURATION } +""" +Dependent fare products can lead to many combinations of fares, however it is often not useful +information to the passenger. + +This enum allows filtering of the dependencies. + +Since it is recognised that this is not covered well in the specification, it is discussed here: +https://github.com/google/transit/pull/423 +""" +enum DependentFareProductFilter { + "Show all dependencies" + ALL + "Show only dependencies where the rider category and medium is the same es the main fare product." + MATCH_CATEGORY_AND_MEDIUM +} + "Entities, which are relevant for a feed and can contain alerts" enum FeedAlertType { "Alerts affecting the feed's agencies" @@ -2907,9 +3217,9 @@ enum FilterPlaceType { "Old value for VEHICLE_RENT" BICYCLE_RENT @deprecated(reason : "Use VEHICLE_RENT instead as it's clearer that it also returns rental scooters, cars...") "Parking lots (not rental stations) that contain spaces for bicycles" - BIKE_PARK + BIKE_PARK @deprecated(reason : "Not supported.") "Parking lots that contain spaces for cars" - CAR_PARK + CAR_PARK @deprecated(reason : "Not supported.") "Departure rows" DEPARTURE_ROW """ @@ -2949,6 +3259,7 @@ enum InputField { DATE_TIME FROM TO + VIA } """ @@ -3390,15 +3701,41 @@ enum RealtimeState { UPDATED } -"Actions to take relative to the current position when engaging a walking/driving step." +""" +A direction that is not absolute but rather fuzzy and context-dependent. +It provides the passenger with information what they should do in this step depending on where they +were in the previous one. +""" enum RelativeDirection { CIRCLE_CLOCKWISE CIRCLE_COUNTERCLOCKWISE + """ + Moving straight ahead in one of these cases + + - Passing through a crossing or intersection. + - Passing through a station entrance or exit when it is not know whether the passenger is + entering or exiting. If it _is_ known then `ENTER_STATION`/`EXIT_STATION` is used. + More information about the entrance is in the `step.feature` field. + """ CONTINUE DEPART ELEVATOR + """ + Entering a public transport station. If it's not known if the passenger is entering or exiting + then `CONTINUE` is used. + + More information about the entrance is in the `step.feature` field. + """ ENTER_STATION + ESCALATOR + """ + Exiting a public transport station. If it's not known if the passenger is entering or exiting + then `CONTINUE` is used. + + More information about the entrance is in the `step.feature` field. + """ EXIT_STATION + "Follow the signs indicating a specific location like \"platform 1\" or \"exit B\"." FOLLOW_SIGNS HARD_LEFT HARD_RIGHT @@ -3406,6 +3743,7 @@ enum RelativeDirection { RIGHT SLIGHTLY_LEFT SLIGHTLY_RIGHT + STAIRS UTURN_LEFT UTURN_RIGHT } @@ -3529,6 +3867,15 @@ enum StopAlertType { TRIPS } +enum StopType { + "An area or zone represented by a polygon. Introduced by the GTFS-Flex spec process." + LOCATION + "A group of stops. Introduced by the GTFS-Flex spec process." + LOCATION_GROUP + "A fixed stop represented by a coordinate." + STOP +} + """ Transit modes include modes that are used within organized transportation networks run by public transportation authorities, taxi companies etc. @@ -3612,6 +3959,13 @@ enum VertexType { TRANSIT } +"The vertical direction e.g. for a set of stairs." +enum VerticalDirection { + DOWN + UNKNOWN + UP +} + enum WheelchairBoarding { "Wheelchair boarding is not possible at this stop." NOT_POSSIBLE @@ -3733,7 +4087,10 @@ input BicyclePreferencesInput { "Preferences related to bicycle rental (station based or floating bicycle rental)." input BicycleRentalPreferencesInput { - "Rental networks which can be potentially used as part of an itinerary." + """ + Rental networks which can be potentially used as part of an itinerary. If this field has no default value, + it means that all networks are allowed unless some are banned with `bannedNetworks`. + """ allowedNetworks: [String!] "Rental networks which cannot be used as part of an itinerary." bannedNetworks: [String!] @@ -3814,6 +4171,8 @@ input CarParkingPreferencesInput { "Preferences related to traveling on a car (excluding car travel on transit services such as taxi)." input CarPreferencesInput { + "Cost of boarding a vehicle with a car." + boardCost: Cost "Car parking related preferences." parking: CarParkingPreferencesInput "A multiplier for how bad travelling on car is compared to being in transit for equal lengths of time." @@ -3824,7 +4183,10 @@ input CarPreferencesInput { "Preferences related to car rental (station based or floating car rental)." input CarRentalPreferencesInput { - "Rental networks which can be potentially used as part of an itinerary." + """ + Rental networks which can be potentially used as part of an itinerary. If this field has no default value, + it means that all networks are allowed unless some are banned with `bannedNetworks`. + """ allowedNetworks: [String!] "Rental networks which cannot be used as part of an itinerary." bannedNetworks: [String!] @@ -3878,14 +4240,14 @@ input InputBanned { banned for boarding and disembarking vehicles — it is possible to get an itinerary where a vehicle stops at one of these stops """ - stops: String @deprecated(reason : "Not implemented in OTP2.") + stops: String @deprecated(reason : "Not implemented") """ A comma-separated list of banned stop ids. Only itineraries where these stops are not travelled through are returned, e.g. if a bus route stops at one of these stops, that route will not be used in the itinerary, even if the stop is not used for boarding or disembarking the vehicle. """ - stopsHard: String @deprecated(reason : "Not implemented in OTP2.") + stopsHard: String @deprecated(reason : "Not implemented") "A comma-separated list of banned trip ids" trips: String } @@ -3948,15 +4310,11 @@ input InputModeWeight { } input InputPreferred { - "A comma-separated list of ids of the agencies preferred by the user." + "Not implemented" agencies: String - """ - Penalty added for using every route that is not preferred if user set any - route as preferred. We return number of seconds that we are willing to wait - for preferred route. - """ + "Not implemented" otherThanPreferredRoutesPenalty: Int - "A comma-separated list of ids of the routes preferred by the user." + "Not implemented" routes: String } @@ -4139,7 +4497,6 @@ input PlanModesInput { If more than one mode is selected, at least one of them must be used but not necessarily all. There are modes that automatically also use walking such as the rental modes. To force rental to be used, this should only include the rental mode and not `WALK` in addition. - The default access mode is `WALK`. """ direct: [PlanDirectMode!] "Should only the direct search without any transit be done." @@ -4147,8 +4504,7 @@ input PlanModesInput { """ Modes for different phases of an itinerary when transit is included. Also includes street mode selections related to connecting to the transit network - and transfers. By default, all transit modes are usable and `WALK` is used for - access, egress and transfers. + and transfers. By default, all transit modes are usable. """ transit: PlanTransitModesInput """ @@ -4241,7 +4597,6 @@ input PlanTransitModesInput { If more than one mode is selected, at least one of them must be used but not necessarily all. There are modes that automatically also use walking such as the rental modes. To force rental to be used, this should only include the rental mode and not `WALK` in addition. - The default access mode is `WALK`. """ access: [PlanAccessMode!] """ @@ -4249,13 +4604,9 @@ input PlanTransitModesInput { If more than one mode is selected, at least one of them must be used but not necessarily all. There are modes that automatically also use walking such as the rental modes. To force rental to be used, this should only include the rental mode and not `WALK` in addition. - The default access mode is `WALK`. """ egress: [PlanEgressMode!] - """ - Street mode that is used when searching for transfers. Selection of only one allowed for now. - The default transfer mode is `WALK`. - """ + "Street mode that is used when searching for transfers. Selection of only one allowed for now." transfer: [PlanTransferMode!] """ Transit modes and reluctances associated with them. Each defined mode can be used in @@ -4281,10 +4632,14 @@ A visit-via-location is a physical visit to one of the stop locations or coordin 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 { + """ + A coordinate to route through. 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. + """ + coordinate: PlanCoordinateInput "The label/name of the location. This is pass-through information and is not used in routing." label: String """ @@ -4328,7 +4683,10 @@ input ScooterPreferencesInput { "Preferences related to scooter rental (station based or floating scooter rental)." input ScooterRentalPreferencesInput { - "Rental networks which can be potentially used as part of an itinerary." + """ + Rental networks which can be potentially used as part of an itinerary. If this field has no default value, + it means that all networks are allowed unless some are banned with `bannedNetworks`. + """ allowedNetworks: [String!] "Rental networks which cannot be used as part of an itinerary." bannedNetworks: [String!] @@ -4344,21 +4702,21 @@ input TimetablePreferencesInput { When false, real-time updates are considered during the routing. In practice, when this option is set as true, some of the suggestions might not be realistic as the transfers could be invalid due to delays, - trips can be cancelled or stops can be skipped. + trips can be canceled or stops can be skipped. """ excludeRealTimeUpdates: Boolean """ - When true, departures that have been cancelled ahead of time will be + When true, departures that have been canceled ahead of time will be included during the routing. This means that an itinerary can include - a cancelled departure while some other alternative that contains no cancellations + a canceled departure while some other alternative that contains no cancellations could be filtered out as the alternative containing a cancellation would normally be better. """ includePlannedCancellations: Boolean """ - When true, departures that have been cancelled through a real-time feed will be + When true, departures that have been canceled through a real-time feed will be included during the routing. This means that an itinerary can include - a cancelled departure while some other alternative that contains no cancellations + a canceled departure while some other alternative that contains no cancellations could be filtered out as the alternative containing a cancellation would normally be better. This option can't be set to true while `includeRealTimeUpdates` is false. """ @@ -4377,15 +4735,61 @@ input TransferPreferencesInput { "How many transfers there can be at maximum in an itinerary." maximumTransfers: Int """ - A global minimum transfer time (in seconds) that specifies the minimum amount of time - that must pass between exiting one transit vehicle and boarding another. This time is - in addition to time it might take to walk between transit stops. Setting this value - as `PT0S`, for example, can lead to passenger missing a connection when the vehicle leaves - ahead of time or the passenger arrives to the stop later than expected. + A global minimum transfer time that specifies the minimum amount of time that must pass + between exiting one transit vehicle and boarding another. This time is in addition to + time it might take to walk between transit stops. Setting this value as `PT0S`, for + example, can lead to passenger missing a connection when the vehicle leaves ahead of time + or the passenger arrives to the stop later than expected. """ slack: Duration } +""" +A collection of selectors for what routes/agencies should be included in or excluded from the search. + +The `include` is always applied to select the initial set, then `exclude` to remove elements. +If only `exclude` is present, the exclude is applied to the existing set of results. + +Therefore, if an entity is both included _and_ excluded the exclusion takes precedence. +""" +input TransitFilterInput { + """ + A list of selectors for what routes/agencies should be excluded during search. + + In order to be excluded, a route/agency has to match with at least one selector. + + An empty list or a list containing `null` is forbidden. + """ + exclude: [TransitFilterSelectInput!] + """ + A list of selectors for what routes/agencies should be allowed during the search. + + If route/agency matches with at least one selector it will be included. + + An empty list or a list containing `null` is forbidden. + """ + include: [TransitFilterSelectInput!] +} + +""" +A list of selectors for including or excluding entities from the routing results. Null +means everything is allowed to be returned and empty lists are not allowed. +""" +input TransitFilterSelectInput @oneOf { + """ + Set of ids for agencies that should be included in/excluded from the search. + + Format: `FeedId:AgencyId` + """ + agencies: [String!] + """ + Set of ids for routes that should be included in/excluded from the search. + + Format: `FeedId:AgencyId` + """ + routes: [String!] +} + "Costs related to using a transit mode." input TransitModePreferenceCostInput { "A cost multiplier of transit leg travel time." @@ -4401,6 +4805,57 @@ input TransitPreferencesInput { can be found under the street mode preferences. """ board: BoardPreferencesInput + """ + A list of filters for which trips should be included or excluded. A trip will be considered in the + result if it is included by at least one filter and isn't excluded by another one at the same time. + + An empty list of filters or no value means that all trips should be included. + + **AND/OR Logic** + + Several filters can be defined and form an OR-condition. + + The following example means "include all trips with `F:route1` _or_ `F:agency1`": + + ``` + filters: [ + { + include: { + routes: ["F:route1"] + } + }, + { + include: { + agencies: ["F:agency1"] + } + } + }] + ``` + + The following example means "include all trips of `F:agency1` _and_ exclude `F:route1`": + + ``` + filters: [ + { + include: { + agencies: ["F:agency1"] + }, + exclude: { + routes: ["F:route1"] + } + } + ] + ``` + + Be aware of the following pitfalls: + - It is easy to construct AND-conditions that can lead to zero results. + - OR-conditions that have an element which consists of only an exclude are likely to be + unwanted and may lead to unexpected results. + + **Note**: This parameter also interacts with the modes set in `modes.transit` by forming + an AND-condition. + """ + filters: [TransitFilterInput!] "Preferences related to cancellations and real-time." timetable: TimetablePreferencesInput "Preferences related to transfers between transit vehicles (typically between stops)." 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 dec86d6317..2d91baa330 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 @@ -12,12 +12,23 @@ This is only worth it when the execution is long running, i.e. more than ~50 mil """ directive @async on FIELD_DEFINITION +"This directive allows results to be deferred during execution" +directive @defer( + "Deferred behaviour is controlled by this argument" + if: Boolean! = true, + "A unique label that represents the fragment being deferred" + label: String + ) on FRAGMENT_SPREAD | INLINE_FRAGMENT + "Marks the field, argument, input field or enum value as deprecated" directive @deprecated( "The reason for the deprecation" - reason: String = "No longer supported" + reason: String! = "No longer supported" ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION +"This directive disables error propagation when a non nullable field returns null for the given operation." +directive @experimental_disableErrorPropagation on QUERY | MUTATION | SUBSCRIPTION + "Directs the executor to include this field or fragment only when the `if` argument is true" directive @include( "Included when true." @@ -39,6 +50,9 @@ directive @specifiedBy( url: String! ) on SCALAR +"Add timing data to prometheus, if Actuator API is enabled" +directive @timingData on FIELD_DEFINITION + "A fare product (a ticket) to be bought by a passenger" interface FareProduct { "Identifier for the fare product." @@ -73,9 +87,18 @@ interface PlaceInterface { "Entity related to an alert" union AlertEntity = Agency | Pattern | Route | RouteType | Stop | StopOnRoute | StopOnTrip | Trip | Unknown +"Scheduled times for a trip on a service date for a stop location." +union CallScheduledTime = ArrivalDepartureTime | TimeWindow + +"Location where a transit vehicle stops at." +union CallStopLocation = Location | LocationGroup | Stop + "Rental place union that represents either a VehicleRentalStation or a RentalVehicle" union RentalPlace = RentalVehicle | VehicleRentalStation +"A feature for a step" +union StepFeature = ElevatorUse | Entrance | EscalatorUse | StairsUse + union StopPosition = PositionAtStop | PositionBetweenStops "A public transport agency" @@ -116,7 +139,7 @@ type Alert implements Node { Agency affected by the disruption. Note that this value is present only if the disruption has an effect on all operations of the agency (e.g. in case of a strike). """ - agency: Agency @deprecated(reason : "Alert can have multiple affected entities now instead of there being duplicate alerts\nfor different entities. This will return only one of the affected agencies.\nUse entities instead.") + agency: Agency @deprecated(reason : "Alert can have multiple affected entities now instead of there being duplicate alerts\nfor different entities. This will return only one of the affected agencies.\nUse entities instead.") @timingData "Alert cause" alertCause: AlertCauseType "Long description of the alert" @@ -125,7 +148,7 @@ type Alert implements Node { language: String ): String! "Long descriptions of the alert in all different available languages" - alertDescriptionTextTranslations: [TranslatedString!]! @deprecated(reason : "Use `alertDescriptionText` instead. `accept-language` header can be used for translations or the `language` parameter on the `alertDescriptionText` field.") + alertDescriptionTextTranslations: [TranslatedString!]! @deprecated(reason : "Use `alertDescriptionText` instead. `accept-language` header can be used for translations or the `language` parameter on the `alertDescriptionText` field.") @timingData "Alert effect" alertEffect: AlertEffectType "hashcode from the original GTFS-RT alert" @@ -136,7 +159,7 @@ type Alert implements Node { language: String ): String "Header of the alert in all different available languages" - alertHeaderTextTranslations: [TranslatedString!]! @deprecated(reason : "Use `alertHeaderText` instead. `accept-language` header can be used for translations or the `language` parameter on the `alertHeaderText` field.") + alertHeaderTextTranslations: [TranslatedString!]! @deprecated(reason : "Use `alertHeaderText` instead. `accept-language` header can be used for translations or the `language` parameter on the `alertHeaderText` field.") @timingData "Alert severity level" alertSeverityLevel: AlertSeverityLevelType "Url with more information" @@ -145,7 +168,7 @@ type Alert implements Node { language: String ): String "Url with more information in all different available languages" - alertUrlTranslations: [TranslatedString!]! @deprecated(reason : "Use `alertUrl` instead. `accept-language` header can be used for translations or the `language` parameter on the `alertUrl` field.") + alertUrlTranslations: [TranslatedString!]! @deprecated(reason : "Use `alertUrl` instead. `accept-language` header can be used for translations or the `language` parameter on the `alertUrl` field.") @timingData "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" @@ -157,13 +180,21 @@ type Alert implements Node { "Global object ID provided by Relay. This value can be used to refetch this object using **node** query." id: ID! "Patterns affected by the disruption" - patterns: [Pattern] @deprecated(reason : "This will always return an empty list. Use entities instead.") + patterns: [Pattern] @deprecated(reason : "This will always return an empty list. Use entities instead.") @timingData "Route affected by the disruption" - route: Route @deprecated(reason : "Alert can have multiple affected entities now instead of there being duplicate alerts\nfor different entities. This will return only one of the affected routes.\nUse entities instead.") + route: Route @deprecated(reason : "Alert can have multiple affected entities now instead of there being duplicate alerts\nfor different entities. This will return only one of the affected routes.\nUse entities instead.") @timingData "Stop affected by the disruption" - stop: Stop @deprecated(reason : "Alert can have multiple affected entities now instead of there being duplicate alerts\nfor different entities. This will return only one of the affected stops.\nUse entities instead.") + stop: Stop @deprecated(reason : "Alert can have multiple affected entities now instead of there being duplicate alerts\nfor different entities. This will return only one of the affected stops.\nUse entities instead.") @timingData "Trip affected by the disruption" - trip: Trip @deprecated(reason : "Alert can have multiple affected entities now instead of there being duplicate alerts\nfor different entities. This will return only one of the affected trips.\nUse entities instead.") + trip: Trip @deprecated(reason : "Alert can have multiple affected entities now instead of there being duplicate alerts\nfor different entities. This will return only one of the affected trips.\nUse entities instead.") @timingData +} + +"Arrival and departure time (not relative to midnight)." +type ArrivalDepartureTime { + "Arrival time as an ISO-8601-formatted datetime." + arrival: OffsetDateTime + "Departure time as an ISO-8601-formatted datetime." + departure: OffsetDateTime } "Bike park represents a location where bicycles can be parked." @@ -241,7 +272,7 @@ type BikeRentalStation implements Node & PlaceInterface { """ spacesAvailable: Int "A description of the current state of this bike rental station, e.g. \"Station on\"" - state: String @deprecated(reason : "Use operative instead") + state: String @deprecated(reason : "Use operative instead") @timingData "ID of the bike rental station" stationId: String } @@ -279,12 +310,16 @@ type BookingInfo { earliestBookingTime: BookingTime "When is the latest time the service can be booked" latestBookingTime: BookingTime + "Maximum duration before travel to make the request." + maximumBookingNotice: Duration "Maximum number of seconds before travel to make the request" - maximumBookingNoticeSeconds: Long + maximumBookingNoticeSeconds: Long @deprecated(reason : "Use `maximumBookingNotice`") @timingData "A general message for those booking the service" message: String + "Minimum duration before travel to make the request" + minimumBookingNotice: Duration "Minimum number of seconds before travel to make the request" - minimumBookingNoticeSeconds: Long + minimumBookingNoticeSeconds: Long @deprecated(reason : "Use `minimumBookingNotice`") @timingData "A message specific to the pick up" pickupMessage: String } @@ -297,6 +332,20 @@ type BookingTime { time: String } +"Real-time estimates for arrival and departure times for a stop location." +type CallRealTime { + "Real-time estimates for the arrival." + arrival: EstimatedTime + "Real-time estimates for the departure." + departure: EstimatedTime +} + +"What is scheduled for a trip on a service date for a stop location." +type CallSchedule { + "Scheduled time for a trip on a service date for a stop location." + time: CallScheduledTime +} + "Car park represents a location where cars can be parked." type CarPark implements Node & PlaceInterface { "ID of the car park" @@ -330,17 +379,17 @@ type CarPark implements Node & PlaceInterface { "Cluster is a list of stops grouped by name and proximity" type Cluster implements Node { "ID of the cluster" - gtfsId: String! + gtfsId: String! @deprecated(reason : "Not implemented") "Global object ID provided by Relay. This value can be used to refetch this object using **node** query." - id: ID! + id: ID! @deprecated(reason : "Not implemented") "Latitude of the center of this cluster (i.e. average latitude of stops in this cluster)" - lat: Float! + lat: Float! @deprecated(reason : "Not implemented") "Longitude of the center of this cluster (i.e. average longitude of stops in this cluster)" - lon: Float! + lon: Float! @deprecated(reason : "Not implemented") "Name of the cluster" - name: String! + name: String! @deprecated(reason : "Not implemented") "List of stops in the cluster" - stops: [Stop!] + stops: [Stop!] @deprecated(reason : "Not implemented") } "Contact information for booking an on-demand or flexible service." @@ -451,11 +500,78 @@ type DepartureRow implements Node & PlaceInterface { ): [Stoptime] } +""" +A (possibly discounted) fare product that requires another fare product to be purchased previously +in order to be valid. + +For example, when taking the train into a city, you might get a discounted "transfer fare" when +switching to the bus for the second leg. +""" +type DependentFareProduct implements FareProduct { + "The fare product is _not_ valid without purchasing at least _one_ of" + dependencies(filter: DependentFareProductFilter = ALL): [FareProduct!]! + id: String! + """ + The 'medium' that this product applies to, for example "Oyster Card" or "Berlin Ticket App". + + This communicates to riders that a specific way of buying or keeping this product is required. + """ + medium: FareMedium + "Human readable name of the product, for example example \"Day pass\" or \"Single ticket\"." + name: String! + "The price of the product" + price: Money! + "The category of riders this product applies to, for example students or pensioners." + riderCategory: RiderCategory +} + +"A single use of an elevator." +type ElevatorUse { + "The level the use begins at." + from: Level + "The level the use ends at." + to: Level + verticalDirection: VerticalDirection! +} + type Emissions { "CO₂ emissions in grams." co2: Grams } +"Station entrance or exit, originating from OSM or GTFS data." +type Entrance { + "ID of the entrance in the format of `FeedId:EntranceId`. If the `FeedId` is `osm`, the entrance originates from OSM data." + entranceId: String! + "Name of the entrance or exit." + name: String + "Short text or a number that identifies the entrance or exit for passengers. For example, `A` or `B`." + publicCode: String + "Whether the entrance or exit is accessible by wheelchair" + wheelchairAccessible: WheelchairBoarding +} + +"A single use of an escalator." +type EscalatorUse { + "The level the use begins at." + from: Level + "The level the use ends at." + to: Level + verticalDirection: VerticalDirection! +} + +"Real-time estimates for an arrival or departure at a certain place." +type EstimatedTime { + """ + The delay or "earliness" of the vehicle at a certain place. This estimate can change quite often. + + If the vehicle is early then this is a negative duration. + """ + delay: Duration! + "The estimate for a call event (such as arrival or departure) at a certain place. This estimate can change quite often." + time: OffsetDateTime! +} + "A 'medium' that a fare product applies to, for example cash, 'Oyster Card' or 'DB Navigator App'." type FareMedium { "ID of the medium" @@ -581,12 +697,12 @@ type Itinerary { "Time when the user leaves arrives at the destination." end: OffsetDateTime "Time when the user arrives to the destination. Format: Unix timestamp in milliseconds." - endTime: Long @deprecated(reason : "Use `end` instead which includes timezone information.") + endTime: Long @deprecated(reason : "Use `end` instead which includes timezone information.") @timingData """ Information about the fares for this itinerary. This is primarily a GTFS Fares V1 interface and always returns an empty list. Use the leg's `fareProducts` instead. """ - fares: [fare] @deprecated(reason : "Use the leg's `fareProducts`.") + fares: [fare] @deprecated(reason : "Use the leg's `fareProducts`.") @timingData "Generalized cost of the itinerary. Used for debugging search results." generalizedCost: Int """ @@ -607,7 +723,7 @@ type Itinerary { "Time when the user leaves from the origin." start: OffsetDateTime "Time when the user leaves from the origin. Format: Unix timestamp in milliseconds." - startTime: Long @deprecated(reason : "Use `start` instead which includes timezone information.") + startTime: Long @deprecated(reason : "Use `start` instead which includes timezone information.") @timingData """ A list of system notices. Contains debug information for itineraries. One use-case is to run a routing search with 'debugItineraryFilter: true'. @@ -642,13 +758,13 @@ type Leg { stop in this leg, i.e. scheduled time of arrival at alighting stop = `endTime - arrivalDelay` """ - arrivalDelay: Int @deprecated(reason : "Use `start.estimated.delay` instead.") + arrivalDelay: Int @deprecated(reason : "Use `start.estimated.delay` instead.") @timingData """ For transit leg, the offset from the scheduled departure time of the boarding stop in this leg, i.e. scheduled time of departure at boarding stop = `startTime - departureDelay` """ - departureDelay: Int @deprecated(reason : "Use `end.estimated.delay` instead.") + departureDelay: Int @deprecated(reason : "Use `end.estimated.delay` instead.") @timingData "The distance traveled while traversing the leg in meters." distance: Float """ @@ -663,7 +779,7 @@ type Leg { "The time when the leg ends including real-time information, if available." end: LegTime! "The date and time when this leg ends. Format: Unix timestamp in milliseconds." - endTime: Long @deprecated(reason : "Use `end.estimated.time` instead which contains timezone information.") + endTime: Long @deprecated(reason : "Use `end.estimated.time` instead which contains timezone information.") @timingData """ Fare products are purchasable tickets which may have an optional fare container or rider category that limits who can buy them or how. @@ -686,6 +802,9 @@ type Leg { 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. + + The identifier is valid for a maximum of 2 years, but sometimes it will fail after a few hours. + We do not recommend storing IDs for a long time. """ id: String """ @@ -695,18 +814,20 @@ type Leg { """ interlineWithPreviousLeg: Boolean "Whether the destination of this leg (field `to`) is one of the intermediate places specified in the query." - intermediatePlace: Boolean + intermediatePlace: Boolean @deprecated(reason : "Not implemented") """ For transit legs, intermediate stops between the Place where the leg originates and the Place where the leg ends. For non-transit legs, null. - Returns Place type, which has fields for e.g. departure and arrival times """ - intermediatePlaces: [Place] + intermediatePlaces: [Place] @deprecated(reason : "Use `leg.stopCalls` instead") """ For transit legs, intermediate stops between the Place where the leg originates and the Place where the leg ends. For non-transit legs, null. + + The `include` parameter allows filtering of the returned places by stop type. If not provided, the + field returns all types. An empty list is not permitted. """ - intermediateStops: [Stop] + intermediateStops(include: [StopType!]): [Stop] @deprecated(reason : "Use `leg.stopCalls` instead") "The leg's geometry." legGeometry: Geometry "The mode (e.g. `WALK`) used when traversing this leg." @@ -758,7 +879,7 @@ type Leg { realTime: Boolean "State of real-time data" realtimeState: RealtimeState - "Whether this leg is traversed with a rented bike." + "Whether this leg is traversed with a rented vehicle." rentedBike: Boolean "Estimate of a hailed ride like Uber." rideHailingEstimate: RideHailingEstimate @@ -769,9 +890,15 @@ type Leg { "The time when the leg starts including real-time information, if available." start: LegTime! "The date and time when this leg begins. Format: Unix timestamp in milliseconds." - startTime: Long @deprecated(reason : "Use `start.estimated.time` instead which contains timezone information.") + startTime: Long @deprecated(reason : "Use `start.estimated.time` instead which contains timezone information.") @timingData "The turn-by-turn navigation instructions." steps: [step] + """ + All the stop calls (stop times) of this _leg_ (but not trip) including the boarding and alighting one. + + Non-transit legs return an empty list. + """ + stopCalls: [StopCall!]! "The Place where the leg ends." to: Place! "Whether this leg is a transit leg or not." @@ -793,6 +920,14 @@ type LegTime { scheduledTime: OffsetDateTime! } +"A level with a name and comparable number. Levels can sometimes contain half levels, e.g. '1.5'." +type Level { + "0-based comparable number where 0 is the ground level." + level: Float! + "Name of the level, e.g. 'M', 'P1', or '1'. Can be equal or different to the numerical representation." + name: String! +} + "A span of time." type LocalTimeSpan { "The start of the time timespan as seconds from midnight." @@ -809,6 +944,34 @@ type LocalTimeSpanDate { timeSpans: [LocalTimeSpan] } +""" +A stop that isn't a fixed point but zone where passengers can board or alight anywhere. + +This is mostly used by demand-responsive services. +""" +type Location { + "The geometry representing the geographic extend of the location." + geometry: StopGeometries! + "ÌD of the location in format `FeedId:LocationId`" + gtfsId: String! + "Optional name of the location." + name: String +} + +""" +A group of fixed stops that are visited in an arbitrary order. + +This is mostly used by demand-responsive services. +""" +type LocationGroup { + "ÌD of the location group in format `FeedId:LocationGroupId`" + gtfsId: String! + "The stops that are part of the group (cannot be stations)." + members: [Stop!]! + "Optional name of the group." + name: String +} + "An amount of money." type Money { """ @@ -919,20 +1082,20 @@ type Place { """ arrival: LegTime "The time the rider will arrive at the place. Format: Unix timestamp in milliseconds." - arrivalTime: Long! @deprecated(reason : "Use `arrival` which includes timezone information.") + arrivalTime: Long! @deprecated(reason : "Use `arrival` which includes timezone information.") @timingData "The bike parking related to the place" - bikePark: BikePark @deprecated(reason : "bikePark is deprecated. Use vehicleParking instead.") + bikePark: BikePark @deprecated(reason : "bikePark is deprecated. Use vehicleParking instead.") @timingData "The bike rental station related to the place" - bikeRentalStation: BikeRentalStation @deprecated(reason : "Use vehicleRentalStation and rentalVehicle instead") + bikeRentalStation: BikeRentalStation @deprecated(reason : "Use vehicleRentalStation and rentalVehicle instead") @timingData "The car parking related to the place" - carPark: CarPark @deprecated(reason : "carPark is deprecated. Use vehicleParking instead.") + carPark: CarPark @deprecated(reason : "carPark is deprecated. Use vehicleParking instead.") @timingData """ The time the rider will depart the place. This also includes real-time information if available. """ departure: LegTime "The time the rider will depart the place. Format: Unix timestamp in milliseconds." - departureTime: Long! @deprecated(reason : "Use `departure` which includes timezone information.") + departureTime: Long! @deprecated(reason : "Use `departure` which includes timezone information.") @timingData "Latitude of the place (WGS 84)" lat: Float! "Longitude of the place (WGS 84)" @@ -966,7 +1129,7 @@ type Place { Type of vertex. (Normal, Bike sharing station, Bike P+R, Transit stop) Mostly used for better localization of bike sharing and P+R station names """ - vertexType: VertexType + vertexType: VertexType @deprecated(reason : "Unmaintained. Use `stop`, `rentalVehicle`, `vehicleParking` or `vehicleRentalStation` to tell which type it is.") @timingData } type Plan { @@ -982,13 +1145,8 @@ type Plan { messageEnums: [String]! "A list of possible error messages in cleartext" messageStrings: [String]! - """ - This is the suggested search time for the "next page" or time window. Insert it together - with the searchWindowUsed in the request to get a new set of trips following in the - search-window AFTER the current search. No duplicate trips should be returned, unless a trip - is delayed and new real-time data is available. - """ - nextDateTime: Long @deprecated(reason : "Use nextPageCursor instead") + "This will not be available after Match 2026." + nextDateTime: Long @deprecated(reason : "Use nextPageCursor instead") @timingData """ Use the cursor to go to the next "page" of itineraries. Copy the cursor from the last response to the pageCursor query parameter and keep the original request as is. This will enable you to @@ -997,13 +1155,8 @@ type Plan { This is only usable when public transportation mode(s) are included in the query. """ nextPageCursor: String - """ - This is the suggested search time for the "previous page" or time window. Insert it together - with the searchWindowUsed in the request to get a new set of trips preceding in the - search-window BEFORE the current search. No duplicate trips should be returned, unless a trip - is delayed and new real-time data is available. - """ - prevDateTime: Long @deprecated(reason : "Use previousPageCursor instead") + "This will not be available after Match 2026." + prevDateTime: Long @deprecated(reason : "Use previousPageCursor instead") @timingData """ Use the cursor to go to the previous "page" of itineraries. Copy the cursor from the last response to the pageCursor query parameter and keep the original request otherwise as is. @@ -1021,7 +1174,7 @@ type Plan { The unit is seconds. """ - searchWindowUsed: Long + searchWindowUsed: Long @deprecated(reason : "This is not needed for debugging, and is misleading if the window is cropped.") "The destination" to: Place! } @@ -1116,11 +1269,11 @@ type QueryType { stop: [String!] ): [Alert] "Get a single bike park based on its ID, i.e. value of field `bikeParkId`" - bikePark(id: String!): BikePark @deprecated(reason : "bikePark is deprecated. Use vehicleParking instead.") + bikePark(id: String!): BikePark @deprecated(reason : "bikePark is deprecated. Use vehicleParking instead.") @timingData "Get all bike parks" - bikeParks: [BikePark] @deprecated(reason : "bikeParks is deprecated. Use vehicleParkings instead.") + bikeParks: [BikePark] @deprecated(reason : "bikeParks is deprecated. Use vehicleParkings instead.") @timingData "Get a single bike rental station based on its ID, i.e. value of field `stationId`" - bikeRentalStation(id: String!): BikeRentalStation @deprecated(reason : "Use rentalVehicle or vehicleRentalStation instead") + bikeRentalStation(id: String!): BikeRentalStation @deprecated(reason : "Use rentalVehicle or vehicleRentalStation instead") @timingData "Get all bike rental stations" bikeRentalStations( """ @@ -1129,32 +1282,61 @@ type QueryType { the returned list will contain `null` values. """ ids: [String] - ): [BikeRentalStation] @deprecated(reason : "Use rentalVehicles or vehicleRentalStations instead") - "Get cancelled TripTimes." + ): [BikeRentalStation] @deprecated(reason : "Use rentalVehicles or vehicleRentalStations instead") @timingData + """ + Get pages of canceled trips. Planned cancellations are not currently supported. Limiting the number of + returned trips with either `first` or `last` is highly recommended since the number of returned trips + can be really high when there is a strike affecting the transit services, for example. Follows the + [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm). + """ + canceledTrips( + """ + This parameter is part of the [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm) + and should be used together with the `first` parameter. + """ + after: String, + """ + This parameter is part of the [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm) + and should be used together with the `last` parameter. + """ + before: String, + """ + Limits how many trips are returned. This parameter is part of the + [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm) and can be used together with + the `after` parameter. + """ + first: Int, + """ + This parameter is part of the [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm) + and should be used together with the `before` parameter. + """ + last: Int + ): TripOnServiceDateConnection + "Get canceled TripTimes." cancelledTripTimes( "Feed feedIds (e.g. [\"HSL\"])." feeds: [String], """ - Only cancelled trip times that have last stop arrival time at maxArrivalTime + Only canceled trip times that have last stop arrival time at maxArrivalTime or before are returned. Format: seconds since midnight of maxDate. """ maxArrivalTime: Int, - "Only cancelled trip times scheduled to run on maxDate or before are returned. Format: \"2019-12-23\" or \"20191223\"." + "Only canceled trip times scheduled to run on maxDate or before are returned. Format: \"2019-12-23\" or \"20191223\"." maxDate: String, """ - Only cancelled trip times that have first stop departure time at + Only canceled trip times that have first stop departure time at maxDepartureTime or before are returned. Format: seconds since midnight of maxDate. """ maxDepartureTime: Int, """ - Only cancelled trip times that have last stop arrival time at minArrivalTime + Only canceled trip times that have last stop arrival time at minArrivalTime or after are returned. Format: seconds since midnight of minDate. """ minArrivalTime: Int, - "Only cancelled trip times scheduled to run on minDate or after are returned. Format: \"2019-12-23\" or \"20191223\"." + "Only canceled trip times scheduled to run on minDate or after are returned. Format: \"2019-12-23\" or \"20191223\"." minDate: String, """ - Only cancelled trip times that have first stop departure time at + Only canceled trip times that have first stop departure time at minDepartureTime or after are returned. Format: seconds since midnight of minDate. """ minDepartureTime: Int, @@ -1164,9 +1346,9 @@ type QueryType { routes: [String], "Trip gtfsIds (e.g. [\"HSL:1098_20190405_Ma_2_1455\"])." trips: [String] - ): [Stoptime] + ): [Stoptime] @deprecated(reason : "`cancelledTripTimes` is not implemented. Use `canceledTrips` instead.") @timingData "Get a single car park based on its ID, i.e. value of field `carParkId`" - carPark(id: String!): CarPark @deprecated(reason : "carPark is deprecated. Use vehicleParking instead.") + carPark(id: String!): CarPark @deprecated(reason : "carPark is deprecated. Use vehicleParking instead.") @timingData "Get all car parks" carParks( """ @@ -1174,11 +1356,11 @@ type QueryType { **Note:** if an id is invalid (or the car park service is unavailable) the returned list will contain `null` values. """ ids: [String] - ): [CarPark] @deprecated(reason : "carParks is deprecated. Use vehicleParkings instead.") + ): [CarPark] @deprecated(reason : "carParks is deprecated. Use vehicleParkings instead.") @timingData "Get a single cluster based on its ID, i.e. value of field `gtfsId`" - cluster(id: String!): Cluster + cluster(id: String!): Cluster @deprecated(reason : "Not implemented") "Get all clusters" - clusters: [Cluster] + clusters: [Cluster] @deprecated(reason : "Not implemented") "Get a single departure row based on its ID (ID format is `FeedId:StopId:PatternId`)" departureRow(id: String!): DepartureRow "Get all available feeds" @@ -1244,7 +1426,7 @@ type QueryType { maxDistance: Int = 2000, "Maximum number of results. Search is stopped when this limit is reached. Default is 20." maxResults: Int = 20 - ): placeAtDistanceConnection @async + ): placeAtDistanceConnection @async @timingData "Fetches an object given its ID" node( "The ID of an object" @@ -1357,7 +1539,7 @@ type QueryType { "When true, real-time updates are ignored during this search. Default value: false" ignoreRealtimeUpdates: Boolean, "An ordered list of intermediate locations to be visited." - intermediatePlaces: [InputCoordinates] @deprecated(reason : "Not implemented in OTP2."), + intermediatePlaces: [InputCoordinates] @deprecated(reason : "Not implemented"), """ How easily bad itineraries are filtered from results. Value 0 (default) disables filtering. Itineraries are filtered if they are worse than another @@ -1424,7 +1606,7 @@ type QueryType { "Preferences for vehicle parking" parking: VehicleParkingInput, "List of routes and agencies which are given higher preference when planning the itinerary" - preferred: InputPreferred, + preferred: InputPreferred @deprecated(reason : "Not implemented"), """ **Consider this argument experimental** – setting this argument to true causes timeouts and unoptimal routes in many cases. @@ -1476,7 +1658,7 @@ type QueryType { travel time of the trip (and therefore arguments `time` and `from` must be used correctly to get meaningful itineraries). """ - startTransitTripId: String @deprecated(reason : "Not implemented in OTP2"), + startTransitTripId: String @deprecated(reason : "Not implemented"), "Time of departure or arrival in format hh:mm:ss. Default value: current time" time: String, """ @@ -1560,7 +1742,7 @@ type QueryType { walkSpeed: Float, "Whether the itinerary must be wheelchair accessible. Default value: false" wheelchair: Boolean - ): Plan @async @deprecated(reason : "Use `planConnection` instead.") + ): Plan @async @deprecated(reason : "Use `planConnection` instead.") @timingData """ Plan (itinerary) search that follows [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm). @@ -1632,8 +1814,8 @@ type QueryType { preferences: PlanPreferencesInput, """ Duration of the search window. This either starts at the defined earliest departure - time or ends at the latest arrival time. If this is not provided, a reasonable - search window is automatically generated. When searching for earlier or later itineraries + time or ends at the latest arrival time. If this is not provided or the value is set as null, + a reasonable search window is automatically generated. When searching for earlier or later itineraries with paging, this search window is no longer used and the new window will be based on how many suggestions were returned in the previous search. The new search window can be shorter or longer than the original search window. Note, itineraries are returned faster @@ -1647,7 +1829,7 @@ type QueryType { searchWindow: Duration, "The list of points the itinerary is required to pass through." via: [PlanViaLocationInput!] - ): PlanConnection @async + ): PlanConnection @async @timingData "Get a single rental vehicle based on its ID, i.e. value of field `vehicleId`" rentalVehicle(id: String!): RentalVehicle "Get all rental vehicles" @@ -1683,7 +1865,7 @@ type QueryType { serviceDates: LocalDateRangeInput, "Only include routes, which use one of these modes" transportModes: [Mode] - ): [Route] + ): [Route] @timingData "Get the time range for which the API has data available" serviceTimeRange: serviceTimeRange "Get a single station based on its ID, i.e. value of field `gtfsId` (format is `FeedId:StopId`)" @@ -1801,6 +1983,10 @@ type RealTimeEstimate { type RentalVehicle implements Node & PlaceInterface { "If true, vehicle is currently available for renting." allowPickupNow: Boolean + "The vehicle should be returned before this deadline." + availableUntil: OffsetDateTime + "Fuel or battery status of the rental vehicle" + fuel: RentalVehicleFuel "Global object ID provided by Relay. This value can be used to refetch this object using **node** query." id: ID! "Latitude of the vehicle (WGS 84)" @@ -1810,7 +1996,7 @@ type RentalVehicle implements Node & PlaceInterface { "Name of the vehicle" name: String! "ID of the rental network." - network: String @deprecated(reason : "Use `networkId` from `rentalNetwork` instead.") + network: String @deprecated(reason : "Use `networkId` from `rentalNetwork` instead.") @timingData "If true, vehicle is not disabled." operative: Boolean "The vehicle rental network information. This is referred as system in the GBFS terminology." @@ -1830,6 +2016,14 @@ type RentalVehicleEntityCounts { total: Int! } +"Rental vehicle fuel represent the current status of the battery or fuel of a rental vehicle" +type RentalVehicleFuel { + "Fuel or battery power remaining in the vehicle. Expressed from 0 to 1." + percent: Ratio + "Range in meters that the vehicle can travel with the current charge or fuel." + range: Int +} + type RentalVehicleType { "The vehicle's general form factor" formFactor: FormFactor @@ -1867,6 +2061,13 @@ type RideHailingProvider { type RiderCategory { "ID of the category" id: String! + """ + If this category is considered the "default" one. In most places this means "Adult" or + "Regular". + Frontends can use this property to display this category more prominently or pre-select this + in a UI. + """ + isDefault: Boolean! "Human readable name of the category." name: String } @@ -1992,6 +2193,15 @@ type RoutingError { inputField: InputField } +"A single use of a set of stairs." +type StairsUse { + "The level the use begins at." + from: Level + "The level the use ends at." + to: Level + verticalDirection: VerticalDirection! +} + """ Stop can represent either a single public transport stop, where passengers can board and/or disembark vehicles, or a station, which contains multiple stops. @@ -2010,7 +2220,7 @@ type Stop implements Node & PlaceInterface { types: [StopAlertType] ): [Alert] "The cluster which this stop is part of" - cluster: Cluster + cluster: Cluster @deprecated(reason : "Not implemented") "Stop code which is visible at the stop" code: String "Description of the stop, usually a street name" @@ -2152,13 +2362,28 @@ type Stop implements Node & PlaceInterface { https://developers.google.com/transit/gtfs/reference/#routestxt and https://developers.google.com/transit/gtfs/reference/extended-route-types """ - vehicleType: Int + vehicleType: Int @deprecated(reason : "Not implemented. Use `vehicleMode`.") @timingData "Whether wheelchair boarding is possible for at least some of vehicles on this stop" wheelchairBoarding: WheelchairBoarding "ID of the zone where this stop is located" zoneId: String } +""" +Represents the time or time window when a specific trip on a specific date arrives to and/or departs +from a specific stop location. + +This may contain real-time information, if available. +""" +type StopCall { + "Real-time estimates for arrival and departure times for this stop location." + realTime: CallRealTime + "Scheduled arrival and departure times for this stop location." + schedule: CallSchedule + "The stop where this arrival/departure happens." + stopLocation: CallStopLocation! +} + type StopGeometries { "Representation of the stop geometries as GeoJSON (https://geojson.org/)" geoJson: GeoJson @@ -2314,6 +2539,12 @@ type TicketType implements Node { zones: [String!] } +"A time window when a vehicle visits a stop, area or group of stops." +type TimeWindow { + end: OffsetDateTime! + start: OffsetDateTime! +} + "Text with language" type TranslatedString { "Two-letter language code (ISO 639-1)" @@ -2336,7 +2567,11 @@ type Trip implements Node { """ types: [TripAlertType] ): [Alert] - "Arrival time to the final stop" + """ + Arrival time to the final stop. If the trip does not run on the given date, + it will return scheduled times from another date. This field is slightly + confusing and will be deprecated when a better replacement is implemented. + """ arrivalStoptime( """ Date for which the arrival time is returned. Format: YYYYMMDD. If this @@ -2347,7 +2582,11 @@ type Trip implements Node { "Whether bikes are allowed on board the vehicle running this trip" bikesAllowed: BikesAllowed blockId: String - "Departure time from the first stop" + """ + Departure time from the first stop. If the trip does not run on the given date, + it will return scheduled times from another date. This field is slightly + confusing and will be deprecated when a better replacement is implemented. + """ departureStoptime( """ Date for which the departure time is returned. Format: YYYYMMDD. If this @@ -2386,6 +2625,12 @@ type Trip implements Node { stops: [Stop!]! "List of times when this trip arrives to or departs from a stop" stoptimes: [Stoptime] + """ + List of times when this trip arrives to or departs from each stop on a given date, or + today if the date is not given. If the trip does not run on the given date, it will + return scheduled times from another date. This field is slightly confusing and + will be deprecated when a better replacement is implemented. + """ stoptimesForDate( "Date for which stoptimes are returned. Format: YYYYMMDD" serviceDate: String @@ -2414,6 +2659,60 @@ type TripOccupancy { occupancyStatus: OccupancyStatus } +"A trip on a specific service date." +type TripOnServiceDate { + "Information related to trip's scheduled arrival to the final stop location. Can contain real-time information." + end: StopCall! + """ + The service date when the trip occurs. + + **Note**: A service date is a technical term useful for transit planning purposes and might not + correspond to a how a passenger thinks of a calendar date. For example, a night bus running + on Sunday morning at 1am to 3am, might have the previous Saturday's service date. + """ + serviceDate: LocalDate! + "Information related to trip's scheduled departure from the first stop location. Can contain real-time information." + start: StopCall! + "List of times when this trip arrives to or departs from a stop location and information related to the visit to the stop location." + stopCalls: [StopCall!]! + "This trip on service date is an instance of this trip." + trip: Trip +} + +""" +A connection to a list of trips on service dates that follows +[GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm). +""" +type TripOnServiceDateConnection { + """ + Edges which contain the trips. Part of the + [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm). + """ + edges: [TripOnServiceDateEdge] + """ + Contains cursors to fetch more pages of trips. + Part of the [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm). + """ + pageInfo: PageInfo! +} + +""" +An edge for TripOnServiceDate connection. Part of the +[GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm). +""" +type TripOnServiceDateEdge { + """ + The cursor of the edge. Part of the + [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm). + """ + cursor: String! + """ + Trip on a service date as a node. Part of the + [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm). + """ + node: TripOnServiceDate +} + "This is used for alert entities that we don't explicitly handle or they are missing." type Unknown { "Entity's description" @@ -2497,8 +2796,10 @@ type VehiclePosition { heading: Float "Human-readable label of the vehicle, eg. a publicly visible number or a license plate" label: String + "When the position of the vehicle was recorded." + lastUpdate: OffsetDateTime "When the position of the vehicle was recorded in seconds since the UNIX epoch." - lastUpdated: Long + lastUpdated: Long @deprecated(reason : "Use `lastUpdate` instead.") @timingData "Latitude of the vehicle" lat: Float "Longitude of the vehicle" @@ -2558,7 +2859,7 @@ type VehicleRentalStation implements Node & PlaceInterface { "Name of the vehicle rental station" name: String! "ID of the rental network." - network: String @deprecated(reason : "Use `networkId` from `rentalNetwork` instead.") + network: String @deprecated(reason : "Use `networkId` from `rentalNetwork` instead.") @timingData "If true, station is on and in service." operative: Boolean """ @@ -2578,14 +2879,14 @@ type VehicleRentalStation implements Node & PlaceInterface { the rental station, even if the vehicle racks don't have any spaces available. See field `allowDropoffNow` to know if is currently possible to return a vehicle. """ - spacesAvailable: Int @deprecated(reason : "Use `availableSpaces` instead, which also contains the space vehicle types") + spacesAvailable: Int @deprecated(reason : "Use `availableSpaces` instead, which also contains the space vehicle types") @timingData "ID of the vehicle in the format of network:id" stationId: String """ Number of vehicles currently available on the rental station. See field `allowPickupNow` to know if is currently possible to pick up a vehicle. """ - vehiclesAvailable: Int @deprecated(reason : "Use `availableVehicles` instead, which also contains vehicle types") + vehiclesAvailable: Int @deprecated(reason : "Use `availableVehicles` instead, which also contains vehicle types") @timingData } type VehicleRentalUris { @@ -2631,12 +2932,12 @@ type fare { Fare price in cents. **Note:** this value is dependent on the currency used, as one cent is not necessarily ¹/₁₀₀ of the basic monerary unit. """ - cents: Int @deprecated(reason : "No longer supported") + cents: Int @deprecated(reason : "No longer supported") @timingData "Components which this fare is composed of" - components: [fareComponent] @deprecated(reason : "No longer supported") + components: [fareComponent] @deprecated(reason : "No longer supported") @timingData "ISO 4217 currency code" - currency: String @deprecated(reason : "No longer supported") - type: String @deprecated(reason : "No longer supported") + currency: String @deprecated(reason : "No longer supported") @timingData + type: String @deprecated(reason : "No longer supported") @timingData } """ @@ -2648,13 +2949,13 @@ type fareComponent { Fare price in cents. **Note:** this value is dependent on the currency used, as one cent is not necessarily ¹/₁₀₀ of the basic monerary unit. """ - cents: Int @deprecated(reason : "No longer supported") + cents: Int @deprecated(reason : "No longer supported") @timingData "ISO 4217 currency code" - currency: String @deprecated(reason : "No longer supported") + currency: String @deprecated(reason : "No longer supported") @timingData "ID of the ticket type. Corresponds to `fareId` in **TicketType**." - fareId: String @deprecated(reason : "No longer supported") + fareId: String @deprecated(reason : "No longer supported") @timingData "List of routes which use this fare component" - routes: [Route] @deprecated(reason : "No longer supported") + routes: [Route] @deprecated(reason : "No longer supported") @timingData } type placeAtDistance implements Node { @@ -2686,13 +2987,6 @@ type serviceTimeRange { start: Long } -type Entrance { - publicCode: String - wheelchairAccessible: Boolean -} - -union StepFeature = Entrance - type step { "The cardinal (compass) direction (e.g. north, northeast) taken when engaging this step." absoluteDirection: AbsoluteDirection @@ -2714,6 +3008,8 @@ type step { elevationProfile: [elevationProfileComponent] "When exiting a highway or traffic circle, the exit name/number." exit: String + "Information about a feature associated with a step e.g. a station entrance or exit." + feature: StepFeature "The latitude of the start of the step." lat: Float "The longitude of the start of the step." @@ -2726,7 +3022,6 @@ type step { streetName: String "Is this step walking with a bike?" walkingBike: Boolean - feature: StepFeature } type stopAtDistance implements Node { @@ -2839,7 +3134,7 @@ enum AlertSeverityLevelType { INFO """ Severe alerts are used when a significant part of public transport services is - affected, for example: All train services are cancelled due to technical problems. + affected, for example: All train services are canceled due to technical problems. """ SEVERE "Severity of alert is unknown" @@ -2890,6 +3185,22 @@ enum CyclingOptimizationType { SHORTEST_DURATION } +""" +Dependent fare products can lead to many combinations of fares, however it is often not useful +information to the passenger. + +This enum allows filtering of the dependencies. + +Since it is recognised that this is not covered well in the specification, it is discussed here: +https://github.com/google/transit/pull/423 +""" +enum DependentFareProductFilter { + "Show all dependencies" + ALL + "Show only dependencies where the rider category and medium is the same es the main fare product." + MATCH_CATEGORY_AND_MEDIUM +} + "Entities, which are relevant for a feed and can contain alerts" enum FeedAlertType { "Alerts affecting the feed's agencies" @@ -2906,9 +3217,9 @@ enum FilterPlaceType { "Old value for VEHICLE_RENT" BICYCLE_RENT @deprecated(reason : "Use VEHICLE_RENT instead as it's clearer that it also returns rental scooters, cars...") "Parking lots (not rental stations) that contain spaces for bicycles" - BIKE_PARK + BIKE_PARK @deprecated(reason : "Not supported.") "Parking lots that contain spaces for cars" - CAR_PARK + CAR_PARK @deprecated(reason : "Not supported.") "Departure rows" DEPARTURE_ROW """ @@ -2948,6 +3259,7 @@ enum InputField { DATE_TIME FROM TO + VIA } """ @@ -3389,15 +3701,41 @@ enum RealtimeState { UPDATED } -"Actions to take relative to the current position when engaging a walking/driving step." +""" +A direction that is not absolute but rather fuzzy and context-dependent. +It provides the passenger with information what they should do in this step depending on where they +were in the previous one. +""" enum RelativeDirection { CIRCLE_CLOCKWISE CIRCLE_COUNTERCLOCKWISE + """ + Moving straight ahead in one of these cases + + - Passing through a crossing or intersection. + - Passing through a station entrance or exit when it is not know whether the passenger is + entering or exiting. If it _is_ known then `ENTER_STATION`/`EXIT_STATION` is used. + More information about the entrance is in the `step.feature` field. + """ CONTINUE DEPART ELEVATOR + """ + Entering a public transport station. If it's not known if the passenger is entering or exiting + then `CONTINUE` is used. + + More information about the entrance is in the `step.feature` field. + """ ENTER_STATION + ESCALATOR + """ + Exiting a public transport station. If it's not known if the passenger is entering or exiting + then `CONTINUE` is used. + + More information about the entrance is in the `step.feature` field. + """ EXIT_STATION + "Follow the signs indicating a specific location like \"platform 1\" or \"exit B\"." FOLLOW_SIGNS HARD_LEFT HARD_RIGHT @@ -3405,6 +3743,7 @@ enum RelativeDirection { RIGHT SLIGHTLY_LEFT SLIGHTLY_RIGHT + STAIRS UTURN_LEFT UTURN_RIGHT } @@ -3528,6 +3867,15 @@ enum StopAlertType { TRIPS } +enum StopType { + "An area or zone represented by a polygon. Introduced by the GTFS-Flex spec process." + LOCATION + "A group of stops. Introduced by the GTFS-Flex spec process." + LOCATION_GROUP + "A fixed stop represented by a coordinate." + STOP +} + """ Transit modes include modes that are used within organized transportation networks run by public transportation authorities, taxi companies etc. @@ -3611,6 +3959,13 @@ enum VertexType { TRANSIT } +"The vertical direction e.g. for a set of stairs." +enum VerticalDirection { + DOWN + UNKNOWN + UP +} + enum WheelchairBoarding { "Wheelchair boarding is not possible at this stop." NOT_POSSIBLE @@ -3732,7 +4087,10 @@ input BicyclePreferencesInput { "Preferences related to bicycle rental (station based or floating bicycle rental)." input BicycleRentalPreferencesInput { - "Rental networks which can be potentially used as part of an itinerary." + """ + Rental networks which can be potentially used as part of an itinerary. If this field has no default value, + it means that all networks are allowed unless some are banned with `bannedNetworks`. + """ allowedNetworks: [String!] "Rental networks which cannot be used as part of an itinerary." bannedNetworks: [String!] @@ -3813,6 +4171,8 @@ input CarParkingPreferencesInput { "Preferences related to traveling on a car (excluding car travel on transit services such as taxi)." input CarPreferencesInput { + "Cost of boarding a vehicle with a car." + boardCost: Cost "Car parking related preferences." parking: CarParkingPreferencesInput "A multiplier for how bad travelling on car is compared to being in transit for equal lengths of time." @@ -3823,7 +4183,10 @@ input CarPreferencesInput { "Preferences related to car rental (station based or floating car rental)." input CarRentalPreferencesInput { - "Rental networks which can be potentially used as part of an itinerary." + """ + Rental networks which can be potentially used as part of an itinerary. If this field has no default value, + it means that all networks are allowed unless some are banned with `bannedNetworks`. + """ allowedNetworks: [String!] "Rental networks which cannot be used as part of an itinerary." bannedNetworks: [String!] @@ -3877,14 +4240,14 @@ input InputBanned { banned for boarding and disembarking vehicles — it is possible to get an itinerary where a vehicle stops at one of these stops """ - stops: String @deprecated(reason : "Not implemented in OTP2.") + stops: String @deprecated(reason : "Not implemented") """ A comma-separated list of banned stop ids. Only itineraries where these stops are not travelled through are returned, e.g. if a bus route stops at one of these stops, that route will not be used in the itinerary, even if the stop is not used for boarding or disembarking the vehicle. """ - stopsHard: String @deprecated(reason : "Not implemented in OTP2.") + stopsHard: String @deprecated(reason : "Not implemented") "A comma-separated list of banned trip ids" trips: String } @@ -3947,15 +4310,11 @@ input InputModeWeight { } input InputPreferred { - "A comma-separated list of ids of the agencies preferred by the user." + "Not implemented" agencies: String - """ - Penalty added for using every route that is not preferred if user set any - route as preferred. We return number of seconds that we are willing to wait - for preferred route. - """ + "Not implemented" otherThanPreferredRoutesPenalty: Int - "A comma-separated list of ids of the routes preferred by the user." + "Not implemented" routes: String } @@ -4138,7 +4497,6 @@ input PlanModesInput { If more than one mode is selected, at least one of them must be used but not necessarily all. There are modes that automatically also use walking such as the rental modes. To force rental to be used, this should only include the rental mode and not `WALK` in addition. - The default access mode is `WALK`. """ direct: [PlanDirectMode!] "Should only the direct search without any transit be done." @@ -4146,8 +4504,7 @@ input PlanModesInput { """ Modes for different phases of an itinerary when transit is included. Also includes street mode selections related to connecting to the transit network - and transfers. By default, all transit modes are usable and `WALK` is used for - access, egress and transfers. + and transfers. By default, all transit modes are usable. """ transit: PlanTransitModesInput """ @@ -4240,7 +4597,6 @@ input PlanTransitModesInput { If more than one mode is selected, at least one of them must be used but not necessarily all. There are modes that automatically also use walking such as the rental modes. To force rental to be used, this should only include the rental mode and not `WALK` in addition. - The default access mode is `WALK`. """ access: [PlanAccessMode!] """ @@ -4248,13 +4604,9 @@ input PlanTransitModesInput { If more than one mode is selected, at least one of them must be used but not necessarily all. There are modes that automatically also use walking such as the rental modes. To force rental to be used, this should only include the rental mode and not `WALK` in addition. - The default access mode is `WALK`. """ egress: [PlanEgressMode!] - """ - Street mode that is used when searching for transfers. Selection of only one allowed for now. - The default transfer mode is `WALK`. - """ + "Street mode that is used when searching for transfers. Selection of only one allowed for now." transfer: [PlanTransferMode!] """ Transit modes and reluctances associated with them. Each defined mode can be used in @@ -4280,10 +4632,14 @@ A visit-via-location is a physical visit to one of the stop locations or coordin 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 { + """ + A coordinate to route through. 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. + """ + coordinate: PlanCoordinateInput "The label/name of the location. This is pass-through information and is not used in routing." label: String """ @@ -4327,7 +4683,10 @@ input ScooterPreferencesInput { "Preferences related to scooter rental (station based or floating scooter rental)." input ScooterRentalPreferencesInput { - "Rental networks which can be potentially used as part of an itinerary." + """ + Rental networks which can be potentially used as part of an itinerary. If this field has no default value, + it means that all networks are allowed unless some are banned with `bannedNetworks`. + """ allowedNetworks: [String!] "Rental networks which cannot be used as part of an itinerary." bannedNetworks: [String!] @@ -4343,21 +4702,21 @@ input TimetablePreferencesInput { When false, real-time updates are considered during the routing. In practice, when this option is set as true, some of the suggestions might not be realistic as the transfers could be invalid due to delays, - trips can be cancelled or stops can be skipped. + trips can be canceled or stops can be skipped. """ excludeRealTimeUpdates: Boolean """ - When true, departures that have been cancelled ahead of time will be + When true, departures that have been canceled ahead of time will be included during the routing. This means that an itinerary can include - a cancelled departure while some other alternative that contains no cancellations + a canceled departure while some other alternative that contains no cancellations could be filtered out as the alternative containing a cancellation would normally be better. """ includePlannedCancellations: Boolean """ - When true, departures that have been cancelled through a real-time feed will be + When true, departures that have been canceled through a real-time feed will be included during the routing. This means that an itinerary can include - a cancelled departure while some other alternative that contains no cancellations + a canceled departure while some other alternative that contains no cancellations could be filtered out as the alternative containing a cancellation would normally be better. This option can't be set to true while `includeRealTimeUpdates` is false. """ @@ -4376,15 +4735,61 @@ input TransferPreferencesInput { "How many transfers there can be at maximum in an itinerary." maximumTransfers: Int """ - A global minimum transfer time (in seconds) that specifies the minimum amount of time - that must pass between exiting one transit vehicle and boarding another. This time is - in addition to time it might take to walk between transit stops. Setting this value - as `PT0S`, for example, can lead to passenger missing a connection when the vehicle leaves - ahead of time or the passenger arrives to the stop later than expected. + A global minimum transfer time that specifies the minimum amount of time that must pass + between exiting one transit vehicle and boarding another. This time is in addition to + time it might take to walk between transit stops. Setting this value as `PT0S`, for + example, can lead to passenger missing a connection when the vehicle leaves ahead of time + or the passenger arrives to the stop later than expected. """ slack: Duration } +""" +A collection of selectors for what routes/agencies should be included in or excluded from the search. + +The `include` is always applied to select the initial set, then `exclude` to remove elements. +If only `exclude` is present, the exclude is applied to the existing set of results. + +Therefore, if an entity is both included _and_ excluded the exclusion takes precedence. +""" +input TransitFilterInput { + """ + A list of selectors for what routes/agencies should be excluded during search. + + In order to be excluded, a route/agency has to match with at least one selector. + + An empty list or a list containing `null` is forbidden. + """ + exclude: [TransitFilterSelectInput!] + """ + A list of selectors for what routes/agencies should be allowed during the search. + + If route/agency matches with at least one selector it will be included. + + An empty list or a list containing `null` is forbidden. + """ + include: [TransitFilterSelectInput!] +} + +""" +A list of selectors for including or excluding entities from the routing results. Null +means everything is allowed to be returned and empty lists are not allowed. +""" +input TransitFilterSelectInput @oneOf { + """ + Set of ids for agencies that should be included in/excluded from the search. + + Format: `FeedId:AgencyId` + """ + agencies: [String!] + """ + Set of ids for routes that should be included in/excluded from the search. + + Format: `FeedId:AgencyId` + """ + routes: [String!] +} + "Costs related to using a transit mode." input TransitModePreferenceCostInput { "A cost multiplier of transit leg travel time." @@ -4400,6 +4805,57 @@ input TransitPreferencesInput { can be found under the street mode preferences. """ board: BoardPreferencesInput + """ + A list of filters for which trips should be included or excluded. A trip will be considered in the + result if it is included by at least one filter and isn't excluded by another one at the same time. + + An empty list of filters or no value means that all trips should be included. + + **AND/OR Logic** + + Several filters can be defined and form an OR-condition. + + The following example means "include all trips with `F:route1` _or_ `F:agency1`": + + ``` + filters: [ + { + include: { + routes: ["F:route1"] + } + }, + { + include: { + agencies: ["F:agency1"] + } + } + }] + ``` + + The following example means "include all trips of `F:agency1` _and_ exclude `F:route1`": + + ``` + filters: [ + { + include: { + agencies: ["F:agency1"] + }, + exclude: { + routes: ["F:route1"] + } + } + ] + ``` + + Be aware of the following pitfalls: + - It is easy to construct AND-conditions that can lead to zero results. + - OR-conditions that have an element which consists of only an exclude are likely to be + unwanted and may lead to unexpected results. + + **Note**: This parameter also interacts with the modes set in `modes.transit` by forming + an AND-condition. + """ + filters: [TransitFilterInput!] "Preferences related to cancellations and real-time." timetable: TimetablePreferencesInput "Preferences related to transfers between transit vehicles (typically between stops)." diff --git a/docs/ZIndex.md b/docs/ZIndex.md index 5742e8cf12..b1363b262c 100644 --- a/docs/ZIndex.md +++ b/docs/ZIndex.md @@ -28,3 +28,5 @@ Selector | Component | Z-Index | Comment `.itinerary-summary-row { .itinerary-legs { .line` | Summary result row leg lines | 1 | `.itinerary-summary-row { .itinerary-legs { .line { :after` | Hides the Summary result row leg lines behind the mode icon. | -1 | `.mobile.top-bar | Mobile top bar | 1000 | +`.TODO-map-cluster-marker` | Cluster objects for indoor routing | 13000 | TODO +`.TODO-map-subway-entrance-info-icon-metro` | Entrance markers for indoor routing | 13100 | TODO diff --git a/static/assets/svg-sprite.default.svg b/static/assets/svg-sprite.default.svg index 8f4184f552..25fce86d5c 100644 --- a/static/assets/svg-sprite.default.svg +++ b/static/assets/svg-sprite.default.svg @@ -372,6 +372,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/assets/svg-sprite.hsl.svg b/static/assets/svg-sprite.hsl.svg index 34bfd903df..486b571e5d 100644 --- a/static/assets/svg-sprite.hsl.svg +++ b/static/assets/svg-sprite.hsl.svg @@ -216,6 +216,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/unit/WalkLeg.test.js b/test/unit/WalkLeg.test.js index fc634ab982..91ec43a585 100644 --- a/test/unit/WalkLeg.test.js +++ b/test/unit/WalkLeg.test.js @@ -11,6 +11,7 @@ describe('', () => { const props = { focusAction: () => {}, focusToLeg: () => {}, + focusToPoint: () => {}, index: 2, leg: { distance: 284.787, @@ -27,6 +28,20 @@ describe('', () => { rentedBike: false, start: { scheduledTime: new Date(1529589709000).toISOString() }, end: { scheduledTime: new Date(1529589701000).toISOString() }, + steps: [ + { + streetName: 'entrance', + area: false, + relativeDirection: 'CONTINUE', + absoluteDirection: null, + feature: { + __typename: 'Entrance', + publicCode: 'A', + entranceId: 'osm:123', + wheelchairAccessible: 'POSSIBLE', + }, + }, + ], }, }; @@ -43,6 +58,7 @@ describe('', () => { const props = { focusAction: () => {}, focusToLeg: () => {}, + focusToPoint: () => {}, index: 2, leg: { distance: 284.787, @@ -59,6 +75,20 @@ describe('', () => { rentedBike: false, start: { scheduledTime: new Date(1529589709000).toISOString() }, end: { scheduledTime: new Date(1529589701000).toISOString() }, + steps: [ + { + streetName: 'entrance', + area: false, + relativeDirection: 'CONTINUE', + absoluteDirection: null, + feature: { + __typename: 'Entrance', + publicCode: 'A', + entranceId: 'osm:123', + wheelchairAccessible: 'POSSIBLE', + }, + }, + ], }, previousLeg: { distance: 3297.017000000001, @@ -94,6 +124,7 @@ describe('', () => { const props = { focusAction: () => {}, focusToLeg: () => {}, + focusToPoint: () => {}, index: 2, leg: { distance: 284.787, @@ -119,6 +150,20 @@ describe('', () => { rentedBike: false, start: { scheduledTime: new Date(startTime).toISOString() }, end: { scheduledTime: new Date(1529589701000).toISOString() }, + steps: [ + { + streetName: 'entrance', + area: false, + relativeDirection: 'CONTINUE', + absoluteDirection: null, + feature: { + __typename: 'Entrance', + publicCode: 'A', + entranceId: 'osm:123', + wheelchairAccessible: 'POSSIBLE', + }, + }, + ], }, }; @@ -135,6 +180,7 @@ describe('', () => { const props = { focusAction: () => {}, focusToLeg: () => {}, + focusToPoint: () => {}, index: 1, leg: { distance: 1.23, @@ -157,6 +203,20 @@ describe('', () => { rentedBike: false, start: { scheduledTime: new Date(1668600030868).toISOString() }, end: { scheduledTime: new Date(1668600108525).toISOString() }, + steps: [ + { + streetName: 'entrance', + area: false, + relativeDirection: 'CONTINUE', + absoluteDirection: null, + feature: { + __typename: 'Entrance', + publicCode: 'A', + entranceId: 'osm:123', + wheelchairAccessible: 'POSSIBLE', + }, + }, + ], }, }; From 5495430e93686729797dbca0fabf7a9290e4d728 Mon Sep 17 00:00:00 2001 From: Ville Pihlava Date: Wed, 10 Dec 2025 14:35:37 +0200 Subject: [PATCH 2/6] Add indoor routing to other views. --- app/component/itinerary/WalkLeg.js | 1 + .../itinerary/navigator/NaviCardExtension.js | 2 + app/component/map/ItineraryLine.js | 38 +++++++---- app/component/map/NearYouMap.js | 15 ++++- app/component/map/StopPageMap.js | 18 ++++-- app/component/map/WalkQuery.js | 2 + app/component/stop/StopPageMapContainer.js | 1 + .../stop/TerminalPageMapContainer.js | 1 + app/util/indoorUtils.js | 63 ++++++++++--------- app/util/shapes.js | 1 - 10 files changed, 91 insertions(+), 51 deletions(-) diff --git a/app/component/itinerary/WalkLeg.js b/app/component/itinerary/WalkLeg.js index 86b37eb6b4..44cf7ee032 100644 --- a/app/component/itinerary/WalkLeg.js +++ b/app/component/itinerary/WalkLeg.js @@ -88,6 +88,7 @@ function WalkLeg( const indoorRouteSteps = getIndoorStepsWithVerticalTransportationUse( previousLeg, leg, + nextLeg, ); useEffect(() => { diff --git a/app/component/itinerary/navigator/NaviCardExtension.js b/app/component/itinerary/navigator/NaviCardExtension.js index 51b72e718e..3c3d7dd9eb 100644 --- a/app/component/itinerary/navigator/NaviCardExtension.js +++ b/app/component/itinerary/navigator/NaviCardExtension.js @@ -105,6 +105,7 @@ const NaviCardExtension = ( indoorRouteSteps = getIndoorStepsWithVerticalTransportationUse( previousLeg, leg, + nextLeg, ); } return ( @@ -190,6 +191,7 @@ const NaviCardExtension = ( const indoorRouteSteps = getIndoorStepsWithVerticalTransportationUse( previousLeg, leg, + nextLeg, ); return (
diff --git a/app/component/map/ItineraryLine.js b/app/component/map/ItineraryLine.js index ce3674af1e..c94e9d532a 100644 --- a/app/component/map/ItineraryLine.js +++ b/app/component/map/ItineraryLine.js @@ -19,10 +19,15 @@ import EntranceMarker from './EntranceMarker'; import ClusterNumberMarker from './ClusterNumberMarker'; import IndoorRouteStepMarker from './IndoorRouteStepMarker'; import { createFeatureObjects } from '../../util/clusterUtils'; -import { ClusterMarkerType, WheelchairBoarding } from '../../constants'; +import { + ClusterMarkerType, + IndoorRouteLegType, + WheelchairBoarding, +} from '../../constants'; import { getEntranceObject, getEntranceWheelchairAccessibility, + getIndoorRouteLegType, getIndoorStepsWithVerticalTransportationUse, } from '../../util/indoorUtils'; @@ -80,6 +85,7 @@ class ItineraryLine extends React.Component { objs, clusterObjs, entranceObject, + indoorRouteLegType, ) { const entranceCoordinates = [entranceObject.lat, entranceObject.lon]; const getDistance = (coord1, coord2) => { @@ -127,7 +133,11 @@ class ItineraryLine extends React.Component { color={leg.route && leg.route.color ? `#${leg.route.color}` : null} key={`${this.props.hash}_${i}_${mode}_0`} geometry={geometry.slice(0, entranceIndex + 1)} - mode={nextLeg?.mode === 'SUBWAY' ? 'walk' : 'walk-inside'} + mode={ + indoorRouteLegType === IndoorRouteLegType.StepsBeforeEntranceInside + ? 'walk-inside' + : 'walk' + } passive={this.props.passive} />, ); @@ -136,7 +146,11 @@ class ItineraryLine extends React.Component { color={leg.route && leg.route.color ? `#${leg.route.color}` : null} key={`${this.props.hash}_${i}_${mode}_1`} geometry={geometry.slice(entranceIndex)} - mode={nextLeg?.mode === 'SUBWAY' ? 'walk-inside' : 'walk'} + mode={ + indoorRouteLegType === IndoorRouteLegType.StepsAfterEntranceInside + ? 'walk-inside' + : 'walk' + } passive={this.props.passive} />, ); @@ -144,11 +158,8 @@ class ItineraryLine extends React.Component { handleLine(previousLeg, leg, nextLeg, mode, i, geometry, objs, clusterObjs) { const entranceObject = getEntranceObject(previousLeg, leg); - if ( - leg.mode === 'WALK' && - (nextLeg?.mode === 'SUBWAY' || previousLeg?.mode === 'SUBWAY') && - entranceObject - ) { + const indoorRouteLegType = getIndoorRouteLegType(previousLeg, leg, nextLeg); + if (indoorRouteLegType !== IndoorRouteLegType.NoStepsInside) { this.handleEntrance( leg, nextLeg, @@ -158,6 +169,7 @@ class ItineraryLine extends React.Component { objs, clusterObjs, entranceObject, + indoorRouteLegType, ); } else { objs.push( @@ -226,11 +238,12 @@ class ItineraryLine extends React.Component { } } - handleIndoorRouteStepMarkers(previousLeg, leg, clusterObjs) { + handleIndoorRouteStepMarkers(previousLeg, leg, nextLeg, clusterObjs) { if (!this.props.passive) { const indoorRouteSteps = getIndoorStepsWithVerticalTransportationUse( previousLeg, leg, + nextLeg, ); if (indoorRouteSteps) { @@ -266,7 +279,7 @@ class ItineraryLine extends React.Component { this.setState({ zoom }); }; - handleClusterObjects(previousLeg, leg, objs, clusterObjs) { + handleClusterObjects(previousLeg, leg, nextLeg, objs, clusterObjs) { if (!this.props.passive) { const index = new Supercluster({ radius: 60, // in pixels @@ -340,6 +353,7 @@ class ItineraryLine extends React.Component { indoorRouteSteps={getIndoorStepsWithVerticalTransportationUse( previousLeg, leg, + nextLeg, )} />, ); @@ -417,8 +431,8 @@ class ItineraryLine extends React.Component { ); this.handleDurationBubble(leg, mode, i, objs, middle); this.handleIntermediateStops(leg, mode, objs); - this.handleIndoorRouteStepMarkers(previousLeg, leg, clusterObjs); - this.handleClusterObjects(previousLeg, leg, objs, clusterObjs); + this.handleIndoorRouteStepMarkers(previousLeg, leg, nextLeg, clusterObjs); + this.handleClusterObjects(previousLeg, leg, nextLeg, objs, clusterObjs); if (!this.props.passive) { if (rentalId) { diff --git a/app/component/map/NearYouMap.js b/app/component/map/NearYouMap.js index 71690b963e..30d193ce86 100644 --- a/app/component/map/NearYouMap.js +++ b/app/component/map/NearYouMap.js @@ -160,6 +160,17 @@ function NearYouMap( const fetchPlan = stop => { if (stop.distance < walkRoutingThreshold) { const settings = getSettings(context.config); + let location = { + coordinate: { + latitude: stop.lat, + longitude: stop.lon, + }, + }; + if (stop.gtfsId) { + location = { + stopLocation: { stopLocationId: stop.gtfsId }, + }; + } const variables = { origin: { location: { @@ -167,9 +178,7 @@ function NearYouMap( }, }, destination: { - location: { - coordinate: { latitude: stop.lat, longitude: stop.lon }, - }, + location, }, walkSpeed: settings.walkSpeed, wheelchair: !!settings.accessibilityOption, diff --git a/app/component/map/StopPageMap.js b/app/component/map/StopPageMap.js index ac199f1a7a..a927b4db90 100644 --- a/app/component/map/StopPageMap.js +++ b/app/component/map/StopPageMap.js @@ -65,6 +65,17 @@ function StopPageMap( if (locationState.hasLocation) { if (distance(locationState, stop) < maxShowRouteDistance) { const settings = getSettings(config); + let location = { + coordinate: { + latitude: targetStop.lat, + longitude: targetStop.lon, + }, + }; + if (targetStop.gtfsId) { + location = { + stopLocation: { stopLocationId: targetStop.gtfsId }, + }; + } const variables = { origin: { location: { @@ -75,12 +86,7 @@ function StopPageMap( }, }, destination: { - location: { - coordinate: { - latitude: targetStop.lat, - longitude: targetStop.lon, - }, - }, + location, }, walkSpeed: settings.walkSpeed, wheelchair: !!settings.accessibilityOption, diff --git a/app/component/map/WalkQuery.js b/app/component/map/WalkQuery.js index 891dfe121f..44b90cee22 100644 --- a/app/component/map/WalkQuery.js +++ b/app/component/map/WalkQuery.js @@ -120,6 +120,7 @@ const walkQuery = graphql` gtfsId code platformCode + vehicleMode } } to { @@ -141,6 +142,7 @@ const walkQuery = graphql` gtfsId code platformCode + vehicleMode } } intermediatePlaces { diff --git a/app/component/stop/StopPageMapContainer.js b/app/component/stop/StopPageMapContainer.js index 39d008ad83..3b0677ab9a 100644 --- a/app/component/stop/StopPageMapContainer.js +++ b/app/component/stop/StopPageMapContainer.js @@ -34,6 +34,7 @@ const containerComponent = createFragmentContainer(StopPageMapContainer, { desc vehicleMode locationType + gtfsId } `, }); diff --git a/app/component/stop/TerminalPageMapContainer.js b/app/component/stop/TerminalPageMapContainer.js index 79b89dd628..8c25d7719b 100644 --- a/app/component/stop/TerminalPageMapContainer.js +++ b/app/component/stop/TerminalPageMapContainer.js @@ -39,6 +39,7 @@ const containerComponent = createFragmentContainer(TerminalPageMapContainer, { desc vehicleMode locationType + gtfsId } `, }); diff --git a/app/util/indoorUtils.js b/app/util/indoorUtils.js index 9d4560c4ab..25c6c23190 100644 --- a/app/util/indoorUtils.js +++ b/app/util/indoorUtils.js @@ -73,17 +73,40 @@ export function getEntranceStepIndex(previousLeg, leg) { return getEntranceObject(previousLeg, leg)?.index; } -export function getIndoorSteps(previousLeg, leg) { +export function getIndoorRouteLegType(previousLeg, leg, nextLeg) { + const entranceObject = getEntranceObject(previousLeg, leg); + // Outdoor routing starts from an entrance if the leg started from the subway. + if ( + entranceObject && + ((leg.mode === 'WALK' && previousLeg?.mode === 'SUBWAY') || + leg.from.stop?.vehicleMode === 'SUBWAY') + ) { + return IndoorRouteLegType.StepsBeforeEntranceInside; + } + // Indoor routing starts from an entrance if the leg ends in the subway. + if ( + entranceObject && + ((leg.mode === 'WALK' && nextLeg?.mode === 'SUBWAY') || + leg.to.stop?.vehicleMode === 'SUBWAY') + ) { + return IndoorRouteLegType.StepsAfterEntranceInside; + } + return IndoorRouteLegType.NoStepsInside; +} + +export function getIndoorSteps(previousLeg, leg, nextLeg) { const entranceIndex = getEntranceStepIndex(previousLeg, leg); if (!entranceIndex) { return []; } - // Outdoor routing starts from entrance. - if (leg.mode === 'WALK' && previousLeg?.mode === 'SUBWAY') { + const indoorRouteLegType = getIndoorRouteLegType(previousLeg, leg, nextLeg); + if (indoorRouteLegType === IndoorRouteLegType.StepsBeforeEntranceInside) { return leg.steps.slice(0, entranceIndex + 1); } - // Indoor routing starts from entrance. - return leg.steps.slice(entranceIndex); + if (indoorRouteLegType === IndoorRouteLegType.StepsAfterEntranceInside) { + return leg.steps.slice(entranceIndex); + } + return []; } export function isVerticalTransportationUse(relativeDirection) { @@ -94,34 +117,16 @@ export function isVerticalTransportationUse(relativeDirection) { ); } -export function getIndoorStepsWithVerticalTransportationUse(previousLeg, leg) { - return getIndoorSteps(previousLeg, leg).filter(step => +export function getIndoorStepsWithVerticalTransportationUse( + previousLeg, + leg, + nextLeg, +) { + return getIndoorSteps(previousLeg, leg, nextLeg).filter(step => isVerticalTransportationUse(step?.relativeDirection), ); } -export function getIndoorRouteLegType(previousLeg, leg, nextLeg) { - const indoorSteps = getIndoorStepsWithVerticalTransportationUse( - previousLeg, - leg, - ); - if ( - indoorSteps.length > 0 && - leg.mode === 'WALK' && - previousLeg?.mode === 'SUBWAY' - ) { - return IndoorRouteLegType.StepsBeforeEntranceInside; - } - if ( - indoorSteps.length > 0 && - leg.mode === 'WALK' && - nextLeg?.mode === 'SUBWAY' - ) { - return IndoorRouteLegType.StepsAfterEntranceInside; - } - return IndoorRouteLegType.NoStepsInside; -} - export function getIndoorRouteTranslationId( relativeDirection, verticalDirection, diff --git a/app/util/shapes.js b/app/util/shapes.js index 358c58ca87..1da3ed14c3 100644 --- a/app/util/shapes.js +++ b/app/util/shapes.js @@ -294,7 +294,6 @@ export const legShape = PropTypes.shape({ name: PropTypes.string, stop: stopShape, vehicleRentalStation: vehicleRentalStationShape, - bikePark: parkShape, carPark: parkShape, }), From 536af69e1cb070fdd6193c70ec5284ae2e3e5f11 Mon Sep 17 00:00:00 2001 From: Ville Pihlava Date: Wed, 10 Dec 2025 15:24:09 +0200 Subject: [PATCH 3/6] Remove and reword TODO comments. --- app/component/map/ItineraryLine.js | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/app/component/map/ItineraryLine.js b/app/component/map/ItineraryLine.js index c94e9d532a..5a085f4cba 100644 --- a/app/component/map/ItineraryLine.js +++ b/app/component/map/ItineraryLine.js @@ -283,11 +283,9 @@ class ItineraryLine extends React.Component { if (!this.props.passive) { const index = new Supercluster({ radius: 60, // in pixels - maxZoom: 15, // TODO if this is greater than max zoom (17) then max zoom icon can be number, dispay better icon than number + maxZoom: 15, minPoints: 2, extent: 512, // tile size (512) - // minZoom: 13, - // TODO maybe draw cluster icons based on what they have map: properties => ({ iconCount: properties.iconCount, }), @@ -299,15 +297,17 @@ class ItineraryLine extends React.Component { index.load(createFeatureObjects(clusterObjs)); const bbox = [-180, -85, 180, 85]; // Bounding box covers the entire world - // TODO fix to use correct bbox, probably requires moveend event listening?: - /* - const bounds = this.props.leaflet.map.getBounds(); - const bbox = [ - bounds.getWest(), - bounds.getSouth(), - bounds.getEast(), - bounds.getNorth(), - ]; */ + // TODO Fix to use smaller bbox, probably requires moveend event listening? + // The same fix should also be applied to RentalVehicles where supercluster is also used. + // + // const bounds = this.props.leaflet.map.getBounds(); + // const bbox = [ + // bounds.getWest(), + // bounds.getSouth(), + // bounds.getEast(), + // bounds.getNorth(), + // ]; + const clusters = index.getClusters(bbox, this.state.zoom); clusters.forEach(clusterFeature => { const { coordinates } = clusterFeature.geometry; From 3e133d0bb8e29482d22a1a829eb21cdec378a062 Mon Sep 17 00:00:00 2001 From: Ville Pihlava Date: Wed, 10 Dec 2025 15:51:15 +0200 Subject: [PATCH 4/6] Add focusAction on onClick to indoor route step in navigator. --- .../navigator/indoorroute/NaviIndoorRouteStepInfo.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/component/itinerary/navigator/indoorroute/NaviIndoorRouteStepInfo.js b/app/component/itinerary/navigator/indoorroute/NaviIndoorRouteStepInfo.js index 40ef6d0326..dbb773d916 100644 --- a/app/component/itinerary/navigator/indoorroute/NaviIndoorRouteStepInfo.js +++ b/app/component/itinerary/navigator/indoorroute/NaviIndoorRouteStepInfo.js @@ -9,6 +9,7 @@ import { } from '../../../../util/indoorUtils'; import { RelativeDirection, VerticalDirection } from '../../../../constants'; import ItineraryMapAction from '../../ItineraryMapAction'; +import { isKeyboardSelectionEvent } from '../../../../util/browser'; function NaviIndoorRouteStepInfo({ focusAction, @@ -23,7 +24,13 @@ function NaviIndoorRouteStepInfo({ ); return ( -
+
isKeyboardSelectionEvent(e) && focusAction(e)} + > Date: Thu, 11 Dec 2025 12:27:12 +0200 Subject: [PATCH 5/6] Change zIndex, add documentation, and change class names. --- app/component/itinerary/IndoorRouteStep.js | 2 +- app/component/itinerary/itinerary.scss | 2 +- app/component/map/IndoorRouteStepMarker.js | 21 +++++++++++---------- app/component/map/map.scss | 22 +++++++++++----------- docs/ZIndex.md | 5 +++-- 5 files changed, 27 insertions(+), 25 deletions(-) diff --git a/app/component/itinerary/IndoorRouteStep.js b/app/component/itinerary/IndoorRouteStep.js index e02034f2a1..bff6bbfea4 100644 --- a/app/component/itinerary/IndoorRouteStep.js +++ b/app/component/itinerary/IndoorRouteStep.js @@ -59,7 +59,7 @@ function IndoorRouteStep({ > svg > circle.vertical-transportation-use-marker { + > svg > circle.indoor-route-step-marker { fill: #fff; stroke: #666; } diff --git a/app/component/map/IndoorRouteStepMarker.js b/app/component/map/IndoorRouteStepMarker.js index 679f23e782..143b90540d 100644 --- a/app/component/map/IndoorRouteStepMarker.js +++ b/app/component/map/IndoorRouteStepMarker.js @@ -35,7 +35,7 @@ export default function IndoorRouteStepMarker( const iconSvg = ` @@ -65,8 +66,8 @@ export default function IndoorRouteStepMarker( defaultMessage: 'Indoor route', })} /> -
-
+
+
{indoorRouteSteps.map((obj, i, filteredObjs) => ( {filteredObjs.length !== i + 1 ? ( ))}
-
-
+
+
{indoorRouteSteps.map((obj, i) => ( -
+
diff --git a/app/component/map/map.scss b/app/component/map/map.scss index f35a27efc3..ab79968549 100644 --- a/app/component/map/map.scss +++ b/app/component/map/map.scss @@ -266,15 +266,15 @@ div.leaflet-marker-icon.map-cluster-number-marker { cursor: grab; } -div.leaflet-marker-icon.map-vertical-transportation-use-marker { - > svg > circle.vertical-transportation-use-marker { +div.leaflet-marker-icon.map-indoor-route-step-marker { + > svg > circle.indoor-route-step-marker { fill: #fff; stroke: #666; transition: all 0.3s ease; transform-origin: center; } - > svg > circle.vertical-transportation-use-marker:hover { + > svg > circle.indoor-route-step-marker:hover { transform: scale(1.3); fill: #666; stroke: #fff; @@ -652,7 +652,7 @@ div.leaflet-marker-icon.vehicle-icon { background: $white; border: solid 1px #ddd; - &.vertical-transportation-use-popup-container { + &.indoor-route-step-popup-container { line-height: 0; margin: 0; padding: 10px 0 10px; @@ -661,7 +661,7 @@ div.leaflet-marker-icon.vehicle-icon { border-radius: 0; flex-direction: column; - .vertical-transportation-use-popup-icons { + .indoor-route-step-popup-icons { flex-direction: row; font-size: 20px; @@ -670,7 +670,7 @@ div.leaflet-marker-icon.vehicle-icon { align-items: center; justify-content: center; - > svg.icon.vertical-transportation-use-popup-icon { + > svg.icon.indoor-route-step-popup-icon { width: 2em; } @@ -682,8 +682,8 @@ div.leaflet-marker-icon.vehicle-icon { } } - .vertical-transportation-use-popup-line-container { - .vertical-transportation-use-popup-line { + .indoor-route-step-popup-line-container { + .indoor-route-step-popup-line { background-size: auto 100%; background-position-y: 0; background-position-x: 0; @@ -695,16 +695,16 @@ div.leaflet-marker-icon.vehicle-icon { min-width: 50px; } - .vertical-transportation-use-popup-line-circle-container { + .indoor-route-step-popup-line-circle-container { position: absolute; min-width: 0; - .vertical-transportation-use-popup-line-circle { + .indoor-route-step-popup-line-circle { border: none; min-width: 0; margin: 11px; - > svg > circle.vertical-transportation-use-marker { + > svg > circle.indoor-route-step-marker { fill: #fff; stroke: #666; diff --git a/docs/ZIndex.md b/docs/ZIndex.md index b1363b262c..a5dc28c560 100644 --- a/docs/ZIndex.md +++ b/docs/ZIndex.md @@ -28,5 +28,6 @@ Selector | Component | Z-Index | Comment `.itinerary-summary-row { .itinerary-legs { .line` | Summary result row leg lines | 1 | `.itinerary-summary-row { .itinerary-legs { .line { :after` | Hides the Summary result row leg lines behind the mode icon. | -1 | `.mobile.top-bar | Mobile top bar | 1000 | -`.TODO-map-cluster-marker` | Cluster objects for indoor routing | 13000 | TODO -`.TODO-map-subway-entrance-info-icon-metro` | Entrance markers for indoor routing | 13100 | TODO +`.map-cluster-number-marker` | Cluster group marker for indoor route steps | 13000 | +`.map-indoor-route-step-marker` | Indoor route step markers | 13050 | +`.map-subway-entrance-info-icon-metro` | Entrance markers for indoor route | 13100 | From 247a19017ee9b41dc7df8ea4c241b01fe2880f92 Mon Sep 17 00:00:00 2001 From: Ville Pihlava Date: Fri, 12 Dec 2025 14:44:40 +0200 Subject: [PATCH 6/6] Remove relativeDirection from indoor route related queries. --- app/component/itinerary/IndoorRouteStep.js | 11 +++---- app/component/itinerary/WalkLeg.js | 3 +- .../itinerary/navigator/NaviCardExtension.js | 3 +- .../indoorroute/NaviIndoorRouteContainer.js | 7 +++-- .../indoorroute/NaviIndoorRouteStepInfo.js | 15 +++------ .../queries/ItineraryDetailsFragment.js | 1 - .../itinerary/queries/PlanConnection.js | 1 - app/component/map/IndoorRouteStepMarker.js | 7 +++-- app/component/map/ItineraryLine.js | 15 +++++---- app/component/map/WalkQuery.js | 1 - app/constants.js | 29 +++-------------- app/util/indoorUtils.js | 31 ++++++++++--------- app/util/shapes.js | 7 +---- test/unit/WalkLeg.test.js | 4 --- 14 files changed, 52 insertions(+), 83 deletions(-) diff --git a/app/component/itinerary/IndoorRouteStep.js b/app/component/itinerary/IndoorRouteStep.js index bff6bbfea4..52052e08a9 100644 --- a/app/component/itinerary/IndoorRouteStep.js +++ b/app/component/itinerary/IndoorRouteStep.js @@ -10,14 +10,14 @@ import { } from '../../util/indoorUtils'; import { IndoorRouteLegType, - RelativeDirection, + IndoorRouteStepType, VerticalDirection, } from '../../constants'; import ItineraryMapAction from './ItineraryMapAction'; function IndoorRouteStep({ focusAction, - relativeDirection, + type, verticalDirection, toLevelName, isLastPlace, @@ -41,7 +41,7 @@ function IndoorRouteStep({ }, []); const indoorTranslationId = getIndoorRouteTranslationId( - relativeDirection, + type, verticalDirection, toLevelName, ); @@ -91,7 +91,7 @@ function IndoorRouteStep({ isKeyboardSelectionEvent(e) && focusAction(e)} >
@@ -53,8 +49,7 @@ function NaviIndoorRouteStepInfo({ NaviIndoorRouteStepInfo.propTypes = { focusAction: PropTypes.func.isRequired, - relativeDirection: PropTypes.oneOf(Object.values(RelativeDirection)) - .isRequired, + type: PropTypes.oneOf(Object.values(IndoorRouteStepType)).isRequired, verticalDirection: PropTypes.oneOf(Object.values(VerticalDirection)), toLevelName: PropTypes.string, }; diff --git a/app/component/itinerary/queries/ItineraryDetailsFragment.js b/app/component/itinerary/queries/ItineraryDetailsFragment.js index 4a06f740cd..484f524958 100644 --- a/app/component/itinerary/queries/ItineraryDetailsFragment.js +++ b/app/component/itinerary/queries/ItineraryDetailsFragment.js @@ -71,7 +71,6 @@ export const ItineraryDetailsFragment = graphql` } } } - relativeDirection lat lon } diff --git a/app/component/itinerary/queries/PlanConnection.js b/app/component/itinerary/queries/PlanConnection.js index e790020e09..c88346f643 100644 --- a/app/component/itinerary/queries/PlanConnection.js +++ b/app/component/itinerary/queries/PlanConnection.js @@ -159,7 +159,6 @@ export const planConnection = graphql` } } } - relativeDirection lat lon } diff --git a/app/component/map/IndoorRouteStepMarker.js b/app/component/map/IndoorRouteStepMarker.js index 143b90540d..344d293a6d 100644 --- a/app/component/map/IndoorRouteStepMarker.js +++ b/app/component/map/IndoorRouteStepMarker.js @@ -12,7 +12,7 @@ import GenericMarker from './GenericMarker'; import Card from '../Card'; import PopupHeader from './PopupHeader'; import Icon from '../Icon'; -import { RelativeDirection, VerticalDirection } from '../../constants'; +import { IndoorRouteStepType, VerticalDirection } from '../../constants'; import { getVerticalTransportationUseIconId } from '../../util/indoorUtils'; export default function IndoorRouteStepMarker( @@ -75,7 +75,8 @@ export default function IndoorRouteStepMarker( , ); - } else if ( - properties.type === ClusterMarkerType.VerticalTransportationUse - ) { + } else if (isVerticalTransportationUse(properties.type)) { objs.push( - isVerticalTransportationUse(step?.relativeDirection), + // eslint-disable-next-line no-underscore-dangle + isVerticalTransportationUse(step?.feature?.__typename), ); } export function getIndoorRouteTranslationId( - relativeDirection, + type, verticalDirection, toLevelName, ) { - if (relativeDirection === RelativeDirection.Elevator && toLevelName) { + if (type === IndoorRouteStepType.ElevatorUse && toLevelName) { return 'indoor-step-message-elevator-to-floor'; } - return `indoor-step-message-${relativeDirection.toLowerCase()}${ + return `indoor-step-message-${type?.toLowerCase().replace('use', '')}${ verticalDirection && verticalDirection !== VerticalDirection.Unknown && - relativeDirection !== RelativeDirection.Elevator + type !== IndoorRouteStepType.ElevatorUse ? `-${verticalDirection.toLowerCase()}` : '' }`; diff --git a/app/util/shapes.js b/app/util/shapes.js index 1da3ed14c3..50d6494c92 100644 --- a/app/util/shapes.js +++ b/app/util/shapes.js @@ -1,9 +1,5 @@ import PropTypes from 'prop-types'; -import { - VerticalDirection, - PlannerMessageType, - RelativeDirection, -} from '../constants'; +import { VerticalDirection, PlannerMessageType } from '../constants'; export const agencyShape = PropTypes.shape({ name: PropTypes.string, @@ -279,7 +275,6 @@ export const legShape = PropTypes.shape({ escalatorUseShape, stairsUseShape, ]), - relativeDirection: PropTypes.oneOf(Object.values(RelativeDirection)), lat: PropTypes.number, lon: PropTypes.number, }), diff --git a/test/unit/WalkLeg.test.js b/test/unit/WalkLeg.test.js index 91ec43a585..2e262003a1 100644 --- a/test/unit/WalkLeg.test.js +++ b/test/unit/WalkLeg.test.js @@ -32,7 +32,6 @@ describe('', () => { { streetName: 'entrance', area: false, - relativeDirection: 'CONTINUE', absoluteDirection: null, feature: { __typename: 'Entrance', @@ -79,7 +78,6 @@ describe('', () => { { streetName: 'entrance', area: false, - relativeDirection: 'CONTINUE', absoluteDirection: null, feature: { __typename: 'Entrance', @@ -154,7 +152,6 @@ describe('', () => { { streetName: 'entrance', area: false, - relativeDirection: 'CONTINUE', absoluteDirection: null, feature: { __typename: 'Entrance', @@ -207,7 +204,6 @@ describe('', () => { { streetName: 'entrance', area: false, - relativeDirection: 'CONTINUE', absoluteDirection: null, feature: { __typename: 'Entrance',