diff --git a/lib/components/narrative/default/default-itinerary.js b/lib/components/narrative/default/default-itinerary.js index 353cf9709..dd445fd9d 100644 --- a/lib/components/narrative/default/default-itinerary.js +++ b/lib/components/narrative/default/default-itinerary.js @@ -20,8 +20,7 @@ import { getAccessibilityScoreForItinerary, itineraryHasAccessibilityScores } from '../../../util/accessibility-routing' -import { getFare, getTotalFare } from '../../../util/state' -import { getFirstLegStartTime } from '../../../util/itinerary' +import { getFare, getFirstLegStartTime } from '../../../util/itinerary' import { Icon, StyledIconWrapperTextAlign } from '../../util/styledIcon' import { localizeGradationMap } from '../utils' import FieldTripGroupSize from '../../admin/field-trip-itinerary-group-size' diff --git a/lib/components/narrative/line-itin/itin-summary.tsx b/lib/components/narrative/line-itin/itin-summary.tsx index 24409f790..18aca4230 100644 --- a/lib/components/narrative/line-itin/itin-summary.tsx +++ b/lib/components/narrative/line-itin/itin-summary.tsx @@ -1,12 +1,13 @@ import { connect } from 'react-redux' +import { FareProductSelector, Itinerary, Leg } from '@opentripplanner/types' import { FormattedMessage, FormattedNumber } from 'react-intl' import coreUtils from '@opentripplanner/core-utils' import React, { Component } from 'react' import styled from 'styled-components' -import type { Itinerary, Leg } from '@opentripplanner/types' +import { AppReduxState } from '../../../util/state-types' import { ComponentContext } from '../../../util/contexts' -import { getFare } from '../../../util/state' +import { getFare } from '../../../util/itinerary' import FormattedDuration from '../../util/formatted-duration' // TODO: make this a prop @@ -77,7 +78,7 @@ const ShortName = styled.div<{ leg: Leg }>` type Props = { currency?: string - defaultFareType: string + defaultFareType?: FareProductSelector itinerary: Itinerary onClick: () => void } @@ -236,7 +237,7 @@ function getRouteColorForBadge(leg: Leg): string { return leg.routeColor ? '#' + leg.routeColor : defaultRouteColor } -const mapStateToProps = (state: any) => { +const mapStateToProps = (state: AppReduxState) => { return { defaultFareType: state.otp.config.itinerary?.defaultFareType } diff --git a/lib/components/narrative/metro/departure-times-list.tsx b/lib/components/narrative/metro/departure-times-list.tsx index be754892a..bebfd30e8 100644 --- a/lib/components/narrative/metro/departure-times-list.tsx +++ b/lib/components/narrative/metro/departure-times-list.tsx @@ -1,30 +1,21 @@ import { FormattedList, useIntl } from 'react-intl' -import { Itinerary, Leg } from '@opentripplanner/types' import React, { MouseEvent, useCallback } from 'react' import { firstTransitLegIsRealtime } from '../../../util/viewer' import { getFirstLegStartTime, - getLastLegEndTime + getLastLegEndTime, + ItineraryStartTime, + ItineraryWithIndex } from '../../../util/itinerary' import InvisibleA11yLabel from '../../util/invisible-a11y-label' -interface ItineraryWithIndex extends Itinerary { - index: number -} - -interface StartTime { - itinerary: ItineraryWithIndex - legs: Leg[] - realtime: boolean -} - export type SetActiveItineraryHandler = (payload: { index: number }) => void type DepartureTimesProps = { expanded?: boolean itinerary: ItineraryWithIndex & { - allStartTimes?: StartTime[] + allStartTimes?: ItineraryStartTime[] } setActiveItinerary: SetActiveItineraryHandler showArrivals?: boolean diff --git a/lib/components/narrative/metro/metro-itinerary.tsx b/lib/components/narrative/metro/metro-itinerary.tsx index 6eff07f40..92a89d7bf 100644 --- a/lib/components/narrative/metro/metro-itinerary.tsx +++ b/lib/components/narrative/metro/metro-itinerary.tsx @@ -12,13 +12,15 @@ import React from 'react' import styled, { keyframes } from 'styled-components' import * as uiActions from '../../../actions/ui' +import { AppReduxState } from '../../../util/state-types' import { ComponentContext } from '../../../util/contexts' import { FlexIndicator } from '../default/flex-indicator' import { getAccessibilityScoreForItinerary, itineraryHasAccessibilityScores } from '../../../util/accessibility-routing' -import { getActiveSearch, getFare } from '../../../util/state' +import { getActiveSearch } from '../../../util/state' +import { getFare } from '../../../util/itinerary' import { IconWithText } from '../../util/styledIcon' import { ItineraryDescription } from '../default/itinerary-description' import { ItineraryView } from '../../../util/ui' @@ -451,8 +453,7 @@ class MetroItinerary extends NarrativeItinerary { } } -// TODO: state type -const mapStateToProps = (state: any, ownProps: Props) => { +const mapStateToProps = (state: AppReduxState, ownProps: Props) => { const activeSearch = getActiveSearch(state) return { diff --git a/lib/components/narrative/narrative-itineraries.js b/lib/components/narrative/narrative-itineraries.js index 588033883..6d20a2437 100644 --- a/lib/components/narrative/narrative-itineraries.js +++ b/lib/components/narrative/narrative-itineraries.js @@ -5,6 +5,7 @@ import { FormattedMessage, injectIntl } from 'react-intl' import { isFlex, isTransit } from '@opentripplanner/core-utils/lib/itinerary' import clone from 'clone' import coreUtils from '@opentripplanner/core-utils' +import memoize from 'lodash.memoize' import PropTypes from 'prop-types' import React, { Component } from 'react' import Skeleton, { SkeletonTheme } from 'react-loading-skeleton' @@ -17,7 +18,8 @@ import { getActiveSearch, getRealtimeEffects, getResponsesWithErrors, - getVisibleItineraryIndex + getVisibleItineraryIndex, + sortItinerariesInPlaceIfNeeded } from '../../util/state' import { getFirstLegStartTime, @@ -52,12 +54,11 @@ function makeStartTime(itinerary) { } } -function doMergeItineraries(itineraries) { +const doMergeItineraries = memoize((itineraries) => { const mergedItineraries = itineraries - .reduce((prev, cur, curIndex) => { + .reduce((prev, cur) => { const updatedItineraries = clone(prev) const updatedItinerary = clone(cur) - updatedItinerary.index = curIndex const duplicateIndex = updatedItineraries.findIndex((itin) => itinerariesAreEqual(itin, cur) @@ -149,56 +150,7 @@ function doMergeItineraries(itineraries) { allItineraries, mergedItineraries } -} - -// Returns a car itinerary if there is one, otherwise returns false -function getCarItinerary(itineraries) { - const isCarOnly = (itin) => - itin.legs.length === 1 && itin.legs[0].mode.startsWith('CAR') - return ( - !!itineraries.filter(isCarOnly).length && itineraries.filter(isCarOnly)[0] - ) -} - -function computeCarbonBaseline(itineraries, co2Config) { - // Sums the sum of the leg distances for each leg - const avgDistance = - itineraries.reduce( - (sum, itin) => - sum + itin.legs.reduce((legsum, leg) => legsum + leg.distance, 0), - 0 - ) / itineraries.length - - // If we do not have a drive yourself itinerary, estimate the distance based on avg of transit distances. - return coreUtils.itinerary.calculateEmissions( - getCarItinerary(itineraries) || { - legs: [{ distance: avgDistance, mode: 'CAR' }] - }, - co2Config?.carbonIntensity, - co2Config?.massUnit - ) -} - -function addCarbonInfo(itin, co2Config, baselineCo2) { - const emissions = coreUtils.itinerary.calculateEmissions( - itin, - co2Config?.carbonIntensity, - co2Config?.massUnit - ) - return { - ...itin, - co2: emissions, - co2VsBaseline: (emissions - baselineCo2) / baselineCo2 - } -} - -function addCarbonInfoToAll(itineraries, co2Config) { - const baselineCo2 = computeCarbonBaseline(itineraries, co2Config) - return ( - itineraries?.map((itin) => addCarbonInfo(itin, co2Config, baselineCo2)) || - [] - ) -} +}) // FIXME: move to typescript once shared types exist class NarrativeItineraries extends Component { @@ -578,12 +530,14 @@ const reduceErrorsFromResponse = (acc, cur) => { // connect to the redux store const mapStateToProps = (state) => { + const { config, filter } = state.otp + const { co2, errorMessages, modes } = config + const { sort } = filter + const activeSearch = getActiveSearch(state) - const activeItinerary = activeSearch && activeSearch.activeItinerary - const { co2, errorMessages, modes } = state.otp.config - const { sort } = state.otp.filter + const activeItinerary = activeSearch?.activeItinerary const pending = activeSearch?.pending > 0 - const itineraries = getActiveItineraries(state) + const itinsWithCo2 = getActiveItineraries(state) const realtimeEffects = getRealtimeEffects(state) const urlParams = coreUtils.query.getUrlParams() const itineraryView = getItineraryView(urlParams) @@ -598,17 +552,14 @@ const mapStateToProps = (state) => { mergeItineraries, showHeaderText, sortModes - } = state.otp.config?.itinerary || false + } = config.itinerary || false // Default to true for backwards compatibility - const renderSkeletons = !state.otp.config.itinerary?.hideSkeletons + const renderSkeletons = !config.itinerary?.hideSkeletons const itineraryIsExpanded = activeItinerary !== undefined && activeItinerary !== null && showDetails const { localUser, loggedInUser } = state.user const user = loggedInUser || localUser - // Add carbon info, if available. - const itinsWithCo2 = addCarbonInfoToAll(itineraries, co2) - // Merge duplicate itineraries together and save multiple departure times let mergedItineraries let allItineraries @@ -616,18 +567,19 @@ const mapStateToProps = (state) => { // eslint-disable-next-line prettier/prettier ({ allItineraries, mergedItineraries } = doMergeItineraries(itinsWithCo2)) } else { - allItineraries = mergedItineraries = itinsWithCo2.map((itin, index) => ({ - ...itin, - index - })) + allItineraries = itinsWithCo2 + mergedItineraries = [...allItineraries] } + // Sort the merged (displayed) itineraries if needed + sortItinerariesInPlaceIfNeeded(mergedItineraries, state) + return { // swap out realtime itineraries with non-realtime depending on boolean activeItinerary, - activeLeg: activeSearch && activeSearch.activeLeg, + activeLeg: activeSearch?.activeLeg, activeSearch, - activeStep: activeSearch && activeSearch.activeStep, + activeStep: activeSearch?.activeStep, co2Config: co2, customBatchUiBackground, enabledSortModes: sortModes, @@ -645,13 +597,13 @@ const mapStateToProps = (state) => { mergedItineraries, modes, pending, - popupTarget: state.otp.config?.popups?.launchers?.optionFilter, + popupTarget: config.popups?.launchers?.optionFilter, realtimeEffects, renderSkeletons, showDetails, showHeaderText, sort, - timeFormat: coreUtils.time.getTimeFormat(state.otp.config), + timeFormat: coreUtils.time.getTimeFormat(config), user, visibleItinerary: getVisibleItineraryIndex(state) } diff --git a/lib/util/config-types.ts b/lib/util/config-types.ts index d5ffba1ca..32ec6a200 100644 --- a/lib/util/config-types.ts +++ b/lib/util/config-types.ts @@ -226,6 +226,15 @@ export type ItinerarySortOption = | 'COST' | 'DEPARTURETIME' +export interface ItineraryCostWeights { + driveReluctance: number + durationFactor: number + fareFactor: number + transferReluctance: number + waitReluctance: number + walkReluctance: number +} + export interface ItineraryConfig { costs?: ItineraryCostConfig customBatchUiBackground?: boolean @@ -248,6 +257,7 @@ export interface ItineraryConfig { showPlanFirstLastButtons?: boolean showRouteFares?: boolean sortModes?: ItinerarySortOption[] + weights?: ItineraryCostWeights } export interface CO2Config extends CO2ConfigType { diff --git a/lib/util/itinerary.js b/lib/util/itinerary.js deleted file mode 100644 index 0dab66b84..000000000 --- a/lib/util/itinerary.js +++ /dev/null @@ -1,116 +0,0 @@ -import { differenceInMinutes } from 'date-fns' -import { toDate, utcToZonedTime } from 'date-fns-tz' -import coreUtils from '@opentripplanner/core-utils' - -import { WEEKDAYS, WEEKEND_DAYS } from './monitored-trip' - -/** - * Determines whether the specified Itinerary can be monitored. - * @returns true if at least one Leg of the specified Itinerary is a transit leg, - * and none of the legs is a rental or ride hail leg (e.g. CAR_RENT, CAR_HAIL, BICYCLE_RENT, etc.). - * (We use the corresponding fields returned by OTP to get transit legs and rental/ride hail legs.) - */ -export function itineraryCanBeMonitored(itinerary) { - let hasTransit = false - let hasRentalOrRideHail = false - - if (itinerary && itinerary.legs) { - for (const leg of itinerary.legs) { - if (leg.transitLeg) { - hasTransit = true - } - if ( - leg.rentedBike || - leg.rentedCar || - leg.rentedVehicle || - leg.hailedCar - ) { - hasRentalOrRideHail = true - } - } - } - - return hasTransit && !hasRentalOrRideHail -} - -export function getMinutesUntilItineraryStart(itinerary) { - return differenceInMinutes(new Date(itinerary.startTime), new Date()) -} - -/** - * Gets the first transit leg of the given itinerary, or null if none found. - */ -function getFirstTransitLeg(itinerary) { - return itinerary?.legs?.find((leg) => leg.transitLeg) -} - -/** - * Get the first stop ID from the itinerary in the underscore format required by - * the startTransitStopId query param (e.g., TRIMET_12345 instead of TRIMET:12345). - */ -export function getFirstStopId(itinerary) { - return getFirstTransitLeg(itinerary)?.from.stopId.replace(':', '_') -} - -/** - * Returns the set of monitored days that will be initially shown to the user - * for the given itinerary. - * @param itinerary The itinerary from which the default monitored days are extracted. - * @returns ['monday' thru 'friday'] if itinerary happens on a weekday(*), - * ['saturday', 'sunday'] if itinerary happens on a saturday/sunday(*). - * (*) For transit itineraries, the first transit leg is used to make - * the determination. Otherwise, the itinerary startTime is used. - */ -export function getItineraryDefaultMonitoredDays( - itinerary, - timeZone = coreUtils.time.getUserTimezone() -) { - const firstTransitLeg = getFirstTransitLeg(itinerary) - // firstTransitLeg should be non-null because only transit trips can be monitored at this time. - // - using serviceDate covers legs that start past midnight. - // - The format of serviceDate can either be 'yyyyMMdd' (OTP v1) or 'yyyy-MM-dd' (OTP v2) - // and both formats are correctly handled by toDate from date-fns-tz. - const startDate = firstTransitLeg - ? toDate(firstTransitLeg.serviceDate, { timeZone }) - : utcToZonedTime(new Date(itinerary.startTime), timeZone) - - const dayOfWeek = startDate.getDay() - return dayOfWeek === 0 || dayOfWeek === 6 ? WEEKEND_DAYS : WEEKDAYS -} - -function legLocationsAreEqual(legLocation, other) { - return ( - !!legLocation && - !!other && - legLocation.lat === other.lat && - legLocation.lon === other.lon - ) -} - -export function itinerariesAreEqual(itinerary, other) { - return ( - itinerary.legs.length === other.legs.length && - itinerary.legs.every((leg, index) => { - const otherLeg = other?.legs?.[index] - return ( - otherLeg.mode === leg.mode && - legLocationsAreEqual(otherLeg?.to, leg?.to) && - legLocationsAreEqual(otherLeg?.from, leg?.from) - ) - }) - ) -} - -export function getFirstLegStartTime(legs) { - return legs[0].startTime -} - -export function getLastLegEndTime(legs) { - return legs[legs.length - 1].endTime -} - -export function sortStartTimes(startTimes) { - return startTimes?.sort( - (a, b) => getFirstLegStartTime(a.legs) - getFirstLegStartTime(b.legs) - ) -} diff --git a/lib/util/itinerary.tsx b/lib/util/itinerary.tsx new file mode 100644 index 000000000..a66f3322d --- /dev/null +++ b/lib/util/itinerary.tsx @@ -0,0 +1,461 @@ +import { differenceInMinutes } from 'date-fns' +import { Itinerary, Leg, Place } from '@opentripplanner/types' +import { toDate, utcToZonedTime } from 'date-fns-tz' +import coreUtils from '@opentripplanner/core-utils' +import hash from 'object-hash' +import memoize from 'lodash.memoize' + +import { AppConfig, CO2Config } from './config-types' +import { WEEKDAYS, WEEKEND_DAYS } from './monitored-trip' + +export interface ItineraryStartTime { + itinerary: ItineraryWithIndex + legs: Leg[] + realtime: boolean +} + +// FIXME: replace with OTP2 logic. +interface ItineraryWithOtp1HailedCar extends Itinerary { + legs: (Leg & { hailedCar?: boolean })[] +} + +interface OtpResponse { + plan: { + itineraries: Itinerary[] + } +} + +export interface ItineraryWithIndex extends Itinerary { + index: number +} + +export interface ItineraryWithCO2Info extends Itinerary { + co2: number + co2VsBaseline: number +} + +export interface ItineraryWithSortingCosts extends Itinerary { + rank: number + totalFare: number +} + +export interface ItineraryFareSummary { + fareCurrency?: string + maxTNCFare: number + minTNCFare: number + transitFare?: number +} + +// Similar to OTP-UI's FareProductSelector, but the fields are nullable. +interface RelaxedFareProductSelector { + mediumId: string | null + riderCategoryId: string | null +} + +/** + * Determines whether the specified Itinerary can be monitored. + * @returns true if at least one Leg of the specified Itinerary is a transit leg, + * and none of the legs is a rental or ride hail leg (e.g. CAR_RENT, CAR_HAIL, BICYCLE_RENT, etc.). + * (We use the corresponding fields returned by OTP to get transit legs and rental/ride hail legs.) + */ +export function itineraryCanBeMonitored( + itinerary: ItineraryWithOtp1HailedCar +): boolean { + let hasTransit = false + let hasRentalOrRideHail = false + + if (itinerary && itinerary.legs) { + for (const leg of itinerary.legs) { + if (leg.transitLeg) { + hasTransit = true + } + if ( + leg.rentedBike || + leg.rentedCar || + leg.rentedVehicle || + leg.hailedCar + ) { + hasRentalOrRideHail = true + } + } + } + + return hasTransit && !hasRentalOrRideHail +} + +export function getMinutesUntilItineraryStart(itinerary: Itinerary): number { + return differenceInMinutes(new Date(itinerary.startTime), new Date()) +} + +/** + * Gets the first transit leg of the given itinerary, or null if none found. + */ +function getFirstTransitLeg(itinerary: Itinerary) { + return itinerary?.legs?.find((leg) => leg.transitLeg) +} + +/** + * Get the first stop ID from the itinerary in the underscore format required by + * the startTransitStopId query param (e.g., TRIMET_12345 instead of TRIMET:12345). + */ +export function getFirstStopId(itinerary: Itinerary): string | undefined { + return getFirstTransitLeg(itinerary)?.from.stopId?.replace(':', '_') +} + +/** + * Returns the set of monitored days that will be initially shown to the user + * for the given itinerary. + * @param itinerary The itinerary from which the default monitored days are extracted. + * @returns ['monday' thru 'friday'] if itinerary happens on a weekday(*), + * ['saturday', 'sunday'] if itinerary happens on a saturday/sunday(*). + * (*) For transit itineraries, the first transit leg is used to make + * the determination. Otherwise, the itinerary startTime is used. + */ +export function getItineraryDefaultMonitoredDays( + itinerary: Itinerary, + timeZone = coreUtils.time.getUserTimezone() +): string[] { + const firstTransitLeg = getFirstTransitLeg(itinerary) + // firstTransitLeg should be non-null because only transit trips can be monitored at this time. + // - using serviceDate covers legs that start past midnight. + // - The format of serviceDate can either be 'yyyyMMdd' (OTP v1) or 'yyyy-MM-dd' (OTP v2) + // and both formats are correctly handled by toDate from date-fns-tz. + const startDate = firstTransitLeg + ? toDate(firstTransitLeg.serviceDate || '', { timeZone }) + : utcToZonedTime(new Date(itinerary.startTime), timeZone) + + const dayOfWeek = startDate.getDay() + return dayOfWeek === 0 || dayOfWeek === 6 ? WEEKEND_DAYS : WEEKDAYS +} + +function legLocationsAreEqual(legLocation: Place, other: Place) { + return ( + !!legLocation && + !!other && + legLocation.lat === other.lat && + legLocation.lon === other.lon + ) +} + +export function itinerariesAreEqual( + itinerary: Itinerary, + other: Itinerary +): boolean { + return ( + itinerary.legs.length === other.legs.length && + itinerary.legs.every((leg, index) => { + const otherLeg = other?.legs?.[index] + return ( + otherLeg.mode === leg.mode && + legLocationsAreEqual(otherLeg?.to, leg?.to) && + legLocationsAreEqual(otherLeg?.from, leg?.from) + ) + }) + ) +} + +export function getFirstLegStartTime(legs: Leg[]): number { + return +legs[0].startTime +} + +export function getLastLegEndTime(legs: Leg[]): number { + return +legs[legs.length - 1].endTime +} + +export function sortStartTimes( + startTimes: ItineraryStartTime[] +): ItineraryStartTime[] { + return startTimes?.sort( + (a, b) => getFirstLegStartTime(a.legs) - getFirstLegStartTime(b.legs) + ) +} + +// Ignore certain keys that could add significant calculation time to hashing. +// The alerts are irrelevant, but the intermediateStops, interStopGeometry and +// steps could have the legGeometry substitute as an equivalent hash value +const blackListedKeys = [ + 'alerts', + 'intermediateStops', + 'interStopGeometry', + 'steps' +] +// make blackListedKeys into an object due to superior lookup performance +const blackListedKeyLookup: Record = {} +blackListedKeys.forEach((key) => { + blackListedKeyLookup[key] = true +}) + +/** + * A memoized function to hash the itinerary. + * NOTE: It can take a while (>30ms) for the object-hash library to calculate + * an itinerary's hash for some lengthy itineraries. If better performance is + * desired, additional values to blackListedKeys should be added to avoid + * spending extra time hashing values that wouldn't result in different + * itineraries. + */ +const hashItinerary = memoize((itinerary) => + hash(itinerary, { excludeKeys: (key) => blackListedKeyLookup[key] }) +) + +/** + * Returns a list of itineraries from the redux-stored responses, without duplicates. + */ +export function collectItinerariesWithoutDuplicates( + response: OtpResponse[] +): ItineraryWithIndex[] { + const itineraries: ItineraryWithIndex[] = [] + // keep track of itinerary hashes in order to not include duplicate + // itineraries. Duplicate itineraries can occur in batch routing where a walk + // to transit trip can sometimes still be the most optimal trip even when + // additional modes such as bike rental were also requested + const seenItineraryHashes: Record = {} + response?.forEach((res) => { + res?.plan?.itineraries?.forEach((itinerary) => { + // hashing takes a while on itineraries + const itineraryHash = hashItinerary(itinerary) + if (!seenItineraryHashes[itineraryHash]) { + itineraries.push({ ...itinerary, index: itineraries.length }) + seenItineraryHashes[itineraryHash] = true + } + }) + }) + + return itineraries +} + +/** + * Whether an itinerary is car-only. + */ +function isCarOnly(itin: Pick) { + return itin.legs.length === 1 && itin.legs[0].mode.startsWith('CAR') +} + +/** + * Returns a car itinerary if there is one, otherwise returns false. + */ +function getCarItinerary(itineraries: Pick[]) { + return ( + !!itineraries.filter(isCarOnly).length && itineraries.filter(isCarOnly)[0] + ) +} + +/** + * Compute the carbon emitted while driving (the baseline for comparison). + */ +function computeCarbonBaseline(itineraries: Itinerary[], co2Config: CO2Config) { + // Sums the sum of the leg distances for each leg + const avgDistance = + itineraries.reduce( + (sum, itin) => + sum + itin.legs.reduce((legsum, leg) => legsum + leg.distance, 0), + 0 + ) / itineraries.length + + // If we do not have a drive yourself itinerary, estimate the distance based on avg of transit distances. + return coreUtils.itinerary.calculateEmissions( + getCarItinerary(itineraries) || { + legs: [{ distance: avgDistance, mode: 'CAR' }] as Leg[] + }, + co2Config?.carbonIntensity, + co2Config?.massUnit + ) +} + +/** + * Add carbon info to an itinerary. + */ +function addCarbonInfo( + itin: T, + co2Config: CO2Config, + baselineCo2: number +) { + const emissions = coreUtils.itinerary.calculateEmissions( + itin, + co2Config?.carbonIntensity, + co2Config?.massUnit + ) + return { + ...itin, + co2: emissions, + co2VsBaseline: (emissions - baselineCo2) / baselineCo2 + } +} + +/** + * Add carbon info to the given set of itineraries. + */ +export function addCarbonInfoToAll( + itineraries: T[], + co2Config: CO2Config +): ItineraryWithCO2Info[] { + const baselineCo2 = computeCarbonBaseline(itineraries, co2Config) + return ( + itineraries?.map((itin) => addCarbonInfo(itin, co2Config, baselineCo2)) || + [] + ) +} + +/** + * Get total drive time (i.e., total duration for legs with mode=CAR) for an + * itinerary. + */ +function getDriveTime(itinerary: Itinerary): number { + if (!itinerary) return 0 + let driveTime = 0 + itinerary.legs.forEach((leg) => { + if (leg.mode === 'CAR') driveTime += leg.duration + }) + return driveTime +} + +/** + * Parses OTP itinerary fare object and returns fares along with overridden currency + */ +export function getFare( + itinerary: Itinerary, + defaultFareType?: RelaxedFareProductSelector +): ItineraryFareSummary { + const { maxTNCFare, minTNCFare } = + coreUtils.itinerary.calculateTncFares(itinerary) + + const itineraryCost = coreUtils.itinerary.getItineraryCost( + itinerary?.legs, + defaultFareType?.mediumId || null, + defaultFareType?.riderCategoryId || null + ) + + return { + fareCurrency: itineraryCost?.currency.code, + maxTNCFare, + minTNCFare, + transitFare: itineraryCost?.amount + } +} + +/** + * Default costs for modes that currently have no costs evaluated in + * OpenTripPlanner. + */ +const DEFAULT_COSTS = { + // $2 per trip? This is a made up number. + bikeshareTripCostCents: 2 * 100, + // $2 for 3 hours of parking? + carParkingCostCents: 3 * 2.0 * 100, + // FL per diem rate: https://www.flcourts.org/content/download/219314/1981830/TravelInformation.pdf + drivingCentsPerMile: 0.445 * 100 +} + +/** + * Returns total fare for itinerary (in cents) + * FIXME: Move to otp-ui? + * TODO: Add GBFS fares + */ +export function getTotalFare( + itinerary: Itinerary, + configCosts = {}, + defaultFareType: RelaxedFareProductSelector = { + mediumId: null, + riderCategoryId: null + } +): number | null { + // Get TNC fares. + const { maxTNCFare, transitFare } = getFare(itinerary, defaultFareType) + // Start with default cost values. + const costs = DEFAULT_COSTS + // If config contains values to override defaults, apply those. + if (configCosts) Object.assign(costs, configCosts) + // Calculate total cost from itinerary legs. + let drivingCost = 0 + let hasBikeshare = false + let transitFareNotProvided = false + let rideHailTrip = false + itinerary.legs.forEach((leg) => { + rideHailTrip = rideHailTrip || !!leg?.rideHailingEstimate + if (leg.mode === 'CAR' && !rideHailTrip) { + // Convert meters to miles and multiple by cost per mile. + drivingCost += leg.distance * 0.000621371 * costs.drivingCentsPerMile + } + if ( + leg.mode === 'BICYCLE_RENT' || + leg.mode === 'MICROMOBILITY' || + leg.mode === 'SCOOTER' || + leg.rentedBike + ) { + hasBikeshare = true + } + if (coreUtils.itinerary.isTransit(leg.mode) && transitFare == null) { + transitFareNotProvided = true + } + }) + // If our itinerary includes a transit leg, but transit fare data is not provided + // return no fare information, rather than an underestimate + if (transitFareNotProvided) return null + const bikeshareCost = hasBikeshare ? costs.bikeshareTripCostCents : 0 + // If some leg uses driving, add parking cost to the total. + if (drivingCost > 0 && !rideHailTrip) drivingCost += costs.carParkingCostCents + return bikeshareCost + drivingCost + (transitFare || 0) + maxTNCFare * 100 +} + +/** + * Default constants for calculating itinerary "cost", i.e., how preferential a + * particular itinerary is based on factors like wait time, total fare, drive + * time, etc. + */ +const DEFAULT_WEIGHTS = { + driveReluctance: 2, + durationFactor: 0.25, + fareFactor: 0.5, + transferReluctance: 0.9, + waitReluctance: 0.1, + walkReluctance: 0.1 +} + +/** + * This calculates the "cost" (not the monetary cost, but the cost according to + * multiple factors like duration, total fare, and walking distance) for a + * particular itinerary, for use in sorting itineraries. + * FIXME: Do major testing to get this right. + */ +export function calculateItineraryCost( + itinerary: Itinerary, + config: Pick = {} +): number { + // Initialize weights to default values. + const weights = DEFAULT_WEIGHTS + // If config contains values to override defaults, apply those. + const configWeights = config.itinerary && config.itinerary.weights + if (configWeights) Object.assign(weights, configWeights) + return ( + (getTotalFare( + itinerary, + config.itinerary?.costs, + config.itinerary?.defaultFareType + ) || 0) * + weights.fareFactor + + itinerary.duration * weights.durationFactor + + (itinerary.walkDistance || 0) * weights.walkReluctance + + getDriveTime(itinerary) * weights.driveReluctance + + itinerary.waitingTime * weights.waitReluctance + + (itinerary.transfers || 0) * weights.transferReluctance + ) +} + +/** + * Computes and add cost attributes to avoid recomputing those costs during sorting. + */ +export function addSortingCosts( + itinerary: T, + config: AppConfig +): ItineraryWithSortingCosts { + const configCosts = config.itinerary?.costs + const totalFareResult = getTotalFare(itinerary, configCosts) + const totalFare = + totalFareResult === null ? Number.MAX_VALUE : totalFareResult + + const rank = calculateItineraryCost(itinerary, config) + return { + ...itinerary, + rank, + totalFare + } +} diff --git a/lib/util/state-types.ts b/lib/util/state-types.ts index 18f7ef36b..ae33c020e 100644 --- a/lib/util/state-types.ts +++ b/lib/util/state-types.ts @@ -2,6 +2,11 @@ import { AppConfig } from './config-types' export interface OtpState { config: AppConfig + filter: { + sort: { + type: string + } + } // TODO: Add other OTP states ui: any // TODO } diff --git a/lib/util/state.js b/lib/util/state.js index eaaf9aa15..3ce343b5b 100644 --- a/lib/util/state.js +++ b/lib/util/state.js @@ -2,12 +2,9 @@ import { compareTwoStrings } from 'string-similarity' import { createSelector } from 'reselect' import { format } from 'date-fns-tz' import { FormattedList, FormattedMessage } from 'react-intl' -import { isTransit } from '@opentripplanner/core-utils/lib/itinerary' import { itineraryToTransitive } from '@opentripplanner/transitive-overlay' import coreUtils from '@opentripplanner/core-utils' -import hash from 'object-hash' import isEqual from 'lodash.isequal' -import memoize from 'lodash.memoize' import qs from 'qs' import React from 'react' import styled from 'styled-components' @@ -15,6 +12,13 @@ import styled from 'styled-components' import FormattedMode from '../components/util/formatted-mode' import Strong from '../components/util/strong-text' +import { + addCarbonInfoToAll, + addSortingCosts, + calculateItineraryCost, + collectItinerariesWithoutDuplicates +} from './itinerary' + // For lowercase context const LowerCase = styled.span` text-transform: lowercase; @@ -29,190 +33,28 @@ export function getActiveSearch(state) { return state.otp.searches[state.otp.activeSearchId] } -/** - * Get total drive time (i.e., total duration for legs with mode=CAR) for an - * itinerary. - */ -function getDriveTime(itinerary) { - if (!itinerary) return 0 - let driveTime = 0 - itinerary.legs.forEach((leg) => { - if (leg.mode === 'CAR') driveTime += leg.duration - }) - return driveTime -} - -/** - * Parses OTP itinerary fare object and returns fares along with overridden currency - */ -export function getFare(itinerary, defaultFareType) { - const { maxTNCFare, minTNCFare } = - coreUtils.itinerary.calculateTncFares(itinerary) - - const itineraryCost = coreUtils.itinerary.getItineraryCost( - itinerary?.legs, - defaultFareType?.mediumId || null, - defaultFareType?.riderCategoryId || null - ) - - return { - fareCurrency: itineraryCost?.currency.code, - maxTNCFare, - minTNCFare, - transitFare: itineraryCost?.amount - } -} - -/** - * Default costs for modes that currently have no costs evaluated in - * OpenTripPlanner. - */ -const DEFAULT_COSTS = { - // $2 per trip? This is a made up number. - bikeshareTripCostCents: 2 * 100, - // $2 for 3 hours of parking? - carParkingCostCents: 3 * 2.0 * 100, - // FL per diem rate: https://www.flcourts.org/content/download/219314/1981830/TravelInformation.pdf - drivingCentsPerMile: 0.445 * 100 -} - -/** - * Returns total fare for itinerary (in cents) - * FIXME: Move to otp-ui? - * TODO: Add GBFS fares - */ -export function getTotalFare( - itinerary, - configCosts = {}, - defaultFareType = { mediumId: null, riderCategoryId: null } -) { - // Get TNC fares. - const { fareCurrency, maxTNCFare, transitFare } = getFare( - itinerary, - defaultFareType - ) - // Start with default cost values. - const costs = DEFAULT_COSTS - // If config contains values to override defaults, apply those. - if (configCosts) Object.assign(costs, configCosts) - // Calculate total cost from itinerary legs. - let drivingCost = 0 - let hasBikeshare = false - let transitFareNotProvided = false - let rideHailTrip = false - itinerary.legs.forEach((leg) => { - rideHailTrip = rideHailTrip || leg?.rideHailingEstimate - if (leg.mode === 'CAR' && !rideHailTrip) { - // Convert meters to miles and multiple by cost per mile. - drivingCost += leg.distance * 0.000621371 * costs.drivingCentsPerMile - } - if ( - leg.mode === 'BICYCLE_RENT' || - leg.mode === 'MICROMOBILITY' || - leg.mode === 'SCOOTER' || - leg.rentedBike - ) { - hasBikeshare = true - } - if (isTransit(leg.mode) && transitFare == null) { - transitFareNotProvided = true - } - }) - // If our itinerary includes a transit leg, but transit fare data is not provided - // return no fare information, rather than an underestimate - if (transitFareNotProvided) return null - const bikeshareCost = hasBikeshare ? costs.bikeshareTripCostCents : 0 - // If some leg uses driving, add parking cost to the total. - if (drivingCost > 0 && !rideHailTrip) drivingCost += costs.carParkingCostCents - return bikeshareCost + drivingCost + transitFare + maxTNCFare * 100 -} - -/** - * Default constants for calculating itinerary "cost", i.e., how preferential a - * particular itinerary is based on factors like wait time, total fare, drive - * time, etc. - */ -const DEFAULT_WEIGHTS = { - driveReluctance: 2, - durationFactor: 0.25, - fareFactor: 0.5, - transferReluctance: 0.9, - waitReluctance: 0.1, - walkReluctance: 0.1 -} - -/** - * This calculates the "cost" (not the monetary cost, but the cost according to - * multiple factors like duration, total fare, and walking distance) for a - * particular itinerary, for use in sorting itineraries. - * FIXME: Do major testing to get this right. - */ -function calculateItineraryCost(itinerary, config = {}) { - // Initialize weights to default values. - const weights = DEFAULT_WEIGHTS - // If config contains values to override defaults, apply those. - const configWeights = config.itinerary && config.itinerary.weights - if (configWeights) Object.assign(weights, configWeights) - return ( - getTotalFare( - itinerary, - config.itinerary?.costs, - config.itinerary?.defaultFareType - ) * - weights.fareFactor + - itinerary.duration * weights.durationFactor + - itinerary.walkDistance * weights.walkReluctance + - getDriveTime(itinerary) * weights.driveReluctance + - itinerary.waitingTime * weights.waitReluctance + - itinerary.transfers * weights.transferReluctance - ) -} - /** * Array sort function for itineraries (in batch routing context) that attempts * to sort based on the type/direction specified. */ -/* eslint-disable-next-line complexity */ -function sortItineraries(type, direction, a, b, config) { +export function sortItineraries(type, direction, a, b, config) { + const dirFactor = direction === 'ASC' ? 1 : -1 switch (type) { case 'WALKTIME': - if (direction === 'ASC') return a.walkTime - b.walkTime - else return b.walkTime - a.walkTime + return dirFactor * (a.walkTime - b.walkTime) case 'ARRIVALTIME': - if (direction === 'ASC') return a.endTime - b.endTime - else return b.endTime - a.endTime + return dirFactor * (a.endTime - b.endTime) case 'DEPARTURETIME': - if (direction === 'ASC') return a.startTime - b.startTime - else return b.startTime - a.startTime + return dirFactor * (a.startTime - b.startTime) case 'DURATION': - if (direction === 'ASC') return a.duration - b.duration - else return b.duration - a.duration + return dirFactor * (a.duration - b.duration) case 'COST': - // eslint-disable-next-line no-case-declarations - const configCosts = config.itinerary?.costs - // Sort an itinerary without fare information last - // eslint-disable-next-line no-case-declarations - const aTotal = - getTotalFare(a, configCosts) === null - ? Number.MAX_VALUE - : getTotalFare(a, configCosts) - // eslint-disable-next-line no-case-declarations - const bTotal = - getTotalFare(b, configCosts) === null - ? Number.MAX_VALUE - : getTotalFare(b, configCosts) - if (direction === 'ASC') return aTotal - bTotal - else return bTotal - aTotal + return dirFactor * (a.totalFare - b.totalFare) default: if (type !== 'BEST') console.warn(`Sort (${type}) not supported. Defaulting to BEST.`) // FIXME: Fully implement default sort algorithm. - // eslint-disable-next-line no-case-declarations - const aCost = calculateItineraryCost(a, config) - // eslint-disable-next-line no-case-declarations - const bCost = calculateItineraryCost(b, config) - if (direction === 'ASC') return aCost - bCost - else return bCost - aCost + return dirFactor * (a.rank - b.rank) } } @@ -253,33 +95,6 @@ export const getActiveFieldTripRequest = createSelector( } ) -// Ignore certain keys that could add significant calculation time to hashing. -// The alerts are irrelevant, but the intermediateStops, interStopGeometry and -// steps could have the legGeometry substitute as an equivalent hash value -const blackListedKeys = [ - 'alerts', - 'intermediateStops', - 'interStopGeometry', - 'steps' -] -// make blackListedKeys into an object due to superior lookup performance -const blackListedKeyLookup = {} -blackListedKeys.forEach((key) => { - blackListedKeyLookup[key] = true -}) - -/** - * A memoized function to hash the itinerary. - * NOTE: It can take a while (>30ms) for the object-hash library to calculate - * an itinerary's hash for some lengthy itineraries. If better performance is - * desired, additional values to blackListedKeys should be added to avoid - * spending extra time hashing values that wouldn't result in different - * itineraries. - */ -const hashItinerary = memoize((itinerary) => - hash(itinerary, { excludeKeys: (key) => blackListedKeyLookup[key] }) -) - /** * Get the active itineraries for the active search, which is dependent on * whether realtime or non-realtime results should be displayed @@ -290,48 +105,37 @@ const hashItinerary = memoize((itinerary) => export const getActiveItineraries = createSelector( (state) => state.otp.config, getActiveSearchNonRealtimeResponse, - (state) => state.otp.filter, getActiveSearchRealtimeResponse, - getActiveFieldTripRequest, - ( - config, - nonRealtimeResponse, - itinerarySortSettings, - realtimeResponse, - activeFieldTripRequest - ) => { + (config, nonRealtimeResponse, realtimeResponse) => { const response = realtimeResponse || nonRealtimeResponse - const itineraries = [] - // keep track of itinerary hashes in order to not include duplicate - // itineraries. Duplicate itineraries can occur in batch routing where a walk - // to transit trip can sometimes still be the most optimal trip even when - // additional modes such as bike rental were also requested - const seenItineraryHashes = {} - if (response) { - response.forEach((res) => { - res?.plan?.itineraries?.forEach((itinerary) => { - // hashing takes a while on itineraries - const itineraryHash = hashItinerary(itinerary) - if (!seenItineraryHashes[itineraryHash]) { - itineraries.push(itinerary) - seenItineraryHashes[itineraryHash] = true - } - }) - }) + const itineraries = collectItinerariesWithoutDuplicates(response).map( + (itin) => addSortingCosts(itin, config) + ) + // Add carbon info, if available. + if (config.co2?.enabled) { + return addCarbonInfoToAll(itineraries, config.co2) } - const { sort } = itinerarySortSettings - const { direction, type } = sort - // If no sort type is provided (e.g., because batch routing is not enabled), - // do not sort itineraries (default sort from API response is used). - // Also, do not sort itineraries if a field trip request is active - return !type || Boolean(activeFieldTripRequest) - ? itineraries - : itineraries.sort((a, b) => - sortItineraries(type, direction, a, b, config) - ) + return itineraries } ) +/** + * Helper method to sort itineraries. + * As the name indicates, it will mutate the order in the specified array. + */ +export function sortItinerariesInPlaceIfNeeded(itineraries, state) { + const { config, filter } = state.otp + const { sort } = filter + const { direction, type } = sort + + // If no sort type is provided (e.g., because batch routing is not enabled), + // do not sort itineraries (default sort from API response is used). + // Also, do not sort itineraries if a field trip request is active + return !type || Boolean(getActiveFieldTripRequest(state)) + ? itineraries + : itineraries.sort((a, b) => sortItineraries(type, direction, a, b, config)) +} + /** * Get the active itinerary/profile for the active search object * @param {Object} state the redux state object diff --git a/package.json b/package.json index a20719e8f..bec525bbe 100644 --- a/package.json +++ b/package.json @@ -138,7 +138,9 @@ "@percy/puppeteer": "^2.0.2", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.1", "@types/clone": "^2.1.1", + "@types/lodash.memoize": "^4.1.8", "@types/mapbox__polyline": "^1.0.2", + "@types/object-hash": "^3.0.5", "@types/qs": "^6.9.7", "@types/react-bootstrap": "^0.32.26", "@types/react-router": "^5.1.17", diff --git a/yarn.lock b/yarn.lock index f3389935f..96dc01d38 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3568,6 +3568,18 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= +"@types/lodash.memoize@^4.1.8": + version "4.1.8" + resolved "https://registry.yarnpkg.com/@types/lodash.memoize/-/lodash.memoize-4.1.8.tgz#a5bb773a4644de4ff10640e7a7f7f6152d5e5c5c" + integrity sha512-mf2QpcedTC4qXJxqgbmCuST3a/SNTuqz2kMtojazqeLhjXaLXgoSwAgVbAz6FINw90Ahg0y1m69pm+rhta3c8Q== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*": + version "4.14.200" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.200.tgz#435b6035c7eba9cdf1e039af8212c9e9281e7149" + integrity sha512-YI/M/4HRImtNf3pJgbF+W6FrXovqj+T+/HpENLTooK9PnkacBsDpeP3IpHab40CClUfhNmdM2WTNP2sa2dni5Q== + "@types/long@^3.0.32": version "3.0.32" resolved "https://registry.yarnpkg.com/@types/long/-/long-3.0.32.tgz#f4e5af31e9e9b196d8e5fca8a5e2e20aa3d60b69" @@ -3626,6 +3638,11 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw== +"@types/object-hash@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@types/object-hash/-/object-hash-3.0.5.tgz#f028398f9d918a4dcb57c50d16fc15023db6b244" + integrity sha512-WFGeSazfL5BWbEh5ACaAIs5RT6sbVIwBs1rgHUp+kZzX/gub41LEEYWTWbYnE/sKb7hDdPEsGa1Vmcaay2fS5g== + "@types/parse-json@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"