mirror of
https://github.com/HSLdevcom/digitransit-ui
synced 2025-09-21 22:02:50 +02:00
950 lines
28 KiB
JavaScript
950 lines
28 KiB
JavaScript
import cx from 'classnames';
|
|
import moment from 'moment';
|
|
import PropTypes from 'prop-types';
|
|
import React from 'react';
|
|
import { FormattedMessage, intlShape } from 'react-intl';
|
|
|
|
import Icon from './Icon';
|
|
import LocalTime from './LocalTime';
|
|
import RelativeDuration from './RelativeDuration';
|
|
import RouteNumber from './RouteNumber';
|
|
import RouteNumberContainer from './RouteNumberContainer';
|
|
import { getActiveLegAlertSeverityLevel } from '../util/alertUtils';
|
|
import {
|
|
getLegMode,
|
|
compressLegs,
|
|
getLegBadgeProps,
|
|
isCallAgencyPickupType,
|
|
getInterliningLegs,
|
|
getTotalDistance,
|
|
} from '../util/legUtils';
|
|
import { dateOrEmpty, isTomorrow } from '../util/timeUtils';
|
|
import withBreakpoint from '../util/withBreakpoint';
|
|
import { isKeyboardSelectionEvent } from '../util/browser';
|
|
import {
|
|
BIKEAVL_UNKNOWN,
|
|
getCityBikeNetworkIcon,
|
|
getCityBikeNetworkConfig,
|
|
getCityBikeNetworkId,
|
|
getCitybikeCapacity,
|
|
} from '../util/citybikes';
|
|
import { getRouteMode } from '../util/modeUtils';
|
|
import { getCapacityForLeg } from '../util/occupancyUtil';
|
|
import { getCo2Value } from '../util/itineraryUtils';
|
|
|
|
const Leg = ({
|
|
mode,
|
|
routeNumber,
|
|
legLength,
|
|
fitRouteNumber,
|
|
renderModeIcons,
|
|
}) => {
|
|
return (
|
|
<div
|
|
className={cx(
|
|
'leg',
|
|
mode.toLowerCase(),
|
|
fitRouteNumber ? 'fit-route-number' : '',
|
|
renderModeIcons ? 'render-icon' : '',
|
|
)}
|
|
style={{ '--width': `${legLength}%` }}
|
|
>
|
|
{routeNumber}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
Leg.propTypes = {
|
|
routeNumber: PropTypes.node.isRequired,
|
|
legLength: PropTypes.number.isRequired,
|
|
mode: PropTypes.string,
|
|
fitRouteNumber: PropTypes.bool,
|
|
renderModeIcons: PropTypes.bool,
|
|
};
|
|
|
|
export const RouteLeg = (
|
|
{
|
|
leg,
|
|
large,
|
|
intl,
|
|
legLength,
|
|
isTransitLeg,
|
|
interliningWithRoute,
|
|
fitRouteNumber,
|
|
withBicycle,
|
|
hasOneTransitLeg,
|
|
},
|
|
{ config },
|
|
) => {
|
|
const isCallAgency = isCallAgencyPickupType(leg);
|
|
let routeNumber;
|
|
const mode = getRouteMode(leg.route);
|
|
|
|
const getOccupancyStatus = () => {
|
|
if (hasOneTransitLeg) {
|
|
return getCapacityForLeg(config, leg);
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
if (isCallAgency) {
|
|
const message = intl.formatMessage({
|
|
id: 'pay-attention',
|
|
defaultMessage: 'Pay Attention',
|
|
});
|
|
|
|
routeNumber = (
|
|
<RouteNumber
|
|
mode="call"
|
|
text={message}
|
|
className={cx('line', 'call')}
|
|
vertical
|
|
withBar
|
|
isTransitLeg={isTransitLeg}
|
|
/>
|
|
);
|
|
} else {
|
|
routeNumber = (
|
|
<RouteNumberContainer
|
|
alertSeverityLevel={getActiveLegAlertSeverityLevel(leg)}
|
|
route={leg.route}
|
|
className={cx('line', leg.mode.toLowerCase())}
|
|
interliningWithRoute={interliningWithRoute}
|
|
mode={mode}
|
|
vertical
|
|
withBar
|
|
isTransitLeg={isTransitLeg}
|
|
withBicycle={withBicycle}
|
|
occupancyStatus={getOccupancyStatus()}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<Leg
|
|
mode={mode}
|
|
routeNumber={routeNumber}
|
|
large={large}
|
|
legLength={legLength}
|
|
fitRouteNumber={fitRouteNumber}
|
|
/>
|
|
);
|
|
};
|
|
|
|
RouteLeg.propTypes = {
|
|
leg: PropTypes.object.isRequired,
|
|
intl: intlShape.isRequired,
|
|
large: PropTypes.bool.isRequired,
|
|
legLength: PropTypes.number.isRequired,
|
|
fitRouteNumber: PropTypes.bool.isRequired,
|
|
interliningWithRoute: PropTypes.number,
|
|
isTransitLeg: PropTypes.bool,
|
|
withBicycle: PropTypes.bool.isRequired,
|
|
hasOneTransitLeg: PropTypes.bool,
|
|
};
|
|
|
|
RouteLeg.contextTypes = {
|
|
config: PropTypes.object.isRequired,
|
|
};
|
|
|
|
RouteLeg.defaultProps = {
|
|
isTransitLeg: true,
|
|
};
|
|
|
|
export const ModeLeg = (
|
|
{ leg, mode, large, legLength, duration, renderModeIcons, icon },
|
|
{ config },
|
|
) => {
|
|
let networkIcon;
|
|
if (
|
|
(mode === 'CITYBIKE' || mode === 'BICYCLE') &&
|
|
leg.from.bikeRentalStation
|
|
) {
|
|
networkIcon =
|
|
leg.from.bikeRentalStation &&
|
|
getCityBikeNetworkIcon(
|
|
getCityBikeNetworkConfig(
|
|
leg.from.bikeRentalStation.networks[0],
|
|
config,
|
|
),
|
|
);
|
|
}
|
|
const routeNumber = (
|
|
<RouteNumber
|
|
mode={mode}
|
|
text=""
|
|
className={cx('line', mode.toLowerCase())}
|
|
duration={duration}
|
|
renderModeIcons={renderModeIcons}
|
|
vertical
|
|
withBar
|
|
icon={networkIcon || icon}
|
|
{...getLegBadgeProps(leg, config)}
|
|
/>
|
|
);
|
|
return (
|
|
<Leg
|
|
mode={mode}
|
|
routeNumber={routeNumber}
|
|
renderModeIcons={renderModeIcons}
|
|
large={large}
|
|
legLength={legLength}
|
|
/>
|
|
);
|
|
};
|
|
|
|
ModeLeg.propTypes = {
|
|
leg: PropTypes.object.isRequired,
|
|
mode: PropTypes.string.isRequired,
|
|
large: PropTypes.bool.isRequired,
|
|
legLength: PropTypes.number.isRequired,
|
|
renderModeIcons: PropTypes.bool,
|
|
duration: PropTypes.number,
|
|
icon: PropTypes.string,
|
|
};
|
|
|
|
ModeLeg.contextTypes = {
|
|
config: PropTypes.object.isRequired,
|
|
};
|
|
|
|
export const ViaLeg = () => (
|
|
<div className="leg via">
|
|
<Icon img="icon-icon_mapMarker-via" className="itinerary-icon place" />
|
|
</div>
|
|
);
|
|
|
|
const getViaPointIndex = (leg, intermediatePlaces) => {
|
|
if (!leg || !Array.isArray(intermediatePlaces)) {
|
|
return -1;
|
|
}
|
|
return intermediatePlaces.findIndex(
|
|
place => place.lat === leg.from.lat && place.lon === leg.from.lon,
|
|
);
|
|
};
|
|
|
|
const connectsFromViaPoint = (currLeg, intermediatePlaces) =>
|
|
getViaPointIndex(currLeg, intermediatePlaces) > -1;
|
|
|
|
const bikeWasParked = legs => {
|
|
const legsLength = legs.length;
|
|
for (let i = 0; i < legsLength; i++) {
|
|
if (legs[i].to && legs[i].to.bikePark) {
|
|
return i;
|
|
}
|
|
}
|
|
return legs.length;
|
|
};
|
|
|
|
const hasOneTransitLeg = data => {
|
|
return data.legs.filter(leg => leg.transitLeg).length === 1;
|
|
};
|
|
|
|
const SummaryRow = (
|
|
{
|
|
data,
|
|
breakpoint,
|
|
intermediatePlaces,
|
|
zones,
|
|
onlyHasWalkingItineraries,
|
|
lowestCo2value,
|
|
...props
|
|
},
|
|
{ intl, intl: { formatMessage }, config },
|
|
) => {
|
|
const isTransitLeg = leg => leg.transitLeg;
|
|
const isLegOnFoot = leg => leg.mode === 'WALK' || leg.mode === 'BICYCLE_WALK';
|
|
const usingOwnBicycle = data.legs.some(
|
|
leg => getLegMode(leg) === 'BICYCLE' && leg.rentedBike === false,
|
|
);
|
|
const usingOwnBicycleWholeTrip =
|
|
usingOwnBicycle && data.legs.every(leg => !leg.to || !leg.to.bikePark);
|
|
const refTime = moment(props.refTime);
|
|
const startTime = moment(data.startTime);
|
|
const endTime = moment(data.endTime);
|
|
const duration = endTime.diff(startTime);
|
|
const co2value = getCo2Value(data);
|
|
const mobile = bp => !(bp === 'large');
|
|
const legs = [];
|
|
let noTransitLegs = true;
|
|
const compressedLegs = compressLegs(data.legs).map(leg => ({
|
|
...leg,
|
|
}));
|
|
let intermediateSlack = 0;
|
|
let transitLegCount = 0;
|
|
compressedLegs.forEach((leg, i) => {
|
|
if (isTransitLeg(leg)) {
|
|
noTransitLegs = false;
|
|
transitLegCount += 1;
|
|
}
|
|
if (
|
|
leg.intermediatePlace ||
|
|
connectsFromViaPoint(leg, intermediatePlaces)
|
|
) {
|
|
intermediateSlack += leg.startTime - compressedLegs[i - 1].endTime; // calculate time spent at each intermediate place
|
|
}
|
|
});
|
|
const durationWithoutSlack = duration - intermediateSlack; // don't include time spent at intermediate places in calculations for bar lengths
|
|
let renderBarThreshold = 6;
|
|
const renderRouteNumberThreshold = 12; // route numbers will be rendered on legs that are longer than this
|
|
if (breakpoint === 'small') {
|
|
renderBarThreshold = 8.5;
|
|
}
|
|
let firstLegStartTime = null;
|
|
const vehicleNames = [];
|
|
const stopNames = [];
|
|
let addition = 0;
|
|
let onlyIconLegs = 0; // keep track of legs that are too short to have a bar
|
|
const onlyIconLegsLength = 0;
|
|
const waitThreshold = 180000;
|
|
const lastLeg = compressedLegs[compressedLegs.length - 1];
|
|
const lastLegLength =
|
|
((lastLeg.duration * 1000) / durationWithoutSlack) * 100;
|
|
const fitAllRouteNumbers = transitLegCount < 4; // if there are three or fewer transit legs, we will show all the route numbers.
|
|
const bikeParkedIndex = usingOwnBicycle
|
|
? bikeWasParked(compressedLegs)
|
|
: undefined;
|
|
const renderModeIcons = compressedLegs.length < 10;
|
|
let bikeNetwork;
|
|
let showRentalBikeDurationWarning = false;
|
|
const citybikeNetworks = new Set();
|
|
let citybikeicon;
|
|
compressedLegs.forEach((leg, i) => {
|
|
let interliningWithRoute;
|
|
let renderBar = true;
|
|
let waiting = false;
|
|
let waitTime;
|
|
let waitLength;
|
|
const isNextLegLast = i + 1 === compressedLegs.length - 1;
|
|
const shouldRenderLastLeg =
|
|
isNextLegLast && lastLegLength < renderBarThreshold;
|
|
const previousLeg = compressedLegs[i - 1];
|
|
const nextLeg = compressedLegs[i + 1];
|
|
let legLength =
|
|
((leg.endTime - leg.startTime) / durationWithoutSlack) * 100; // length of the current leg in %
|
|
|
|
const longName = !leg?.route?.shortName || leg?.route?.shortName.length > 5;
|
|
|
|
if (
|
|
nextLeg &&
|
|
!nextLeg.intermediatePlace &&
|
|
!connectsFromViaPoint(nextLeg, intermediatePlaces)
|
|
) {
|
|
// don't show waiting in intermediate places
|
|
waitTime = nextLeg.startTime - leg.endTime;
|
|
waitLength = (waitTime / durationWithoutSlack) * 100;
|
|
if (waitTime > waitThreshold && waitLength > renderBarThreshold) {
|
|
// if waittime is long enough, render a waiting bar
|
|
waiting = true;
|
|
} else {
|
|
legLength =
|
|
((leg.endTime - leg.startTime + waitTime) / durationWithoutSlack) *
|
|
100; // otherwise add the waiting to the current legs length
|
|
}
|
|
}
|
|
|
|
const [interliningLines, interliningLegs] = getInterliningLegs(
|
|
compressedLegs,
|
|
i,
|
|
);
|
|
|
|
const lastLegWithInterline = interliningLegs[interliningLegs.length - 1];
|
|
if (lastLegWithInterline) {
|
|
interliningWithRoute = interliningLines.join(' / ');
|
|
legLength =
|
|
((lastLegWithInterline.endTime - leg.startTime) /
|
|
durationWithoutSlack) *
|
|
100;
|
|
}
|
|
legLength += addition;
|
|
addition = 0;
|
|
|
|
if (shouldRenderLastLeg) {
|
|
legLength += lastLegLength; // if the last leg is too short add its length to the leg before it
|
|
}
|
|
if (legLength < renderBarThreshold && isLegOnFoot(leg)) {
|
|
// don't render short legs that are on foot at all
|
|
renderBar = false;
|
|
addition += legLength; // carry over the length of the leg to the next
|
|
}
|
|
if (leg.intermediatePlace) {
|
|
onlyIconLegs += 1;
|
|
legs.push(<ViaLeg key={`via_${leg.mode}_${leg.startTime}`} />);
|
|
}
|
|
if (isLegOnFoot(leg) && renderBar) {
|
|
const walkingTime = Math.floor(leg.duration / 60);
|
|
let walkMode = 'walk';
|
|
if (usingOwnBicycle && i < bikeParkedIndex) {
|
|
walkMode = 'bicycle_walk';
|
|
}
|
|
legs.push(
|
|
<ModeLeg
|
|
key={`${leg.mode}_${leg.startTime}`}
|
|
renderModeIcons={renderModeIcons}
|
|
isTransitLeg={false}
|
|
leg={leg}
|
|
duration={walkingTime}
|
|
mode={walkMode}
|
|
legLength={legLength}
|
|
large={breakpoint === 'large'}
|
|
/>,
|
|
);
|
|
if (leg.to.bikePark) {
|
|
onlyIconLegs += 1;
|
|
legs.push(
|
|
<div
|
|
className="leg bike_park"
|
|
key={`${leg.mode}_${leg.startTime}_bike_park_indicator`}
|
|
>
|
|
<Icon
|
|
img="icon-bike_parking"
|
|
className="itinerary-icon bike_park"
|
|
/>
|
|
</div>,
|
|
);
|
|
}
|
|
} else if (
|
|
(leg.mode === 'CITYBIKE' || leg.mode === 'BICYCLE') &&
|
|
leg.rentedBike
|
|
) {
|
|
const bikingTime = Math.floor((leg.endTime - leg.startTime) / 1000 / 60);
|
|
// eslint-disable-next-line prefer-destructuring
|
|
bikeNetwork = getCityBikeNetworkId(leg.from.bikeRentalStation.networks);
|
|
if (
|
|
config.cityBike.networks &&
|
|
config.cityBike.networks[bikeNetwork]?.timeBeforeSurcharge &&
|
|
config.cityBike.networks[bikeNetwork]?.durationInstructions
|
|
) {
|
|
const rentDurationOverSurchargeLimit =
|
|
leg.duration >
|
|
config.cityBike?.networks[bikeNetwork].timeBeforeSurcharge;
|
|
if (rentDurationOverSurchargeLimit) {
|
|
citybikeNetworks.add(bikeNetwork);
|
|
}
|
|
showRentalBikeDurationWarning =
|
|
showRentalBikeDurationWarning || rentDurationOverSurchargeLimit;
|
|
if (!citybikeicon) {
|
|
citybikeicon = getCityBikeNetworkIcon(
|
|
getCityBikeNetworkConfig(getCityBikeNetworkId(bikeNetwork), config),
|
|
);
|
|
}
|
|
}
|
|
legs.push(
|
|
<ModeLeg
|
|
key={`${leg.mode}_${leg.startTime}`}
|
|
isTransitLeg={false}
|
|
renderModeIcons={renderModeIcons}
|
|
leg={leg}
|
|
duration={bikingTime}
|
|
mode="CITYBIKE"
|
|
legLength={legLength}
|
|
large={breakpoint === 'large'}
|
|
/>,
|
|
);
|
|
} else if (leg.mode === 'CAR') {
|
|
const drivingTime = Math.floor((leg.endTime - leg.startTime) / 1000 / 60);
|
|
legs.push(
|
|
<ModeLeg
|
|
key={`${leg.mode}_${leg.startTime}`}
|
|
leg={leg}
|
|
duration={drivingTime}
|
|
mode="CAR"
|
|
legLength={legLength}
|
|
large={breakpoint === 'large'}
|
|
icon="icon-icon_car-withoutBox"
|
|
/>,
|
|
);
|
|
if (leg.to.carPark) {
|
|
onlyIconLegs += 1;
|
|
legs.push(
|
|
<div
|
|
className="leg car_park"
|
|
key={`${leg.mode}_${leg.startTime}_car_park_indicator`}
|
|
>
|
|
<Icon
|
|
img="icon-icon_car-park"
|
|
className="itinerary-icon car_park"
|
|
/>
|
|
</div>,
|
|
);
|
|
}
|
|
} else if (leg.mode === 'BICYCLE' && renderBar) {
|
|
const bikingTime = Math.floor((leg.endTime - leg.startTime) / 1000 / 60);
|
|
legs.push(
|
|
<ModeLeg
|
|
key={`${leg.mode}_${leg.startTime}`}
|
|
isTransitLeg={false}
|
|
duration={bikingTime}
|
|
renderModeIcons={renderModeIcons}
|
|
leg={leg}
|
|
mode={leg.mode}
|
|
legLength={legLength}
|
|
large={breakpoint === 'large'}
|
|
/>,
|
|
);
|
|
if (leg.to.bikePark) {
|
|
onlyIconLegs += 1;
|
|
legs.push(
|
|
<div
|
|
className="leg bike_park"
|
|
key={`${leg.mode}_${leg.startTime}_bike_park_indicator`}
|
|
>
|
|
<Icon
|
|
img="icon-bike_parking"
|
|
className="itinerary-icon bike_park"
|
|
/>
|
|
</div>,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (leg.route) {
|
|
const withBicycle =
|
|
usingOwnBicycleWholeTrip &&
|
|
(leg.route.mode === 'RAIL' || leg.route.mode === 'SUBWAY');
|
|
if (
|
|
previousLeg &&
|
|
!previousLeg.intermediatePlace &&
|
|
connectsFromViaPoint(leg, intermediatePlaces)
|
|
) {
|
|
legs.push(<ViaLeg key={`via_${leg.mode}_${leg.startTime}`} />);
|
|
}
|
|
const renderRouteNumberForALongLeg =
|
|
legLength > renderRouteNumberThreshold &&
|
|
!longName &&
|
|
transitLegCount < 7;
|
|
if (!leg.interlineWithPreviousLeg) {
|
|
legs.push(
|
|
<RouteLeg
|
|
key={`${leg.mode}_${leg.startTime}`}
|
|
leg={leg}
|
|
fitRouteNumber={
|
|
(fitAllRouteNumbers && !longName) || renderRouteNumberForALongLeg
|
|
}
|
|
interliningWithRoute={interliningWithRoute}
|
|
intl={intl}
|
|
legLength={legLength}
|
|
large={breakpoint === 'large'}
|
|
withBicycle={withBicycle}
|
|
hasOneTransitLeg={hasOneTransitLeg(data)}
|
|
/>,
|
|
);
|
|
}
|
|
vehicleNames.push(
|
|
formatMessage(
|
|
{
|
|
id: `${leg.mode.toLowerCase()}-with-route-number`,
|
|
},
|
|
{
|
|
routeNumber: leg.route.shortName,
|
|
headSign: '',
|
|
},
|
|
),
|
|
);
|
|
stopNames.push(leg.from.name);
|
|
}
|
|
|
|
if (waiting && !nextLeg?.interlineWithPreviousLeg) {
|
|
const waitingTimeinMin = Math.floor(waitTime / 1000 / 60);
|
|
legs.push(
|
|
<ModeLeg
|
|
key={`${leg.mode}_${leg.startTime}_wait`}
|
|
leg={leg}
|
|
legLength={waitLength}
|
|
renderModeIcons={renderModeIcons}
|
|
duration={waitingTimeinMin}
|
|
isTransitLeg={false}
|
|
mode="WAIT"
|
|
large={breakpoint === 'large'}
|
|
/>,
|
|
);
|
|
}
|
|
});
|
|
const normalLegs = legs.length - onlyIconLegs;
|
|
// how many pixels to take from each 'normal' leg to give room for the icons
|
|
const iconLegsInPixels = (24 * onlyIconLegs) / normalLegs;
|
|
// the leftover percentage from only showing icons added to each 'normal' leg
|
|
const iconLegsInPercents = onlyIconLegsLength / normalLegs;
|
|
let firstDeparture;
|
|
if (!noTransitLegs) {
|
|
firstDeparture = compressedLegs.find(isTransitLeg);
|
|
if (firstDeparture) {
|
|
let firstDepartureStopType;
|
|
if (firstDeparture.mode === 'RAIL' || firstDeparture.mode === 'SUBWAY') {
|
|
firstDepartureStopType = 'from-station';
|
|
} else {
|
|
firstDepartureStopType = 'from-stop';
|
|
}
|
|
let firstDeparturePlatform;
|
|
if (firstDeparture.from.stop.platformCode) {
|
|
const comma = ', ';
|
|
firstDeparturePlatform = (
|
|
<span className="platform-or-track">
|
|
{comma}
|
|
<FormattedMessage
|
|
id={firstDeparture.mode === 'RAIL' ? 'track-num' : 'platform-num'}
|
|
values={{ platformCode: firstDeparture.from.stop.platformCode }}
|
|
/>
|
|
</span>
|
|
);
|
|
}
|
|
firstLegStartTime = firstDeparture.rentedBike ? (
|
|
<div
|
|
className={cx('itinerary-first-leg-start-time', {
|
|
small: breakpoint !== 'large',
|
|
})}
|
|
>
|
|
<FormattedMessage
|
|
id="itinerary-summary-row.first-leg-start-time-citybike"
|
|
values={{
|
|
firstDepartureTime: (
|
|
<span
|
|
className={cx('time', { realtime: firstDeparture.realTime })}
|
|
>
|
|
<LocalTime time={firstDeparture.startTime} />
|
|
</span>
|
|
),
|
|
firstDepartureStop: firstDeparture.from.name,
|
|
}}
|
|
/>
|
|
<div>
|
|
{getCitybikeCapacity(
|
|
config,
|
|
firstDeparture.from.bikeRentalStation.networks[0],
|
|
) !== BIKEAVL_UNKNOWN && (
|
|
<FormattedMessage
|
|
id="bikes-available"
|
|
values={{
|
|
amount: firstDeparture.from.bikeRentalStation.bikesAvailable,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className={cx('itinerary-first-leg-start-time', 'overflow-fade', {
|
|
small: breakpoint !== 'large',
|
|
})}
|
|
>
|
|
<FormattedMessage
|
|
id="itinerary-summary-row.first-leg-start-time"
|
|
values={{
|
|
firstDepartureTime: (
|
|
<span
|
|
className={cx('start-time', {
|
|
realtime: firstDeparture.realTime,
|
|
})}
|
|
>
|
|
<LocalTime time={firstDeparture.startTime} />
|
|
</span>
|
|
),
|
|
firstDepartureStopType: (
|
|
<FormattedMessage id={firstDepartureStopType} />
|
|
),
|
|
firstDepartureStop: stopNames[0],
|
|
firstDeparturePlatform,
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
} else {
|
|
firstLegStartTime = (
|
|
<div
|
|
className={cx('itinerary-first-leg-start-time', {
|
|
small: breakpoint !== 'large',
|
|
})}
|
|
>
|
|
<FormattedMessage id="itinerary-summary-row.no-transit-legs" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const classes = cx([
|
|
'itinerary-summary-row',
|
|
'cursor-pointer',
|
|
{
|
|
passive: props.passive,
|
|
'bp-large': breakpoint === 'large',
|
|
'cancelled-itinerary': props.isCancelled,
|
|
'no-border': onlyHasWalkingItineraries,
|
|
},
|
|
]);
|
|
|
|
const itineraryStartAndEndTime = (
|
|
<div>
|
|
<LocalTime time={startTime} />
|
|
<span> - </span>
|
|
<LocalTime time={endTime} />
|
|
</div>
|
|
);
|
|
|
|
// accessible representation for summary
|
|
const textSummary = (
|
|
<div className="sr-only" key="screenReader">
|
|
<FormattedMessage
|
|
id="itinerary-summary-row.description"
|
|
values={{
|
|
departureDate: dateOrEmpty(startTime, refTime),
|
|
departureTime: <LocalTime time={startTime} />,
|
|
arrivalDate: dateOrEmpty(endTime, refTime),
|
|
arrivalTime: <LocalTime time={endTime} />,
|
|
firstDeparture:
|
|
vehicleNames.length === 0 ? null : (
|
|
<>
|
|
<FormattedMessage
|
|
id="itinerary-summary-row.first-departure"
|
|
values={{
|
|
vehicle: vehicleNames[0],
|
|
departureTime: firstDeparture ? (
|
|
<LocalTime time={firstDeparture.startTime} />
|
|
) : (
|
|
'ggh'
|
|
),
|
|
stopName: stopNames[0],
|
|
}}
|
|
/>
|
|
</>
|
|
),
|
|
transfers: vehicleNames.map((name, index) => {
|
|
if (index === 0) {
|
|
return null;
|
|
}
|
|
return formatMessage(
|
|
{ id: 'itinerary-summary-row.transfers' },
|
|
{
|
|
vehicle: name,
|
|
stopName: stopNames[index],
|
|
},
|
|
);
|
|
}),
|
|
totalTime: <RelativeDuration duration={duration} />,
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
const co2summary = (
|
|
<div className="sr-only">
|
|
<FormattedMessage
|
|
id="itinerary-co2.description-simple"
|
|
defaultMessage="CO₂ emissions for this route"
|
|
values={{
|
|
co2value,
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
const ariaLabelMessage = intl.formatMessage(
|
|
{
|
|
id: 'itinerary-page.show-details-label',
|
|
},
|
|
{ number: props.hash + 1 },
|
|
);
|
|
|
|
const dateString = dateOrEmpty(startTime, refTime);
|
|
const date = (
|
|
<>
|
|
{dateString}
|
|
<span> </span>
|
|
{dateString && <FormattedMessage id="at-time" />}
|
|
</>
|
|
);
|
|
|
|
const startDate = isTomorrow(startTime, refTime) ? (
|
|
<div className="tomorrow">
|
|
<FormattedMessage id="tomorrow" />
|
|
</div>
|
|
) : (
|
|
date
|
|
);
|
|
return (
|
|
<span role="listitem" className={classes} aria-atomic="true">
|
|
<h3 className="sr-only">
|
|
<FormattedMessage
|
|
id="summary-page.row-label"
|
|
values={{
|
|
number: props.hash + 1,
|
|
}}
|
|
/>
|
|
</h3>
|
|
{textSummary}
|
|
{config.showCO2InItinerarySummary &&
|
|
co2value !== null &&
|
|
co2value >= 0 &&
|
|
co2summary}
|
|
<div
|
|
className="itinerary-summary-visible"
|
|
style={{
|
|
display: props.isCancelled && !props.showCancelled ? 'none' : 'flex',
|
|
}}
|
|
>
|
|
{/* This next clickable region does not have proper accessible role, tabindex and keyboard handler
|
|
because screen reader works weirdly with nested buttons. Same functonality works from the inner button */
|
|
/* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
|
|
<>
|
|
<div className="itinerary-summary-header">
|
|
<div
|
|
className="summary-clickable-area"
|
|
onClick={e => {
|
|
if (mobile(breakpoint)) {
|
|
e.stopPropagation();
|
|
props.onSelectImmediately(props.hash);
|
|
} else {
|
|
props.onSelect(props.hash);
|
|
}
|
|
}}
|
|
onKeyPress={e =>
|
|
isKeyboardSelectionEvent(e) && props.onSelect(props.hash)
|
|
}
|
|
tabIndex="0"
|
|
role="button"
|
|
aria-label={ariaLabelMessage}
|
|
>
|
|
<span key="ShowOnMapScreenReader" className="sr-only">
|
|
<FormattedMessage id="itinerary-summary-row.clickable-area-description" />
|
|
</span>
|
|
<div
|
|
className="itinerary-duration-container"
|
|
key="startTime"
|
|
aria-hidden="true"
|
|
>
|
|
{startDate && (
|
|
<div className="itinerary-start-date">{startDate}</div>
|
|
)}
|
|
<div className="itinerary-start-time-and-end-time">
|
|
{itineraryStartAndEndTime}
|
|
</div>
|
|
|
|
<div style={{ flexGrow: 1 }} />
|
|
{config.showDistanceInItinerarySummary && (
|
|
<div className="itinerary-total-distance">
|
|
{(getTotalDistance(data) / 1000).toFixed(1)} km
|
|
</div>
|
|
)}
|
|
{config.showCO2InItinerarySummary &&
|
|
co2value !== null &&
|
|
co2value >= 0 && (
|
|
<div className="itinerary-co2-value-container">
|
|
{lowestCo2value === co2value && (
|
|
<Icon img="icon-icon_co2_leaf" className="co2-leaf" />
|
|
)}
|
|
<div className="itinerary-co2-value">{co2value} g</div>
|
|
</div>
|
|
)}
|
|
<div className="itinerary-duration">
|
|
<RelativeDuration duration={duration} />
|
|
</div>
|
|
</div>
|
|
<div
|
|
className="legs-container"
|
|
style={{ '--minus': `${iconLegsInPixels}px` }}
|
|
key="legs"
|
|
aria-hidden="true"
|
|
>
|
|
<div
|
|
className="itinerary-legs"
|
|
style={{ '--plus': `${iconLegsInPercents}%` }}
|
|
>
|
|
{legs}
|
|
</div>
|
|
</div>
|
|
<div
|
|
className="itinerary-first-leg-start-time-container"
|
|
key="endtime-distance"
|
|
aria-hidden="true"
|
|
>
|
|
{firstLegStartTime}
|
|
</div>
|
|
{showRentalBikeDurationWarning &&
|
|
(citybikeNetworks.size === 1 ? (
|
|
<div className="citybike-duration-info-short">
|
|
<Icon img={citybikeicon} height={1.2} width={1.2} />
|
|
<FormattedMessage
|
|
id="citybike-duration-info-short"
|
|
values={{
|
|
duration:
|
|
config.cityBike?.networks[bikeNetwork]
|
|
.timeBeforeSurcharge / 60,
|
|
}}
|
|
defaultMessage=""
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="citybike-duration-info-short">
|
|
<Icon img={citybikeicon} height={1.2} width={1.2} />
|
|
<FormattedMessage
|
|
id="citybike-duration-general-header"
|
|
defaultMessage=""
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{mobile(breakpoint) !== true && (
|
|
<div
|
|
tabIndex="0"
|
|
role="button"
|
|
title={formatMessage({
|
|
id: 'itinerary-page.show-details',
|
|
})}
|
|
key="arrow"
|
|
className="action-arrow-click-area flex-vertical noborder"
|
|
onClick={e => {
|
|
e.stopPropagation();
|
|
props.onSelectImmediately(props.hash);
|
|
}}
|
|
onKeyPress={e =>
|
|
isKeyboardSelectionEvent(e) &&
|
|
props.onSelectImmediately(props.hash)
|
|
}
|
|
aria-label={ariaLabelMessage}
|
|
>
|
|
<div className="action-arrow flex-grow">
|
|
<Icon img="icon-icon_arrow-collapse--right" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<span className="itinerary-details-container" aria-expanded="false" />
|
|
</>
|
|
</div>
|
|
</span>
|
|
);
|
|
};
|
|
|
|
SummaryRow.propTypes = {
|
|
refTime: PropTypes.number.isRequired,
|
|
data: PropTypes.object.isRequired,
|
|
passive: PropTypes.bool,
|
|
onSelect: PropTypes.func.isRequired,
|
|
onSelectImmediately: PropTypes.func.isRequired,
|
|
hash: PropTypes.number.isRequired,
|
|
children: PropTypes.node,
|
|
breakpoint: PropTypes.string.isRequired,
|
|
intermediatePlaces: PropTypes.array,
|
|
isCancelled: PropTypes.bool,
|
|
showCancelled: PropTypes.bool,
|
|
zones: PropTypes.arrayOf(PropTypes.string),
|
|
onlyHasWalkingItineraries: PropTypes.bool,
|
|
lowestCo2value: PropTypes.number,
|
|
};
|
|
|
|
SummaryRow.defaultProps = {
|
|
zones: [],
|
|
passive: false,
|
|
intermediatePlaces: [],
|
|
isCancelled: false,
|
|
showCancelled: false,
|
|
onlyHasWalkingItineraries: false,
|
|
lowestCo2value: 0,
|
|
};
|
|
|
|
SummaryRow.contextTypes = {
|
|
intl: intlShape.isRequired,
|
|
config: PropTypes.object.isRequired,
|
|
};
|
|
|
|
SummaryRow.displayName = 'SummaryRow';
|
|
|
|
const SummaryRowWithBreakpoint = withBreakpoint(SummaryRow);
|
|
|
|
export { SummaryRow as component, SummaryRowWithBreakpoint as default };
|