diff --git a/app/component/itinerary/IndoorInfo.js b/app/component/itinerary/IndoorInfo.js new file mode 100644 index 0000000000..4009220175 --- /dev/null +++ b/app/component/itinerary/IndoorInfo.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 IndoorInfo( + { 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 && ( + + )} +
+
+ ); +} + +IndoorInfo.contextTypes = { + config: configShape.isRequired, +}; + +IndoorInfo.propTypes = { + intermediateStepCount: PropTypes.number.isRequired, + toggleFunction: PropTypes.func.isRequired, + showIntermediateSteps: PropTypes.bool, +}; + +IndoorInfo.defaultProps = { + showIntermediateSteps: false, +}; diff --git a/app/component/itinerary/IndoorStep.js b/app/component/itinerary/IndoorStep.js new file mode 100644 index 0000000000..86c1f73e10 --- /dev/null +++ b/app/component/itinerary/IndoorStep.js @@ -0,0 +1,119 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import cx from 'classnames'; +import { configShape } from '../../util/shapes'; +import Icon from '../Icon'; +import { + getIndoorTranslationId, + getVerticalTransportationUseIconId, +} from '../../util/indoorUtils'; +import { + IndoorLegType, + IndoorStepType, + VerticalDirection, +} from '../../constants'; +import ItineraryMapAction from './ItineraryMapAction'; + +function IndoorStep({ + focusAction, + type, + verticalDirection, + toLevelName, + isLastPlace, + onlyOneStep, + indoorLegType, +}) { + const indoorTranslationId = getIndoorTranslationId( + type, + verticalDirection, + toLevelName, + ); + + return ( +
+
+
+ + + +
+
+
+
+
+ +
+ +
+ +
+
+
+ ); +} + +IndoorStep.propTypes = { + focusAction: PropTypes.func.isRequired, + type: PropTypes.oneOf(Object.values(IndoorStepType)).isRequired, + verticalDirection: PropTypes.oneOf(Object.values(VerticalDirection)), + toLevelName: PropTypes.string, + isLastPlace: PropTypes.bool, + onlyOneStep: PropTypes.bool, + indoorLegType: PropTypes.oneOf(Object.values(IndoorLegType)), +}; + +IndoorStep.defaultProps = { + verticalDirection: undefined, + toLevelName: undefined, + isLastPlace: false, + onlyOneStep: false, + indoorLegType: IndoorLegType.NoStepsInside, +}; + +IndoorStep.contextTypes = { + config: configShape.isRequired, +}; + +export default IndoorStep; diff --git a/app/component/itinerary/ItineraryCircleLine.js b/app/component/itinerary/ItineraryCircleLine.js index 0dece6081f..27f4960054 100644 --- a/app/component/itinerary/ItineraryCircleLine.js +++ b/app/component/itinerary/ItineraryCircleLine.js @@ -25,22 +25,6 @@ class ItineraryCircleLine extends React.Component { isStop: PropTypes.bool, }; - constructor(props) { - super(props); - - this.state = { - imageUrl: 'none', - }; - } - - componentDidMount() { - import( - /* webpackChunkName: "dotted-line" */ `../../configurations/images/default/dotted-line.svg` - ).then(imageUrl => { - this.setState({ imageUrl: `url(${imageUrl.default})` }); - }); - } - isFirstChild = () => { return this.props.index === 0 && !this.props.viaType; }; @@ -115,11 +99,12 @@ class ItineraryCircleLine extends React.Component { const topMarker = this.getMarker(true); const bottomMarker = this.getMarker(false); const legBeforeLineStyle = { color: this.props.color }; + let backgroundClass = ''; if ( this.props.modeClassName === 'car-park-walk' || this.props.modeClassName === 'walk' ) { - legBeforeLineStyle.backgroundImage = this.state.imageUrl; + backgroundClass = 'default-dotted-line'; } return ( @@ -137,6 +122,7 @@ class ItineraryCircleLine extends React.Component { 'leg-before-line', this.props.modeClassName, this.props.appendClass, + backgroundClass, )} /> {this.props.renderBottomMarker && bottomMarker} diff --git a/app/component/itinerary/ItineraryCircleLineLong.js b/app/component/itinerary/ItineraryCircleLineLong.js index 5a0087d162..22657a5177 100644 --- a/app/component/itinerary/ItineraryCircleLineLong.js +++ b/app/component/itinerary/ItineraryCircleLineLong.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React, { useState, useEffect } from 'react'; +import React from 'react'; import cx from 'classnames'; import Icon from '../Icon'; import RouteNumber from '../RouteNumber'; @@ -7,16 +7,6 @@ import { legShape } from '../../util/shapes'; import { ViaLocationType } from '../../constants'; const ItineraryCircleLineLong = props => { - const [imgUrl, setImgUrl] = useState(''); - - useEffect(() => { - import( - /* webpackChunkName: "dotted-line" */ `../../configurations/images/default/dotted-line.svg` - ).then(imageUrl => { - setImgUrl(`url(${imageUrl.default})`); - }); - }, []); - const isFirstChild = () => { return props.index === 0; }; @@ -66,7 +56,6 @@ const ItineraryCircleLineLong = props => { const carBoardingRouteNumber = ( ); - legBeforeLineStyle.backgroundImage = imgUrl; return (
{ 'leg-before-line top', positionRelativeToTransit, firstModeClassName, + 'default-dotted-line', )} />
{ 'leg-before-line middle', positionRelativeToTransit, props.modeClassName, + 'default-dotted-line', )} />
{ 'leg-before-line second-middle', positionRelativeToTransit, props.modeClassName, + 'default-dotted-line', )} /> )} @@ -151,6 +143,7 @@ const ItineraryCircleLineLong = props => { positionRelativeToTransit === 'between-transit' ? firstModeClassName : secondModeClassName, + 'default-dotted-line', )} /> {props.renderBottomMarker && bottomMarker} diff --git a/app/component/itinerary/ItineraryCircleLineWithIcon.js b/app/component/itinerary/ItineraryCircleLineWithIcon.js index 087f1a2bb4..6b8a9a3fe0 100644 --- a/app/component/itinerary/ItineraryCircleLineWithIcon.js +++ b/app/component/itinerary/ItineraryCircleLineWithIcon.js @@ -3,12 +3,14 @@ import React from 'react'; import cx from 'classnames'; import Icon from '../Icon'; import RouteNumber from '../RouteNumber'; -import { ViaLocationType } from '../../constants'; +import { IndoorLegType, ViaLocationType } from '../../constants'; class ItineraryCircleLineWithIcon extends React.Component { static propTypes = { index: PropTypes.number.isRequired, modeClassName: PropTypes.string.isRequired, + indoorLegType: PropTypes.oneOf(Object.values(IndoorLegType)), + showIntermediateSteps: PropTypes.bool, viaType: PropTypes.string, bikePark: PropTypes.bool, carPark: PropTypes.bool, @@ -17,10 +19,13 @@ class ItineraryCircleLineWithIcon extends React.Component { icon: PropTypes.string, style: PropTypes.shape({}), isNotFirstLeg: PropTypes.bool, + indoorStepsLength: PropTypes.number, isStop: PropTypes.bool, }; static defaultProps = { + indoorLegType: IndoorLegType.NoStepsInside, + showIntermediateSteps: false, viaType: null, color: null, bikePark: false, @@ -29,27 +34,16 @@ class ItineraryCircleLineWithIcon extends React.Component { icon: undefined, style: {}, isNotFirstLeg: undefined, + indoorStepsLength: 0, isStop: false, }; - state = { - imageUrl: 'none', - }; - isFirstChild = () => { return ( !this.props.isNotFirstLeg && this.props.index === 0 && !this.props.viaType ); }; - componentDidMount() { - import( - /* webpackChunkName: "dotted-line" */ `../../configurations/images/default/dotted-line.svg` - ).then(imageUrl => { - this.setState({ imageUrl: `url(${imageUrl.default})` }); - }); - } - getMarker = top => { if (this.props.viaType === ViaLocationType.Visit && !this.props.isStop) { return ( @@ -107,16 +101,38 @@ class ItineraryCircleLineWithIcon extends React.Component { const topMarker = this.getMarker(true); const bottomMarker = this.getMarker(false); const legBeforeLineStyle = { color: this.props.color, ...this.props.style }; + let topBackgroundClass = ''; + let bottomBackgroundClass = ''; if ( this.props.modeClassName === 'walk' || this.props.modeClassName === 'bicycle_walk' ) { - legBeforeLineStyle.backgroundImage = this.state.imageUrl; + switch (this.props.indoorLegType) { + case IndoorLegType.StepsAfterEntranceInside: + topBackgroundClass = 'default-dotted-line'; + bottomBackgroundClass = 'indoor-dotted-line'; + break; + case IndoorLegType.StepsBeforeEntranceInside: + if (this.props.showIntermediateSteps) { + topBackgroundClass = 'indoor-dotted-line'; + bottomBackgroundClass = 'indoor-dotted-line'; + } else { + topBackgroundClass = 'indoor-dotted-line'; + bottomBackgroundClass = 'default-dotted-line'; + } + break; + default: + topBackgroundClass = 'default-dotted-line'; + bottomBackgroundClass = 'default-dotted-line'; + } } return (
@@ -118,7 +137,7 @@ export default function NaviCard(
@@ -139,6 +162,8 @@ export default function NaviCard( } NaviCard.propTypes = { + focusToPoint: PropTypes.func.isRequired, + previousLeg: legShape, leg: legShape, nextLeg: legShape, legType: PropTypes.string.isRequired, @@ -152,6 +177,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 da692b2947..89ac619c5b 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, @@ -225,6 +226,8 @@ function NaviCardContainer( aria-hidden={legChanging ? 'true' : 'false'} > { const { stop, name, rentalVehicle, vehicleParking, vehicleRentalStation } = @@ -79,36 +92,48 @@ const NaviCardExtension = (
); } - const stopInformation = (expandIcon = false) => { + const stopInformation = (expandIcon = false, showIndoorButton = false) => { return (
- {expandIcon && } - -
- {destination.name} -
- {!stop && address &&
{address}
} - {code && } - {platformCode && ( - + {expandIcon && } + +
+ {destination.name} +
+ {!stop && address &&
{address}
} + {code && } + {platformCode && ( + + )} + - )} - +
+ {showIndoorButton && ( + + )}
); }; @@ -136,6 +161,17 @@ const NaviCardExtension = ( ); } if (legType === LEGTYPE.MOVE && nextLeg?.transitLeg) { + if (currentCard === NaviCardType.Indoor) { + return ( + + ); + } const { headsign, route, start } = nextLeg; const hs = headsign || nextLeg.trip?.tripHeadsign; const remainingDuration = ; @@ -146,8 +182,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, + currentCard: PropTypes.oneOf(Object.values(NaviCardType)), + setCurrentCard: PropTypes.func.isRequired, }; NaviCardExtension.defaultProps = { legType: '', + previousLeg: undefined, leg: undefined, nextLeg: undefined, platformUpdated: false, + currentCard: NaviCardType.Default, }; 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 && ( - - )} -
+ {showDestinationInfo && ( +
+ +   + {legDestination(intl, leg, null, nextLeg)} +   + + {displayDistance(tailLength, config, intl.formatNumber)}  + + {nextLeg?.transitLeg && ( + + )} +
+ )} {nextLeg?.transitLeg && ( { + e.stopPropagation(); + setCurrentCard( + currentCard === NaviCardType.Indoor + ? NaviCardType.Default + : NaviCardType.Indoor, + ); + }} + onKeyPress={e => { + if (isKeyboardSelectionEvent(e)) { + e.stopPropagation(); + setCurrentCard( + currentCard === NaviCardType.Indoor + ? NaviCardType.Default + : NaviCardType.Indoor, + ); + } + }} + > + {currentCard === NaviCardType.Indoor ? ( + <> +
+ +
+
+ +
+ + ) : ( + <> +
+ +
+
+ +
+ + )} +
+ ); +} + +NaviIndoorButton.propTypes = { + currentCard: PropTypes.oneOf(Object.values(NaviCardType)), + setCurrentCard: PropTypes.func.isRequired, +}; +NaviIndoorButton.defaultProps = { + currentCard: NaviCardType.Default, +}; diff --git a/app/component/itinerary/navigator/indoor/NaviIndoorButtonContainer.js b/app/component/itinerary/navigator/indoor/NaviIndoorButtonContainer.js new file mode 100644 index 0000000000..e9b1b563b3 --- /dev/null +++ b/app/component/itinerary/navigator/indoor/NaviIndoorButtonContainer.js @@ -0,0 +1,69 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Icon from '../../../Icon'; +import { legShape } from '../../../../util/shapes'; +import { + getIndoorStepsWithVerticalTransportation, + getStepFocusAction, +} from '../../../../util/indoorUtils'; +import NaviIndoorStepInfo from './NaviIndoorStepInfo'; +import NaviIndoorButton from './NaviIndoorButton'; +import { NaviCardType } from '../../../../constants'; + +export default function NaviIndoorButtonContainer({ + currentCard, + setCurrentCard, + previousLeg, + leg, + nextLeg, + focusToPoint, +}) { + const indoorSteps = getIndoorStepsWithVerticalTransportation( + previousLeg, + leg, + nextLeg, + ); + if (indoorSteps.length === 1) { + return ( +
+ + +
+ ); + } + if (indoorSteps.length > 1) { + return ( + + ); + } + return null; +} + +NaviIndoorButtonContainer.propTypes = { + currentCard: PropTypes.oneOf(Object.values(NaviCardType)), + setCurrentCard: PropTypes.func.isRequired, + previousLeg: legShape, + leg: legShape, + nextLeg: legShape, + focusToPoint: PropTypes.func.isRequired, +}; + +NaviIndoorButtonContainer.defaultProps = { + currentCard: NaviCardType.Default, + previousLeg: undefined, + leg: undefined, + nextLeg: undefined, +}; diff --git a/app/component/itinerary/navigator/indoor/NaviIndoorCard.js b/app/component/itinerary/navigator/indoor/NaviIndoorCard.js new file mode 100644 index 0000000000..6afbd11c88 --- /dev/null +++ b/app/component/itinerary/navigator/indoor/NaviIndoorCard.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import cx from 'classnames'; +import { configShape, legShape } from '../../../../util/shapes'; +import { getIndoorStepsWithVerticalTransportation } from '../../../../util/indoorUtils'; +import NaviIndoorButton from './NaviIndoorButton'; +import NaviIndoorContainer from './NaviIndoorContainer'; +import { NaviCardType } from '../../../../constants'; + +function NaviIndoorCard({ + setCurrentCard, + previousLeg, + leg, + nextLeg, + focusToPoint, +}) { + const indoorSteps = getIndoorStepsWithVerticalTransportation( + previousLeg, + leg, + nextLeg, + ); + return ( +
+
+
+ +
+
+
+ +
+
+ ); +} + +NaviIndoorCard.propTypes = { + setCurrentCard: PropTypes.func.isRequired, + focusToPoint: PropTypes.func.isRequired, + previousLeg: legShape, + leg: legShape, + nextLeg: legShape, +}; + +NaviIndoorCard.defaultProps = { + previousLeg: undefined, + leg: undefined, + nextLeg: undefined, +}; + +NaviIndoorCard.contextTypes = { + config: configShape.isRequired, +}; + +export default NaviIndoorCard; diff --git a/app/component/itinerary/navigator/indoor/NaviIndoorContainer.js b/app/component/itinerary/navigator/indoor/NaviIndoorContainer.js new file mode 100644 index 0000000000..88e87cab99 --- /dev/null +++ b/app/component/itinerary/navigator/indoor/NaviIndoorContainer.js @@ -0,0 +1,75 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { configShape } from '../../../../util/shapes'; +import { IndoorStepType, VerticalDirection } from '../../../../constants'; +import NaviIndoorStepInfo from './NaviIndoorStepInfo'; +import { getStepFocusAction } from '../../../../util/indoorUtils'; + +function NaviIndoorContainer({ focusToPoint, indoorSteps }) { + return ( +
+
+
+ {indoorSteps.map((step, i) => ( + +
+ + + +
+
+ ))} +
+
+
+
+ {indoorSteps.map((step, i) => ( + + ))} +
+
+ ); +} + +NaviIndoorContainer.propTypes = { + focusToPoint: PropTypes.func.isRequired, + indoorSteps: PropTypes.arrayOf( + PropTypes.shape({ + type: PropTypes.oneOf(Object.values(IndoorStepType)), + feature: PropTypes.shape({ + verticalDirection: PropTypes.oneOf(Object.values(VerticalDirection)), + to: PropTypes.shape({ + name: PropTypes.string, + }), + }), + }), + ), +}; + +NaviIndoorContainer.defaultProps = { + indoorSteps: [], +}; + +NaviIndoorContainer.contextTypes = { + config: configShape.isRequired, +}; + +export default NaviIndoorContainer; diff --git a/app/component/itinerary/navigator/indoor/NaviIndoorStepInfo.js b/app/component/itinerary/navigator/indoor/NaviIndoorStepInfo.js new file mode 100644 index 0000000000..0e9c8bcbe4 --- /dev/null +++ b/app/component/itinerary/navigator/indoor/NaviIndoorStepInfo.js @@ -0,0 +1,66 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { configShape } from '../../../../util/shapes'; +import Icon from '../../../Icon'; +import { + getIndoorTranslationId, + getVerticalTransportationUseIconId, +} from '../../../../util/indoorUtils'; +import { IndoorStepType, VerticalDirection } from '../../../../constants'; +import ItineraryMapAction from '../../ItineraryMapAction'; +import { isKeyboardSelectionEvent } from '../../../../util/browser'; + +function NaviIndoorStepInfo({ + focusAction, + type, + verticalDirection, + toLevelName, +}) { + const indoorTranslationId = getIndoorTranslationId( + type, + verticalDirection, + toLevelName, + ); + + return ( +
isKeyboardSelectionEvent(e) && focusAction(e)} + > + +
+ +
+ +
+ ); +} + +NaviIndoorStepInfo.propTypes = { + focusAction: PropTypes.func.isRequired, + type: PropTypes.oneOf(Object.values(IndoorStepType)).isRequired, + verticalDirection: PropTypes.oneOf(Object.values(VerticalDirection)), + toLevelName: PropTypes.string, +}; + +NaviIndoorStepInfo.defaultProps = { + verticalDirection: undefined, + toLevelName: undefined, +}; + +NaviIndoorStepInfo.contextTypes = { + config: configShape.isRequired, +}; + +export default NaviIndoorStepInfo; diff --git a/app/component/itinerary/navigator/navigator.scss b/app/component/itinerary/navigator/navigator.scss index 8f79623522..a85d761b95 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,148 +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-button, + .extension-indoor-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; + } - .destination { - text-align: left; + &.replacement-bus { + color: $replacement-bus-color; + } + + &.tram-stop { + color: $tram-color; + } - .details { + &.subway-stop { + color: $metro-color; + } + + &.rail-stop { + color: $rail-color; + } + + &.ferry-stop { + color: $ferry-color; + } + + &.ferry-external-stop { + color: $external-feed-color; + } + + &.funicular-stop { + color: $funicular-color; + } + + &.speedtram-stop { + color: $speedtram-color; + } + } + + .destination { + text-align: left; + + .details { + display: flex; + flex-direction: row; + align-items: center; + + .address { + color: #888; + } + + .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; + } + } + + .zone-icon-container { + .circle { + width: 16px; + height: 16px; + font-size: 0.9rem; + padding: 0 2px 0 2px; + } + } + } + } + } + + .indoor-container-clickable { display: flex; - flex-direction: row; - align-items: center; + margin-top: 10px; + margin-bottom: 10px; - .address { - color: #888; + .indoor-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-arrow-icon { + margin-right: 11px; + + span { + display: flex; + align-items: center; + + svg { + color: $primary-color; + + &.open { + transform: rotate(180deg); + } + } + } + } + } + + .navi-indoor-one-step-info-container { + display: flex; + align-items: center; + margin-top: 4px; + + .navi-indoor-step-info { + display: flex; + flex: 1; + font-size: 0.9375rem; + + .navi-indoor-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-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-container { + max-height: 130px; + overflow-y: auto; + overflow-x: hidden; + + .navi-indoor-step-container { + display: flex; + + .navi-indoor-step-info-container { + flex: 1; + + .navi-indoor-step-info { + display: flex; + font-size: 0.9375rem; + + .navi-indoor-step-icon { + width: 24px; + height: 100%; + vertical-align: middle; + display: flex; + margin-right: 8px; + } + + .navi-indoor-step-text { + align-items: center; + display: flex; + flex: 1; + } + } + } + + .navi-indoor-step-line-container { + min-width: 28px; + position: relative; + + .navi-indoor-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-step-line-circle-container { + position: absolute; + + .navi-indoor-step-line-circle { + position: relative; + z-index: 9; + min-height: 37px; + + > svg > circle.indoor-step-marker { + fill: #fff; + stroke: #666; + } + } + } } } } diff --git a/app/component/itinerary/queries/ItineraryDetailsFragment.js b/app/component/itinerary/queries/ItineraryDetailsFragment.js index 506441a524..d9d644dba7 100644 --- a/app/component/itinerary/queries/ItineraryDetailsFragment.js +++ b/app/component/itinerary/queries/ItineraryDetailsFragment.js @@ -45,6 +45,39 @@ 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 + } + } } lat lon diff --git a/app/component/itinerary/queries/PlanConnection.js b/app/component/itinerary/queries/PlanConnection.js index f4d4538e28..4c5987d525 100644 --- a/app/component/itinerary/queries/PlanConnection.js +++ b/app/component/itinerary/queries/PlanConnection.js @@ -127,6 +127,39 @@ 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 + } + } } 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/IndoorStepMarker.js b/app/component/map/IndoorStepMarker.js new file mode 100644 index 0000000000..5fdcc0e39f --- /dev/null +++ b/app/component/map/IndoorStepMarker.js @@ -0,0 +1,137 @@ +import React 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 { IndoorStepType, VerticalDirection } from '../../constants'; +import { getVerticalTransportationUseIconId } from '../../util/indoorUtils'; + +export default function IndoorStepMarker( + { position, index, indoorSteps }, + { intl }, +) { + const objs = []; + + const getIcon = () => { + const radius = 10; + const iconSvg = ` + + + `; + + return L.divIcon({ + html: iconSvg, + iconSize: [radius * 2, radius * 2], + className: 'map-indoor-step-marker disable-icon-border', + }); + }; + + objs.push( + + + +
+
+ {indoorSteps.map((obj, i, filteredObjs) => ( + + + {filteredObjs.length !== i + 1 ? ( + + ) : null} + + ))} +
+
+
+ {indoorSteps.map((obj, i) => ( + +
+ + + +
+
+ ))} +
+
+
+
+ + , + ); + + return
{objs}
; +} + +IndoorStepMarker.contextTypes = { + config: configShape.isRequired, + intl: intlShape.isRequired, +}; + +IndoorStepMarker.propTypes = { + position: locationShape.isRequired, + index: PropTypes.number.isRequired, + indoorSteps: PropTypes.arrayOf( + PropTypes.shape({ + type: PropTypes.oneOf(Object.values(IndoorStepType)), + feature: PropTypes.shape({ + verticalDirection: PropTypes.oneOf(Object.values(VerticalDirection)), + }), + }), + ), +}; + +IndoorStepMarker.defaultProps = { + indoorSteps: [], +}; diff --git a/app/component/map/ItineraryLine.js b/app/component/map/ItineraryLine.js index 1fa1954801..b79bc5a46a 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,21 @@ 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 IndoorStepMarker from './IndoorStepMarker'; +import { createFeatureObjects } from '../../util/clusterUtils'; +import { + IndoorStepType, + IndoorLegType, + WheelchairBoarding, +} from '../../constants'; +import { + getEntranceObject, + getEntranceWheelchairAccessibility, + getIndoorLegType, + getIndoorStepsWithVerticalTransportation, + isVerticalTransportationUse, +} from '../../util/indoorUtils'; class ItineraryLine extends React.Component { static contextTypes = { @@ -28,6 +45,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 +63,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 +77,301 @@ class ItineraryLine extends React.Component { return false; } + handleEntrance( + leg, + nextLeg, + mode, + i, + geometry, + objs, + clusterObjs, + entranceObject, + indoorLegType, + ) { + 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: IndoorStepType.Entrance, + code: entranceObject.feature.publicCode?.toLowerCase(), + }, + }); + } + + objs.push( + , + ); + objs.push( + , + ); + } + + handleLine(previousLeg, leg, nextLeg, mode, i, geometry, objs, clusterObjs) { + const entranceObject = getEntranceObject(previousLeg, leg); + const indoorLegType = getIndoorLegType(previousLeg, leg, nextLeg); + if (indoorLegType !== IndoorLegType.NoStepsInside) { + this.handleEntrance( + leg, + nextLeg, + mode, + i, + geometry, + objs, + clusterObjs, + entranceObject, + indoorLegType, + ); + } 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( + , + ); + } + } + + handleIndoorStepMarkers(previousLeg, leg, nextLeg, clusterObjs) { + if (!this.props.passive) { + const indoorSteps = getIndoorStepsWithVerticalTransportation( + previousLeg, + leg, + nextLeg, + ); + + if (indoorSteps) { + indoorSteps.forEach((indoorStep, i) => { + if (indoorStep.lat && indoorStep.lon) { + clusterObjs.push({ + lat: indoorStep.lat, + lon: indoorStep.lon, + properties: { + iconCount: 1, + // eslint-disable-next-line no-underscore-dangle + type: indoorStep.feature?.__typename, + verticalDirection: indoorStep.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, nextLeg, objs, clusterObjs) { + if (!this.props.passive) { + const index = new Supercluster({ + radius: 60, // in pixels + maxZoom: 15, + minPoints: 2, + extent: 512, // tile size (512) + 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 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; + 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 === IndoorStepType.Entrance) { + objs.push( + , + ); + } else if (isVerticalTransportationUse(properties.type)) { + objs.push( + , + ); + } + } + }); + } + } + render() { const objs = []; const transitLegs = []; this.props.legs.forEach((leg, i) => { + const clusterObjs = []; + if (!leg || leg.mode === LegMode.Wait) { return; } @@ -103,142 +421,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.handleIndoorStepMarkers(previousLeg, leg, nextLeg, clusterObjs); + this.handleClusterObjects(previousLeg, leg, nextLeg, 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/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 4fda6c94ea..56d685a9b1 100644 --- a/app/component/map/WalkQuery.js +++ b/app/component/map/WalkQuery.js @@ -46,6 +46,39 @@ 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 + } + } } lat lon @@ -86,6 +119,7 @@ const walkQuery = graphql` gtfsId code platformCode + vehicleMode } } to { @@ -107,6 +141,7 @@ const walkQuery = graphql` gtfsId code platformCode + vehicleMode } } intermediatePlaces { diff --git a/app/component/map/map.scss b/app/component/map/map.scss index 9be6630fe2..1ede36cc84 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-indoor-step-marker { + > svg > circle.indoor-step-marker { + fill: #fff; + stroke: #666; + transition: all 0.3s ease; + transform-origin: center; + } + + > svg > circle.indoor-step-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; + &.indoor-step-popup-container { + line-height: 0; + margin: 0; + padding: 10px 0 10px; + border: none; + border-top: 1px solid #ddd; + border-radius: 0; + flex-direction: column; + + .indoor-step-popup-icons { + flex-direction: row; + font-size: 20px; + + .icon-container { + display: inline-flex; + align-items: center; + justify-content: center; + + > svg.icon.indoor-step-popup-icon { + width: 2em; + } + + > svg.icon.arrow-popup-icon { + color: black; + height: 0.5em; + width: 0.5em; + } + } + } + + .indoor-step-popup-line-container { + .indoor-step-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; + } + + .indoor-step-popup-line-circle-container { + position: absolute; + min-width: 0; + + .indoor-step-popup-line-circle { + border: none; + min-width: 0; + margin: 11px; + + > svg > circle.indoor-step-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/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/constants.js b/app/constants.js index 2f75e29e89..7acc1f825b 100644 --- a/app/constants.js +++ b/app/constants.js @@ -129,6 +129,32 @@ export const PlannerMessageType = Object.freeze({ SystemError: 'SYSTEM_ERROR', }); +export const VerticalDirection = Object.freeze({ + Up: 'UP', + Down: 'DOWN', + Unknown: 'UNKNOWN', +}); + +export const IndoorStepType = Object.freeze({ + Entrance: 'Entrance', + ElevatorUse: 'ElevatorUse', + EscalatorUse: 'EscalatorUse', + StairsUse: 'StairsUse', +}); + +export const IndoorLegType = 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', +}); + /** * OpenTripPlanner (v2) via point types. */ @@ -142,6 +168,11 @@ export const LocationTypes = Object.freeze({ STATION: 'STATION', }); +export const NaviCardType = Object.freeze({ + Default: 'DEFAULT', + Indoor: 'INDOOR', +}); + export const TrafficNowTransportModes = Object.freeze([ TransportMode.Bus, TransportMode.Ferry, diff --git a/app/translations.js b/app/translations.js index 2c2da933b0..8128338e83 100644 --- a/app/translations.js +++ b/app/translations.js @@ -1136,6 +1136,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:', @@ -1173,11 +1181,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', @@ -2502,6 +2512,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:', @@ -2538,10 +2556,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', @@ -5490,6 +5510,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:', @@ -5528,10 +5556,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..b63ddff47e --- /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..8b4bd1ae54 100644 --- a/app/util/indoorUtils.js +++ b/app/util/indoorUtils.js @@ -1,8 +1,175 @@ -export function subwayTransferUsesSameStation(prevLeg, nextLeg) { +import { IndoorLegType, IndoorStepType, VerticalDirection } from '../constants'; +import { addAnalyticsEvent } from './analyticsUtils'; + +export function subwayTransferUsesSameStation(previousLeg, nextLeg) { return ( - prevLeg?.mode === 'SUBWAY' && + previousLeg?.mode === 'SUBWAY' && nextLeg?.mode === 'SUBWAY' && - prevLeg.to.stop.parentStation?.gtfsId === + previousLeg.to.stop.parentStation?.gtfsId === 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, + type, + filled, +) { + if ( + verticalDirection === undefined || + verticalDirection === VerticalDirection.Unknown || + type === IndoorStepType.ElevatorUse + ) { + return iconMappings[ + `${type?.toLowerCase().replace('use', '')}${filled ? '-filled' : ''}` + ]; + } + return iconMappings[ + `${type + ?.toLowerCase() + .replace('use', '')}-${verticalDirection.toLowerCase()}${ + filled ? '-filled' : '' + }` + ]; +} + +export function getIndoorTranslationId(type, verticalDirection, toLevelName) { + if (type === IndoorStepType.ElevatorUse && toLevelName) { + return 'indoor-step-message-elevator-to-floor'; + } + return `indoor-step-message-${type?.toLowerCase().replace('use', '')}${ + verticalDirection && + verticalDirection !== VerticalDirection.Unknown && + type !== IndoorStepType.ElevatorUse + ? `-${verticalDirection.toLowerCase()}` + : '' + }`; +} + +/** + * @return an entrance object or undefined if one can not be found + */ +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', + ); + // Select the entrance to the outside if there are multiple entrances. + const entranceObject = + previousLeg?.mode === 'SUBWAY' + ? entranceObjects[entranceObjects.length - 1] + : entranceObjects[0]; + + return entranceObject; +} + +/** + * @return the index of an entrance in the steps of a leg or undefined if one can not be found + */ +export function getEntranceStepIndex(previousLeg, leg) { + return getEntranceObject(previousLeg, leg)?.index; +} + +export function getIndoorLegType(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 IndoorLegType.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 IndoorLegType.StepsAfterEntranceInside; + } + return IndoorLegType.NoStepsInside; +} + +export function getIndoorSteps(previousLeg, leg, nextLeg) { + const entranceIndex = getEntranceStepIndex(previousLeg, leg); + if (!entranceIndex) { + return []; + } + const indoorLegType = getIndoorLegType(previousLeg, leg, nextLeg); + if (indoorLegType === IndoorLegType.StepsBeforeEntranceInside) { + return leg.steps.slice(0, entranceIndex + 1); + } + if (indoorLegType === IndoorLegType.StepsAfterEntranceInside) { + return leg.steps.slice(entranceIndex); + } + return []; +} + +export function isVerticalTransportationUse(type) { + return ( + type === IndoorStepType.ElevatorUse || + type === IndoorStepType.EscalatorUse || + type === IndoorStepType.StairsUse + ); +} + +/** + * @return a filtered array of only indoor steps with vertical transportation or an empty array + */ +export function getIndoorStepsWithVerticalTransportation( + previousLeg, + leg, + nextLeg, +) { + return getIndoorSteps(previousLeg, leg, nextLeg).filter(step => + // eslint-disable-next-line no-underscore-dangle + isVerticalTransportationUse(step.feature?.__typename), + ); +} + +/** + * @return the name (letter identifier) of an entrance in the steps of a leg or undefined if one can not be found + */ +export function getEntranceName(previousLeg, leg) { + return getEntranceObject(previousLeg, leg)?.feature.publicCode; +} + +/** + * @return wheelchair accessibility information for an entrance in the steps of a leg or undefined if it can not be found + */ +export function getEntranceWheelchairAccessibility(previousLeg, leg) { + return getEntranceObject(previousLeg, leg)?.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 eb783d5204..987ca132eb 100644 --- a/app/util/shapes.js +++ b/app/util/shapes.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import { PlannerMessageType } from '../constants'; +import { VerticalDirection, PlannerMessageType } from '../constants'; export const agencyShape = PropTypes.shape({ name: PropTypes.string, @@ -208,10 +208,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, @@ -226,7 +266,12 @@ export const legShape = PropTypes.shape({ fare: fareShape, steps: PropTypes.arrayOf( PropTypes.shape({ - entrance: entranceShape, + feature: PropTypes.oneOfType([ + entranceShape, + elevatorUseShape, + escalatorUseShape, + stairsUseShape, + ]), lat: PropTypes.number, lon: PropTypes.number, }), @@ -241,7 +286,6 @@ export const legShape = PropTypes.shape({ name: PropTypes.string, stop: stopShape, vehicleRentalStation: vehicleRentalStationShape, - bikePark: parkShape, carPark: parkShape, }), diff --git a/build/schema.graphql b/build/schema.graphql index 7e130624af..cde9393132 100644 --- a/build/schema.graphql +++ b/build/schema.graphql @@ -97,7 +97,7 @@ union CallStopLocation = Location | LocationGroup | Stop union RentalPlace = RentalVehicle | VehicleRentalStation "A feature for a step" -union StepFeature = Entrance +union StepFeature = ElevatorUse | Entrance | EscalatorUse | StairsUse union StopPosition = PositionAtStop | PositionBetweenStops @@ -525,6 +525,15 @@ type DependentFareProduct implements FareProduct { 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 @@ -542,6 +551,15 @@ type Entrance { 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 { """ @@ -902,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." @@ -2181,6 +2207,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. @@ -2987,7 +3022,7 @@ type step { elevationProfile: [elevationProfileComponent] "When exiting a highway or traffic circle, the exit name/number." exit: String - "Information about an feature associated with a step e.g. an station entrance or exit" + "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 @@ -3706,6 +3741,7 @@ enum RelativeDirection { 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. @@ -3721,6 +3757,7 @@ enum RelativeDirection { RIGHT SLIGHTLY_LEFT SLIGHTLY_RIGHT + STAIRS UTURN_LEFT UTURN_RIGHT } @@ -3936,6 +3973,13 @@ enum VertexType { TRANSIT } +"The vertical direction e.g. for a set of stairs." +enum VerticalDirection { + DOWN + UNKNOWN + UP +} + "Categorization for via locations." enum ViaLocationType { """ @@ -4941,4 +4985,4 @@ input WheelchairPreferencesInput { that the itineraries are wheelchair accessible as there can be data issues. """ enabled: Boolean -} \ No newline at end of file +} 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 7e130624af..cde9393132 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 @@ -97,7 +97,7 @@ union CallStopLocation = Location | LocationGroup | Stop union RentalPlace = RentalVehicle | VehicleRentalStation "A feature for a step" -union StepFeature = Entrance +union StepFeature = ElevatorUse | Entrance | EscalatorUse | StairsUse union StopPosition = PositionAtStop | PositionBetweenStops @@ -525,6 +525,15 @@ type DependentFareProduct implements FareProduct { 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 @@ -542,6 +551,15 @@ type Entrance { 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 { """ @@ -902,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." @@ -2181,6 +2207,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. @@ -2987,7 +3022,7 @@ type step { elevationProfile: [elevationProfileComponent] "When exiting a highway or traffic circle, the exit name/number." exit: String - "Information about an feature associated with a step e.g. an station entrance or exit" + "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 @@ -3706,6 +3741,7 @@ enum RelativeDirection { 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. @@ -3721,6 +3757,7 @@ enum RelativeDirection { RIGHT SLIGHTLY_LEFT SLIGHTLY_RIGHT + STAIRS UTURN_LEFT UTURN_RIGHT } @@ -3936,6 +3973,13 @@ enum VertexType { TRANSIT } +"The vertical direction e.g. for a set of stairs." +enum VerticalDirection { + DOWN + UNKNOWN + UP +} + "Categorization for via locations." enum ViaLocationType { """ @@ -4941,4 +4985,4 @@ input WheelchairPreferencesInput { that the itineraries are wheelchair accessible as there can be data issues. """ enabled: Boolean -} \ No newline at end of file +} diff --git a/docs/ZIndex.md b/docs/ZIndex.md index 5742e8cf12..8290024893 100644 --- a/docs/ZIndex.md +++ b/docs/ZIndex.md @@ -28,3 +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 | +`.map-cluster-number-marker` | Cluster group marker for indoor route steps | 13000 | +`.map-indoor-step-marker` | Indoor route step markers | 13050 | +`.map-subway-entrance-info-icon-metro` | Entrance markers for indoor route | 13100 | diff --git a/sass/themes/default/_theme.scss b/sass/themes/default/_theme.scss index 1a36972abf..ea385aeae0 100644 --- a/sass/themes/default/_theme.scss +++ b/sass/themes/default/_theme.scss @@ -152,6 +152,11 @@ $navigation-icon-size: 40px; $slide-up-down-animation: 0.8s cubic-bezier(0.47, 0, 0.23, 1.38) forwards; $slide-up-down-fade: 0.8s ease-out forwards; +/* Itinerary circle lines */ +$default-dotted-line: url('../default/default-dotted-line.svg'); +$indoor-dotted-line: url('../default/indoor-dotted-line.svg'); +$indoor-dotted-line-horizontal: url('../default/indoor-dotted-line-horizontal.svg'); + .theme-default { display: block; } diff --git a/app/configurations/images/default/dotted-line.svg b/sass/themes/default/default-dotted-line.svg similarity index 100% rename from app/configurations/images/default/dotted-line.svg rename to sass/themes/default/default-dotted-line.svg diff --git a/sass/themes/default/indoor-dotted-line-horizontal.svg b/sass/themes/default/indoor-dotted-line-horizontal.svg new file mode 100644 index 0000000000..d4d2c228ed --- /dev/null +++ b/sass/themes/default/indoor-dotted-line-horizontal.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/sass/themes/default/indoor-dotted-line.svg b/sass/themes/default/indoor-dotted-line.svg new file mode 100644 index 0000000000..9cf130fc10 --- /dev/null +++ b/sass/themes/default/indoor-dotted-line.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/static/assets/svg-sprite.default.svg b/static/assets/svg-sprite.default.svg index 9b0dca2164..12da924b90 100644 --- a/static/assets/svg-sprite.default.svg +++ b/static/assets/svg-sprite.default.svg @@ -370,6 +370,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/assets/svg-sprite.hsl.svg b/static/assets/svg-sprite.hsl.svg index f1c4c411d8..878dccb6e2 100644 --- a/static/assets/svg-sprite.hsl.svg +++ b/static/assets/svg-sprite.hsl.svg @@ -216,6 +216,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/unit/WalkLeg.test.js b/test/unit/WalkLeg.test.js index fc634ab982..2e262003a1 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,19 @@ describe('', () => { rentedBike: false, start: { scheduledTime: new Date(1529589709000).toISOString() }, end: { scheduledTime: new Date(1529589701000).toISOString() }, + steps: [ + { + streetName: 'entrance', + area: false, + absoluteDirection: null, + feature: { + __typename: 'Entrance', + publicCode: 'A', + entranceId: 'osm:123', + wheelchairAccessible: 'POSSIBLE', + }, + }, + ], }, }; @@ -43,6 +57,7 @@ describe('', () => { const props = { focusAction: () => {}, focusToLeg: () => {}, + focusToPoint: () => {}, index: 2, leg: { distance: 284.787, @@ -59,6 +74,19 @@ describe('', () => { rentedBike: false, start: { scheduledTime: new Date(1529589709000).toISOString() }, end: { scheduledTime: new Date(1529589701000).toISOString() }, + steps: [ + { + streetName: 'entrance', + area: false, + absoluteDirection: null, + feature: { + __typename: 'Entrance', + publicCode: 'A', + entranceId: 'osm:123', + wheelchairAccessible: 'POSSIBLE', + }, + }, + ], }, previousLeg: { distance: 3297.017000000001, @@ -94,6 +122,7 @@ describe('', () => { const props = { focusAction: () => {}, focusToLeg: () => {}, + focusToPoint: () => {}, index: 2, leg: { distance: 284.787, @@ -119,6 +148,19 @@ describe('', () => { rentedBike: false, start: { scheduledTime: new Date(startTime).toISOString() }, end: { scheduledTime: new Date(1529589701000).toISOString() }, + steps: [ + { + streetName: 'entrance', + area: false, + absoluteDirection: null, + feature: { + __typename: 'Entrance', + publicCode: 'A', + entranceId: 'osm:123', + wheelchairAccessible: 'POSSIBLE', + }, + }, + ], }, }; @@ -135,6 +177,7 @@ describe('', () => { const props = { focusAction: () => {}, focusToLeg: () => {}, + focusToPoint: () => {}, index: 1, leg: { distance: 1.23, @@ -157,6 +200,19 @@ describe('', () => { rentedBike: false, start: { scheduledTime: new Date(1668600030868).toISOString() }, end: { scheduledTime: new Date(1668600108525).toISOString() }, + steps: [ + { + streetName: 'entrance', + area: false, + absoluteDirection: null, + feature: { + __typename: 'Entrance', + publicCode: 'A', + entranceId: 'osm:123', + wheelchairAccessible: 'POSSIBLE', + }, + }, + ], }, };