diff --git a/app/component/itinerary/NaviCard.js b/app/component/itinerary/NaviCard.js new file mode 100644 index 0000000000..e7e89f48e3 --- /dev/null +++ b/app/component/itinerary/NaviCard.js @@ -0,0 +1,60 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { legShape } from '../../util/shapes'; +import Icon from '../Icon'; +import { isRental } from '../../util/legUtils'; +import NaviInstructions from './NaviInstructions'; +import NaviCardExtension from './NaviCardExtension'; + +const iconMap = { + BICYCLE: 'icon-icon_cyclist', + CAR: 'icon-icon_car-withoutBox', + SCOOTER: 'icon-icon_scooter_rider', + WALK: 'icon-icon_walk', + WAIT: 'icon-icon_navigation_wait', +}; + +export default function NaviCard({ leg, nextLeg, legType, cardExpanded }) { + const iconName = legType === 'wait' ? iconMap.WAIT : iconMap[leg.mode]; + let instructions = `navileg-${leg.mode.toLowerCase()}`; + + if (isRental(leg, nextLeg)) { + if (leg.mode === 'WALK' && nextLeg?.mode === 'SCOOTER') { + instructions = `navileg-rent-scooter`; + } else { + instructions = `navileg-rent-cycle`; + } + } + return ( +
+
+ +
+ +
+ +
+
+
+ {cardExpanded && } +
+ ); +} + +NaviCard.propTypes = { + leg: legShape.isRequired, + nextLeg: legShape.isRequired, + legType: PropTypes.string.isRequired, + cardExpanded: PropTypes.bool, +}; +NaviCard.defaultProps = { + cardExpanded: false, +}; diff --git a/app/component/itinerary/NaviTop.js b/app/component/itinerary/NaviCardContainer.js similarity index 63% rename from app/component/itinerary/NaviTop.js rename to app/component/itinerary/NaviCardContainer.js index 09cf2a89d3..9d65cf630b 100644 --- a/app/component/itinerary/NaviTop.js +++ b/app/component/itinerary/NaviCardContainer.js @@ -4,8 +4,7 @@ import { FormattedMessage, intlShape } from 'react-intl'; import distance from '@digitransit-search-util/digitransit-search-util-distance'; import { legShape, configShape } from '../../util/shapes'; import { legTime, legTimeStr } from '../../util/legUtils'; -import NaviLeg from './NaviLeg'; -import Icon from '../Icon'; +import NaviCard from './NaviCard'; import NaviStack from './NaviStack'; import { getItineraryAlerts, @@ -22,12 +21,12 @@ function getFirstLastLegs(legs) { return { first, last }; } -function NaviTop( +function NaviCardContainer( { focusToLeg, time, realTimeLegs, position }, { intl, config }, ) { const [currentLeg, setCurrentLeg] = useState(null); - const [showMessages, setShowMessages] = useState(true); + const [cardExpanded, setCardExpanded] = useState(false); // All notifications including those user has dismissed. const [messages, setMessages] = useState(new Map()); // notifications that are shown to the user. @@ -37,17 +36,9 @@ function NaviTop( const destCountRef = useRef(0); const handleClick = () => { - setShowMessages(!showMessages); + setCardExpanded(!cardExpanded); }; - useEffect(() => { - const timer = setTimeout(() => { - setShowMessages(false); - }, 5000); - - return () => clearTimeout(timer); - }, []); - useEffect(() => { const newLeg = realTimeLegs.find(leg => { return legTime(leg.start) <= time && time <= legTime(leg.end); @@ -68,21 +59,17 @@ function NaviTop( const l = currentLeg || newLeg; if (l) { - const nextTransitLeg = realTimeLegs.find( - leg => legTime(leg.start) > legTime(l.start) && leg.transitLeg, + const nextLeg = realTimeLegs.find( + leg => legTime(leg.start) > legTime(l.start), ); - if (nextTransitLeg) { + if (nextLeg?.transitLeg) { // Messages for NaviStack. - const transitLegState = getTransitLegState( - nextTransitLeg, - intl, - messages, - ); + const transitLegState = getTransitLegState(nextLeg, intl, messages); if (transitLegState) { incomingMessages.set(transitLegState.id, transitLegState); } const additionalMsgs = getAdditionalMessages( - nextTransitLeg, + nextLeg, time, intl, config, @@ -94,42 +81,50 @@ function NaviTop( }); } } - - if (legChanged) { + if (newLeg) { focusToLeg?.(newLeg); - setCurrentLeg(newLeg); } - - if (incomingMessages.size || legChanged) { - // Handle messages when new messages arrives or leg is changed. - - // Current active messages. Filter away legChange messages when leg changes. - const currActiveMessages = legChanged - ? activeMessages.filter(m => m.expiresOn !== 'legChange') - : activeMessages; - - const newMessages = Array.from(incomingMessages.values()); - setActiveMessages([...currActiveMessages, ...newMessages]); - setMessages(new Map([...messages, ...incomingMessages])); - - setShowMessages(true); + if (legChanged) { + setCurrentLeg(newLeg); + setCardExpanded(false); } + } + if (incomingMessages.size || legChanged) { + // Handle messages when new messages arrives or leg is changed. + + // Current active messages. Filter away legChange messages when leg changes. + const previousValidMessages = legChanged + ? activeMessages.filter(m => m.expiresOn !== 'legChange') + : activeMessages; + + // handle messages that are updated. + const updatedMessages = previousValidMessages.map(msg => { + const incoming = incomingMessages.get(msg.id); + if (incoming) { + incomingMessages.delete(msg.id); + return incoming; + } + return msg; + }); + const newMessages = Array.from(incomingMessages.values()); + setActiveMessages([...updatedMessages, ...newMessages]); + setMessages(new Map([...messages, ...incomingMessages])); + } - if (!focusRef.current && focusToLeg) { - // handle initial focus when not tracking - if (newLeg) { - focusToLeg(newLeg); - destCountRef.current = 0; + if (!focusRef.current && focusToLeg) { + // handle initial focus when not tracking + if (newLeg) { + focusToLeg(newLeg); + destCountRef.current = 0; + } else { + const { first, last } = getFirstLastLegs(realTimeLegs); + if (time < legTime(first.start)) { + focusToLeg(first); } else { - const { first, last } = getFirstLastLegs(realTimeLegs); - if (time < legTime(first.start)) { - focusToLeg(first); - } else { - focusToLeg(last); - } + focusToLeg(last); } - focusRef.current = true; } + focusRef.current = true; } // User position and distance from currentleg endpoint. @@ -160,16 +155,20 @@ function NaviTop( const nextLeg = realTimeLegs.find(leg => { return legTime(leg.start) > legTime(currentLeg.start); }); + let legType; if (destCountRef.current >= TIME_AT_DESTINATION) { - // User at the destination. show wait message. - naviTopContent = ( - - ); + legType = 'wait'; } else { - naviTopContent = ( - - ); + legType = 'move'; } + naviTopContent = ( + + ); } else { naviTopContent = `Tracking ${currentLeg?.mode} leg`; } @@ -182,25 +181,19 @@ function NaviTop( setActiveMessages(activeMessages.filter((_, i) => i !== index)); }; - const showmessages = activeMessages.length > 0; return ( <> - - {showmessages && ( + {activeMessages.length > 0 && ( )} @@ -208,7 +201,7 @@ function NaviTop( ); } -NaviTop.propTypes = { +NaviCardContainer.propTypes = { focusToLeg: PropTypes.func, time: PropTypes.number.isRequired, realTimeLegs: PropTypes.arrayOf(legShape).isRequired, @@ -222,14 +215,14 @@ NaviTop.propTypes = { */ }; -NaviTop.defaultProps = { +NaviCardContainer.defaultProps = { focusToLeg: undefined, position: undefined, }; -NaviTop.contextTypes = { +NaviCardContainer.contextTypes = { intl: intlShape.isRequired, config: configShape.isRequired, }; -export default NaviTop; +export default NaviCardContainer; diff --git a/app/component/itinerary/NaviCardExtension.js b/app/component/itinerary/NaviCardExtension.js new file mode 100644 index 0000000000..f21c0466de --- /dev/null +++ b/app/component/itinerary/NaviCardExtension.js @@ -0,0 +1,68 @@ +import React from 'react'; +import Icon from '../Icon'; +import StopCode from '../StopCode'; +import PlatformNumber from '../PlatformNumber'; +import { getZoneLabel } from '../../util/legUtils'; +import ZoneIcon from '../ZoneIcon'; +import { legShape, configShape } from '../../util/shapes'; +import { getDestinationProperties } from './NaviUtils'; + +const NaviCardExtension = ({ leg }, { config }) => { + const { stop, name } = leg.to; + const { code, platformCode, zoneId, vehicleMode } = stop || {}; + const [place, address] = name?.split(/, (.+)/) || []; + + let destination = {}; + if (stop) { + destination = getDestinationProperties(leg, stop, config); + destination.name = stop.name; + } else { + destination.iconId = 'icon-icon_mapMarker-to'; + destination.className = 'place'; + destination.name = place; + } + return ( +
+
+
+ + +
+ {destination.name} +
+ {!stop && address &&
{address}
} + {code && } + {platformCode && ( + + )} + +
+
+
+
+ ); +}; + +NaviCardExtension.propTypes = { + leg: legShape.isRequired, +}; + +NaviCardExtension.contextTypes = { + config: configShape.isRequired, +}; + +export default NaviCardExtension; diff --git a/app/component/itinerary/NaviContainer.js b/app/component/itinerary/NaviContainer.js index 4eff2c7596..3357b26bd5 100644 --- a/app/component/itinerary/NaviContainer.js +++ b/app/component/itinerary/NaviContainer.js @@ -2,7 +2,7 @@ import React, { useEffect, useState, useRef } from 'react'; import PropTypes from 'prop-types'; import { graphql, fetchQuery } from 'react-relay'; import { itineraryShape, relayShape } from '../../util/shapes'; -import NaviTop from './NaviTop'; +import NaviCardContainer from './NaviCardContainer'; import NaviBottom from './NaviBottom'; import { legTime } from '../../util/legUtils'; import { checkPositioningPermission } from '../../action/PositionActions'; @@ -119,7 +119,7 @@ function NaviContainer( return ( <> - { - const timer = setTimeout(() => { - setFadeOut(true); - }, 10000); - return () => { - setFadeOut(false); - clearTimeout(timer); - }; - }, [leg]); - - const stopName = stop?.name || name; - let destination; - - if (rentalVehicle) { - destination = rentalVehicle.rentalNetwork.networkId; - } else if (vehicleParking) { - destination = vehicleParking.name; - } else if (vehicleRentalStation) { - destination = vehicleRentalStation.name; - } else if (stopName) { - destination = stopName; - } - - return ( -
-
- {destination && ( -
- {destination} - {stop?.platformCode && ( - <> -   •   - - - )} -
- )} - {distance && duration && ( -
- {durationToString(duration * 1000)} •   - {displayDistance(distance, config, intl.formatNumber)} -
- )} -
-
- ); -} - -NaviDestination.propTypes = { - leg: legShape.isRequired, -}; - -NaviDestination.contextTypes = { - intl: intlShape.isRequired, - config: configShape.isRequired, -}; - -export default NaviDestination; diff --git a/app/component/itinerary/NaviLegContent.js b/app/component/itinerary/NaviInstructions.js similarity index 61% rename from app/component/itinerary/NaviLegContent.js rename to app/component/itinerary/NaviInstructions.js index 61b080e439..a5becf30ce 100644 --- a/app/component/itinerary/NaviLegContent.js +++ b/app/component/itinerary/NaviInstructions.js @@ -1,15 +1,30 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { FormattedMessage, intlShape } from 'react-intl'; import PropTypes from 'prop-types'; -import { legShape } from '../../util/shapes'; +import cx from 'classnames'; +import { legShape, configShape } from '../../util/shapes'; import { legDestination } from '../../util/legUtils'; -import NaviDestination from './NaviDestination'; import RouteNumber from '../RouteNumber'; +import { displayDistance } from '../../util/geo-utils'; +import { durationToString } from '../../util/timeUtils'; -export default function NaviLegContent( +export default function NaviInstructions( { leg, nextLeg, instructions, legType }, - { intl }, + { intl, config }, ) { + const { distance, duration } = leg; + const [fadeOut, setFadeOut] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => { + setFadeOut(true); + }, 10000); + return () => { + setFadeOut(false); + clearTimeout(timer); + }; + }, [leg]); + if (legType === 'move') { return ( <> @@ -18,7 +33,12 @@ export default function NaviLegContent(   {legDestination(intl, leg, null, nextLeg)}
- + {distance && duration && ( +
+ {displayDistance(distance, config, intl.formatNumber)}   ( + {durationToString(duration * 1000)}) +
+ )} ); } @@ -56,16 +76,17 @@ export default function NaviLegContent( return null; } -NaviLegContent.propTypes = { +NaviInstructions.propTypes = { leg: legShape.isRequired, nextLeg: legShape.isRequired, instructions: PropTypes.string.isRequired, legType: PropTypes.string, }; -NaviLegContent.defaultProps = { +NaviInstructions.defaultProps = { legType: 'move', }; -NaviLegContent.contextTypes = { +NaviInstructions.contextTypes = { intl: intlShape.isRequired, + config: configShape.isRequired, }; diff --git a/app/component/itinerary/NaviLeg.js b/app/component/itinerary/NaviLeg.js deleted file mode 100644 index 485c02e69c..0000000000 --- a/app/component/itinerary/NaviLeg.js +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { legShape } from '../../util/shapes'; -import Icon from '../Icon'; -import { isRental } from '../../util/legUtils'; -import NaviLegContent from './NaviLegContent'; - -const iconMap = { - BICYCLE: 'icon-icon_cyclist', - CAR: 'icon-icon_car-withoutBox', - SCOOTER: 'icon-icon_scooter_rider', - WALK: 'icon-icon_walk', - WAIT: 'icon-icon_navigation_wait', -}; - -export default function NaviLeg({ leg, nextLeg, legType }) { - const iconName = legType === 'wait' ? iconMap.WAIT : iconMap[leg.mode]; - let instructions = `navileg-${leg.mode.toLowerCase()}`; - - if (isRental(leg, nextLeg)) { - if (leg.mode === 'WALK' && nextLeg?.mode === 'SCOOTER') { - instructions = `navileg-rent-scooter`; - } else { - instructions = `navileg-rent-cycle`; - } - } - return ( -
- -
-
- -
-
- ); -} - -NaviLeg.propTypes = { - leg: legShape.isRequired, - nextLeg: legShape.isRequired, - legType: PropTypes.string.isRequired, -}; diff --git a/app/component/itinerary/NaviStack.js b/app/component/itinerary/NaviStack.js index 71ac34862b..eb08da4298 100644 --- a/app/component/itinerary/NaviStack.js +++ b/app/component/itinerary/NaviStack.js @@ -3,10 +3,9 @@ import PropTypes from 'prop-types'; import cx from 'classnames'; import NaviMessage from './NaviMessage'; -// eslint-disable-next-line no-unused-vars -const NaviStack = ({ messages, handleRemove, show }) => { +const NaviStack = ({ messages, handleRemove, cardExpanded }) => { return ( -
+
{messages.map((notification, index) => ( { }; NaviStack.propTypes = { - // eslint-disable-next-line - messages: PropTypes.arrayOf( PropTypes.shape({ + messages: PropTypes.arrayOf( + PropTypes.shape({ id: PropTypes.string.isRequired, severity: PropTypes.string.isRequired, }), ).isRequired, - show: PropTypes.bool.isRequired, handleRemove: PropTypes.func.isRequired, + cardExpanded: PropTypes.bool, +}; + +NaviStack.defaultProps = { + cardExpanded: false, }; export default NaviStack; diff --git a/app/component/itinerary/NaviUtils.js b/app/component/itinerary/NaviUtils.js index 01b1d999dd..43ef196d8a 100644 --- a/app/component/itinerary/NaviUtils.js +++ b/app/component/itinerary/NaviUtils.js @@ -3,6 +3,7 @@ import { FormattedMessage } from 'react-intl'; import { legTime } from '../../util/legUtils'; import { timeStr } from '../../util/timeUtils'; import { getFaresFromLegs } from '../../util/fareUtils'; +import { ExtendedRouteTypes } from '../../constants'; const TRANSFER_SLACK = 60000; @@ -168,3 +169,84 @@ export const getItineraryAlerts = (realTimeLegs, intl, messages) => { return alerts; }; + +/* + * Get the properties of the destination based on the leg. + * + */ +export const getDestinationProperties = (leg, stop, config) => { + const { rentalVehicle, vehicleParking, vehicleRentalStation } = leg.to; + const { vehicleMode, routes } = stop; + + let destination = {}; + let mode = vehicleMode; + + if (routes && vehicleMode === 'BUS' && config.useExtendedRouteTypes) { + if (routes.some(p => p.type === ExtendedRouteTypes.BusExpress)) { + mode = 'bus-express'; + } + } else if (routes && vehicleMode === 'TRAM' && config.useExtendedRouteTypes) { + if (routes.some(p => p.type === ExtendedRouteTypes.SpeedTram)) { + mode = 'speedtram'; + } + } + // todo: scooter and citybike icons etc. + if (rentalVehicle) { + destination.name = rentalVehicle.rentalNetwork.networkId; + } else if (vehicleParking) { + destination.name = vehicleParking.name; + } else if (vehicleRentalStation) { + destination.name = vehicleRentalStation.name; + } else { + let iconProps = {}; + switch (mode) { + case 'TRAM,BUS': + iconProps = { + iconId: 'icon-icon_bustram-stop-lollipop', + className: 'tram-stop', + }; + break; + case 'SUBWAY': + iconProps = { + iconId: 'icon-icon_subway', + className: 'subway-stop', + }; + break; + case 'RAIL': + iconProps = { + iconId: 'icon-icon_rail-stop-lollipop', + className: 'rail-stop', + }; + + break; + case 'FERRY': + iconProps = { + iconId: 'icon-icon_ferry', + className: 'ferry-stop', + }; + break; + case 'bus-express': + iconProps = { + iconId: 'icon-icon_bus-stop-express-lollipop', + className: 'bus-stop', + }; + break; + case 'speedtram': + iconProps = { + iconId: 'icon-icon_speedtram-stop-lollipop', + className: 'speedtram-stop', + }; + break; + default: + iconProps = { + iconId: `icon-icon_${mode.toLowerCase()}-stop-lollipop`, + }; + } + destination = { + ...iconProps, + name: stop.name, + }; + } + + return destination; +}; diff --git a/app/component/itinerary/PlanConnection.js b/app/component/itinerary/PlanConnection.js index d33ff77e24..37ffb1559a 100644 --- a/app/component/itinerary/PlanConnection.js +++ b/app/component/itinerary/PlanConnection.js @@ -141,6 +141,9 @@ const planConnection = graphql` parentStation { name } + routes { + type + } } vehicleParking { name diff --git a/app/component/itinerary/navigator.scss b/app/component/itinerary/navigator.scss index beb4110b6e..f2defb6ce5 100644 --- a/app/component/itinerary/navigator.scss +++ b/app/component/itinerary/navigator.scss @@ -44,13 +44,19 @@ width: 92%; margin-left: 4%; border-radius: 15px; - height: 100px; + min-height: 70px; color: black; background-color: white !important; display: flex; align-items: center; + &.expanded { + max-height: 150px; + } + .navitop-arrow { + margin-right: var(--space-m); + .inverted { transform: rotate(180deg); } @@ -66,85 +72,166 @@ } .content { - margin: 15px 20px 0 20px; + width: 100%; - .navileg-goto { - display: flex; - flex-direction: row; - height: 100%; - color: black; + .navi-top-card { + .main-card { + display: flex; + flex-direction: row; + color: black; + margin: var(--space-s) var(--space-m) 0; + + .mode { + width: var(--space-l); + height: var(--space-l); + color: black; + margin-right: var(--space-s); + margin-top: var(--space-xxs); + } + } + + .instructions { + display: flex; + flex-direction: column; + width: 100%; + font-size: $font-size-normal; + font-weight: $font-weight-medium; + + .destination-header { + font-size: $font-size-normal; + font-weight: $font-weight-medium; + display: flex; + } + + .wait-leg { + display: flex; + align-items: center; + margin-top: 3px; + + .headsign { + margin-left: 10px; + font-size: $font-size-small; + font-weight: $font-weight-book; + max-width: 120px; + } + + .icon { + margin-top: 2px; + height: 25px; + width: 25px; + } + + .route-number { + min-width: 55px; + + .vcenter-children { + .vehicle-number { + color: white; + font-size: $font-size-large; + margin-top: 4px; + } - .navileg-divider { - height: 70px; - margin-left: 15px; + display: flex; + } + } + } } - .navileg-mode { - width: 40px; - height: 40px; - margin-top: 10px; - color: black; + .duration { + &.fade-out { + animation: fadeOut 3s forwards; + } + + font-size: $font-size-xsmall; + font-weight: $font-weight-book; + display: flex; } } - .navileg-destination { - display: flex; + .secondary-info { flex-direction: column; - width: 100%; - font-size: $font-size-normal; - font-weight: $font-weight-medium; + max-height: 250px; + padding-top: var(--space-xxs); - .destination-header { - font-size: $font-size-normal; - font-weight: $font-weight-medium; - display: flex; + .secondary-divider { + border: 1px solid #ddd; + width: 75%; + margin-left: 50px; } - .wait-leg { + .secondary-content { display: flex; - align-items: center; - margin-top: 3px; - - .headsign { - margin-left: 10px; - font-size: $font-size-small; - font-weight: $font-weight-book; - max-width: 120px; + margin-left: var(--space-xxl); + margin-bottom: var(--space-s); + margin-top: var(--space-xs); + } + + .icon-expand { + margin-top: 5px; + width: var(--space-m); + height: var(--space-m); + } + + .destination-icon { + margin: 0 10px; + + &.place { + fill: $to-color; } - .icon { - margin-top: 2px; - height: 25px; - width: 25px; + &.bus-stop { + color: $bus-color; } - .route-number { - min-width: 55px; + &.tram-stop { + color: $tram-color; + } - .vcenter-children { - .vehicle-number { - color: white; - font-size: $font-size-large; - margin-top: 4px; - } + &.subway-stop { + color: $metro-color; + } - display: flex; - } + &.rail-stop { + color: $rail-color; + } + + &.ferry-stop { + color: $ferry-color; + } + + &.funicular-stop { + color: $funicular-color; + } + + &.speedtram-stop { + color: $speedtram-color; } } - .navileg-destination-details { - .duration { - &.fade-out { - animation: fadeOut 3s forwards; + .destination { + font-weight: $font-weight-book; + text-align: left; + + .details { + > * { + margin-right: var(--space-xs); + } + + .address { + color: #888; + } + + .platform-short { + width: unset; + } + + .zone-icon-container { + margin-top: 2px; } - font-size: $font-size-xsmall; - font-weight: $font-weight-book; display: flex; + flex-direction: row; } - - display: flex; } } } @@ -198,10 +285,14 @@ .info-stack { position: fixed; - top: 180px; height: 69px; margin-left: 5%; width: 90%; + top: 150px; + + &.expanded { + top: 180px; + } &.slide-out { animation: slideOut 0.5s ease-out forwards; diff --git a/static/assets/svg-sprite.default.svg b/static/assets/svg-sprite.default.svg index a0dcc6c3e0..9ffdc699a9 100644 --- a/static/assets/svg-sprite.default.svg +++ b/static/assets/svg-sprite.default.svg @@ -2847,5 +2847,8 @@ + + + diff --git a/static/assets/svg-sprite.hsl.svg b/static/assets/svg-sprite.hsl.svg index c0409325b0..6be1983b93 100644 --- a/static/assets/svg-sprite.hsl.svg +++ b/static/assets/svg-sprite.hsl.svg @@ -2792,4 +2792,7 @@ + + +