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..52052e08a9 --- /dev/null +++ b/app/component/itinerary/IndoorRouteStep.js @@ -0,0 +1,135 @@ +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, + IndoorRouteStepType, + VerticalDirection, +} from '../../constants'; +import ItineraryMapAction from './ItineraryMapAction'; + +function IndoorRouteStep({ + focusAction, + type, + 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( + type, + verticalDirection, + toLevelName, + ); + + return ( +
+
+
+ + + +
+
+
+
+
+ +
+ +
+ +
+
+
+ ); +} + +IndoorRouteStep.propTypes = { + focusAction: PropTypes.func.isRequired, + type: PropTypes.oneOf(Object.values(IndoorRouteStepType)).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 087f1a2bb4..90daf9f70a 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 { IndoorRouteLegType, ViaLocationType } from '../../constants'; class ItineraryCircleLineWithIcon extends React.Component { static propTypes = { index: PropTypes.number.isRequired, modeClassName: PropTypes.string.isRequired, + indoorRouteLegType: PropTypes.oneOf(Object.values(IndoorRouteLegType)), + 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, + onlyOneStep: PropTypes.bool, isStop: PropTypes.bool, }; static defaultProps = { + indoorRouteLegType: IndoorRouteLegType.NoStepsInside, + showIntermediateSteps: false, viaType: null, color: null, bikePark: false, @@ -29,11 +34,13 @@ class ItineraryCircleLineWithIcon extends React.Component { icon: undefined, style: {}, isNotFirstLeg: undefined, + onlyOneStep: false, isStop: false, }; state = { - imageUrl: 'none', + defaultImageUrl: 'none', + insideImageUrl: 'none', }; isFirstChild = () => { @@ -43,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})`, + }); }); } @@ -107,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 ( ); + + const getIntermediateRows = () => + showIntermediateSteps ? ( +
+ {indoorRouteSteps.map((step, i) => ( + + ))} +
+ ) : null; + + return ( + <> + {getMainRow()} + {getIntermediateRows()} + + ); } WalkLeg.propTypes = { @@ -321,6 +383,7 @@ WalkLeg.propTypes = { previousLeg: legShape, nextLeg: legShape, focusToLeg: PropTypes.func.isRequired, + focusToPoint: PropTypes.func.isRequired, useOriginAddress: PropTypes.bool, }; diff --git a/app/component/itinerary/itinerary.scss b/app/component/itinerary/itinerary.scss index bda354570e..4dfe5cb48e 100644 --- a/app/component/itinerary/itinerary.scss +++ b/app/component/itinerary/itinerary.scss @@ -973,6 +973,10 @@ $itinerary-tab-switch-height: 48px; top: 7px; } + &.only-one-step { + top: 10px; + } + position: absolute; z-index: 9; @@ -1063,6 +1067,13 @@ $itinerary-tab-switch-height: 48px; &.replacement-bus { @include setModeCircles($replacement-bus-color); } + + &.indoor-step { + > svg > circle.indoor-route-step-marker { + fill: #fff; + stroke: #666; + } + } } } @@ -1301,6 +1312,56 @@ $itinerary-tab-switch-height: 48px; } } } + + &.indoor-route { + .route-number { + top: 46%; + } + + .itinerary-icon-container { + top: 7px; + } + + .leg-before-line { + border-radius: 10px; + + &.walk { + height: 39%; + background-position-y: 1px; + } + + &.bottom { + top: 59%; + height: 48%; + background-position-y: 0; + } + } + } + + &.indoor-route.only-one-step { + .route-number { + top: 57%; + } + + .itinerary-icon-container { + top: 7px; + } + + .leg-before-line { + border-radius: 10px; + + &.walk { + height: 47%; + background-position-y: 3px; + } + + &.bottom { + top: 74%; + height: 48%; + background-position-y: 0; + } + } + } } &.taxi-external { @@ -1518,6 +1579,7 @@ $itinerary-tab-switch-height: 48px; } } + &.indoor-step, &.walk, &.bicycle_walk, &.car_boarding, @@ -1529,6 +1591,10 @@ $itinerary-tab-switch-height: 48px; border: none; width: 6px; left: 8px; + + &.only-one-step { + top: 20px; + } } &.car { @@ -1809,6 +1875,33 @@ $itinerary-tab-switch-height: 48px; flex-grow: 0; } } + + .itinerary-leg-row-intermediate-indoor-step { + color: #666; + font-size: 0.9375rem; + padding-bottom: 10px; + display: flex; + + &.only-one-step { + padding-top: 10px; + border-top: 1px solid #ddd; + } + + .itinerary-intermediate-indoor-route-icon { + color: #333; + width: 24px; + height: 100%; + vertical-align: middle; + display: flex; + margin-right: 8px; + } + + .itinerary-intermediate-indoor-route-step-info { + align-items: center; + display: flex; + flex-grow: 1; + } + } } .itinerary-icon { @@ -1830,6 +1923,7 @@ $itinerary-tab-switch-height: 48px; } } + .intermediate-step-info-container, .intermediate-stop-info-container, .alternative-legs-info-container { @media print { @@ -2399,6 +2493,15 @@ $itinerary-tab-switch-height: 48px; } } + .itinerary-leg-indoor-route-button-container { + border-top: 1px solid #ddd; + min-height: 40px; + height: auto; + font-size: 0.9375rem; + + @extend .itinerary-leg-text-gray; + } + .subway-entrance-info-container { display: flex; align-items: center; @@ -2435,6 +2538,7 @@ $itinerary-tab-switch-height: 48px; min-height: 40px; } + .intermediate-steps-clickable, .intermediate-stops-clickable, .alternative-legs-clickable { height: 60px; @@ -2449,6 +2553,19 @@ $itinerary-tab-switch-height: 48px; } } + .intermediate-steps-message { + color: $primary-color; + font-weight: $font-weight-medium; + line-height: 1.2; + border-bottom: none; + padding-right: 4px; + flex: 1; + + @media print { + display: none; + } + } + .intermediate-stops-amount { color: $primary-color; font-weight: $font-weight-medium; @@ -2816,6 +2933,7 @@ div.itinerary-container-content { } } +.intermediate-steps-message-no-steps, .intermediate-stop-no-stops { color: $gray; font-weight: $font-weight-book; diff --git a/app/component/itinerary/navigator/NaviCard.js b/app/component/itinerary/navigator/NaviCard.js index d51ed84539..eb1a18702b 100644 --- a/app/component/itinerary/navigator/NaviCard.js +++ b/app/component/itinerary/navigator/NaviCard.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React, { useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { isAnyLegPropertyIdentical, isRental } from '../../../util/legUtils'; import { getRouteMode, transitIconName } from '../../../util/modeUtils'; import { configShape, legShape } from '../../../util/shapes'; @@ -21,6 +21,8 @@ const iconMap = { export default function NaviCard( { + focusToPoint, + previousLeg, leg, nextLeg, legType, @@ -33,6 +35,7 @@ export default function NaviCard( { config }, ) { const [cardExpanded, setCardExpanded] = useState(false); + const [showIndoorRoute, setShowIndoorRoute] = useState(false); const contentRef = useRef(); const { isEqual: legChanged } = usePrevious(leg, (prev, current) => isAnyLegPropertyIdentical(prev, current, ['legId', 'mode']), @@ -40,10 +43,12 @@ export default function NaviCard( const handleClick = () => { setCardExpanded(prev => !prev); + setShowIndoorRoute(false); }; if (legChanged) { setCardExpanded(false); + setShowIndoorRoute(false); } if ( @@ -87,6 +92,18 @@ export default function NaviCard( ? `${contentRef.current?.scrollHeight}px` : '0px'; + useEffect(() => { + const element = contentRef.current; + if (!element) { + return; + } + + // Resize card when card size changes. + if (cardExpanded || showIndoorRoute) { + element.style.maxHeight = `${element.scrollHeight}px`; + } + }, [cardExpanded, showIndoorRoute]); + 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 b9a9a684d3..9d888bc806 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, @@ -219,6 +220,8 @@ function NaviCardContainer( aria-hidden={legChanging ? 'true' : 'false'} > { const { stop, name, rentalVehicle, vehicleParking, vehicleRentalStation } = @@ -79,36 +96,71 @@ const NaviCardExtension = ( ); } - const stopInformation = (expandIcon = false) => { + const stopInformation = ( + expandIcon = false, + showIndoorRouteButton = false, + ) => { + let indoorRouteSteps = []; + if (showIndoorRouteButton) { + indoorRouteSteps = getIndoorStepsWithVerticalTransportationUse( + previousLeg, + leg, + nextLeg, + ); + } 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 +188,31 @@ const NaviCardExtension = ( ); } if (legType === LEGTYPE.MOVE && nextLeg?.transitLeg) { + if (showIndoorRoute) { + const indoorRouteSteps = getIndoorStepsWithVerticalTransportationUse( + previousLeg, + leg, + nextLeg, + ); + return ( +
+
+
+ +
+
+
+ +
+
+ ); + } const { headsign, route, start } = nextLeg; const hs = headsign || nextLeg.trip?.tripHeadsign; const remainingDuration = ; @@ -146,8 +223,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..54d89d4420 --- /dev/null +++ b/app/component/itinerary/navigator/indoorroute/NaviIndoorRouteContainer.js @@ -0,0 +1,89 @@ +import PropTypes from 'prop-types'; +import React, { useEffect, useState } from 'react'; +import { configShape } from '../../../../util/shapes'; +import { IndoorRouteStepType, 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({ + type: PropTypes.oneOf(Object.values(IndoorRouteStepType)), + 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..2219ba01ec --- /dev/null +++ b/app/component/itinerary/navigator/indoorroute/NaviIndoorRouteStepInfo.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 { + getIndoorRouteTranslationId, + getVerticalTransportationUseIconId, +} from '../../../../util/indoorUtils'; +import { IndoorRouteStepType, VerticalDirection } from '../../../../constants'; +import ItineraryMapAction from '../../ItineraryMapAction'; +import { isKeyboardSelectionEvent } from '../../../../util/browser'; + +function NaviIndoorRouteStepInfo({ + focusAction, + type, + verticalDirection, + toLevelName, +}) { + const indoorTranslationId = getIndoorRouteTranslationId( + type, + verticalDirection, + toLevelName, + ); + + return ( +
isKeyboardSelectionEvent(e) && focusAction(e)} + > + +
+ +
+ +
+ ); +} + +NaviIndoorRouteStepInfo.propTypes = { + focusAction: PropTypes.func.isRequired, + type: PropTypes.oneOf(Object.values(IndoorRouteStepType)).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 8f79623522..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,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-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; + } - .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-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 5a09c8406c..d45b775886 100644 --- a/app/component/itinerary/queries/ItineraryDetailsFragment.js +++ b/app/component/itinerary/queries/ItineraryDetailsFragment.js @@ -39,6 +39,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/IndoorRouteStepMarker.js b/app/component/map/IndoorRouteStepMarker.js new file mode 100644 index 0000000000..344d293a6d --- /dev/null +++ b/app/component/map/IndoorRouteStepMarker.js @@ -0,0 +1,151 @@ +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 { IndoorRouteStepType, 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-indoor-route-step-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({ + type: PropTypes.oneOf(Object.values(IndoorRouteStepType)), + 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..06e3e3e205 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 IndoorRouteStepMarker from './IndoorRouteStepMarker'; +import { createFeatureObjects } from '../../util/clusterUtils'; +import { + IndoorRouteStepType, + IndoorRouteLegType, + WheelchairBoarding, +} from '../../constants'; +import { + getEntranceObject, + getEntranceWheelchairAccessibility, + getIndoorRouteLegType, + getIndoorStepsWithVerticalTransportationUse, + 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,298 @@ class ItineraryLine extends React.Component { return false; } + handleEntrance( + leg, + nextLeg, + mode, + i, + geometry, + objs, + clusterObjs, + entranceObject, + indoorRouteLegType, + ) { + 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: IndoorRouteStepType.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 indoorRouteLegType = getIndoorRouteLegType(previousLeg, leg, nextLeg); + if (indoorRouteLegType !== IndoorRouteLegType.NoStepsInside) { + this.handleEntrance( + leg, + nextLeg, + mode, + i, + geometry, + objs, + clusterObjs, + entranceObject, + indoorRouteLegType, + ); + } 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, nextLeg, clusterObjs) { + if (!this.props.passive) { + const indoorRouteSteps = getIndoorStepsWithVerticalTransportationUse( + previousLeg, + leg, + nextLeg, + ); + + if (indoorRouteSteps) { + indoorRouteSteps.forEach((indoorRouteStep, i) => { + if (indoorRouteStep.lat && indoorRouteStep.lon) { + clusterObjs.push({ + lat: indoorRouteStep.lat, + lon: indoorRouteStep.lon, + properties: { + iconCount: 1, + // eslint-disable-next-line no-underscore-dangle + type: indoorRouteStep.feature?.__typename, + 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, 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 === IndoorRouteStepType.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 +418,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, 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..ab79968549 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-route-step-marker { + > svg > circle.indoor-route-step-marker { + fill: #fff; + stroke: #666; + transition: all 0.3s ease; + transform-origin: center; + } + + > svg > circle.indoor-route-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-route-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-route-step-popup-icons { + flex-direction: row; + font-size: 20px; + + .icon-container { + display: inline-flex; + align-items: center; + justify-content: center; + + > svg.icon.indoor-route-step-popup-icon { + width: 2em; + } + + > svg.icon.arrow-popup-icon { + color: black; + height: 0.5em; + width: 0.5em; + } + } + } + + .indoor-route-step-popup-line-container { + .indoor-route-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-route-step-popup-line-circle-container { + position: absolute; + min-width: 0; + + .indoor-route-step-popup-line-circle { + border: none; + min-width: 0; + margin: 11px; + + > svg > circle.indoor-route-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/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 f91c32a11d..9ff4319c10 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 IndoorRouteStepType = Object.freeze({ + Entrance: 'Entrance', + ElevatorUse: 'ElevatorUse', + EscalatorUse: 'EscalatorUse', + StairsUse: 'StairsUse', +}); + +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', +}); + /** * OpenTripPlanner (v2) via point types. */ diff --git a/app/translations.js b/app/translations.js index c5901d710b..278105af5f 100644 --- a/app/translations.js +++ b/app/translations.js @@ -1133,6 +1133,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:', @@ -1170,11 +1178,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', @@ -2485,6 +2495,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:', @@ -2521,10 +2539,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', @@ -5460,6 +5480,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:', @@ -5498,10 +5526,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..86d5cb3c80 100644 --- a/app/util/indoorUtils.js +++ b/app/util/indoorUtils.js @@ -1,3 +1,10 @@ +import { + IndoorRouteLegType, + IndoorRouteStepType, + VerticalDirection, +} from '../constants'; +import { addAnalyticsEvent } from './analyticsUtils'; + export function subwayTransferUsesSameStation(prevLeg, nextLeg) { return ( prevLeg?.mode === 'SUBWAY' && @@ -6,3 +13,158 @@ 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, + type, + filled, +) { + if ( + verticalDirection === undefined || + verticalDirection === VerticalDirection.Unknown || + type === IndoorRouteStepType.ElevatorUse + ) { + return iconMappings[ + `${type?.toLowerCase().replace('use', '')}${filled ? '-filled' : ''}` + ]; + } + return iconMappings[ + `${type + ?.toLowerCase() + .replace('use', '')}-${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 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 []; + } + const indoorRouteLegType = getIndoorRouteLegType(previousLeg, leg, nextLeg); + if (indoorRouteLegType === IndoorRouteLegType.StepsBeforeEntranceInside) { + return leg.steps.slice(0, entranceIndex + 1); + } + if (indoorRouteLegType === IndoorRouteLegType.StepsAfterEntranceInside) { + return leg.steps.slice(entranceIndex); + } + return []; +} + +export function isVerticalTransportationUse(type) { + return ( + type === IndoorRouteStepType.ElevatorUse || + type === IndoorRouteStepType.EscalatorUse || + type === IndoorRouteStepType.StairsUse + ); +} + +export function getIndoorStepsWithVerticalTransportationUse( + previousLeg, + leg, + nextLeg, +) { + return getIndoorSteps(previousLeg, leg, nextLeg).filter(step => + // eslint-disable-next-line no-underscore-dangle + isVerticalTransportationUse(step?.feature?.__typename), + ); +} + +export function getIndoorRouteTranslationId( + type, + verticalDirection, + toLevelName, +) { + if (type === IndoorRouteStepType.ElevatorUse && toLevelName) { + return 'indoor-step-message-elevator-to-floor'; + } + return `indoor-step-message-${type?.toLowerCase().replace('use', '')}${ + verticalDirection && + verticalDirection !== VerticalDirection.Unknown && + type !== IndoorRouteStepType.ElevatorUse + ? `-${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 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 d7ed4db3ba..f89ec7f85d 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." @@ -2172,6 +2198,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. @@ -2978,7 +3013,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 @@ -3697,6 +3732,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. @@ -3712,6 +3748,7 @@ enum RelativeDirection { RIGHT SLIGHTLY_LEFT SLIGHTLY_RIGHT + STAIRS UTURN_LEFT UTURN_RIGHT } @@ -3927,6 +3964,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 { """ @@ -4932,4 +4976,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 d7ed4db3ba..f89ec7f85d 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." @@ -2172,6 +2198,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. @@ -2978,7 +3013,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 @@ -3697,6 +3732,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. @@ -3712,6 +3748,7 @@ enum RelativeDirection { RIGHT SLIGHTLY_LEFT SLIGHTLY_RIGHT + STAIRS UTURN_LEFT UTURN_RIGHT } @@ -3927,6 +3964,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 { """ @@ -4932,4 +4976,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..a5dc28c560 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-route-step-marker` | Indoor route step markers | 13050 | +`.map-subway-entrance-info-icon-metro` | Entrance markers for indoor route | 13100 | diff --git a/static/assets/svg-sprite.default.svg b/static/assets/svg-sprite.default.svg index 0e338d98ef..48e760bf2e 100644 --- a/static/assets/svg-sprite.default.svg +++ b/static/assets/svg-sprite.default.svg @@ -370,6 +370,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/assets/svg-sprite.hsl.svg b/static/assets/svg-sprite.hsl.svg index 35ba43f811..04ecbbe738 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..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', + }, + }, + ], }, };