mirror of
https://github.com/HSLdevcom/digitransit-ui
synced 2025-07-06 01:00:37 +02:00
644 lines
17 KiB
JavaScript
644 lines
17 KiB
JavaScript
import isEmpty from 'lodash/isEmpty';
|
|
import isEqual from 'lodash/isEqual';
|
|
import pick from 'lodash/pick';
|
|
import polyline from 'polyline-encoded';
|
|
import SunCalc from 'suncalc';
|
|
import {
|
|
changeRealTimeClientTopics,
|
|
startRealTimeClient,
|
|
stopRealTimeClient,
|
|
} from '../../action/realTimeClientAction';
|
|
import { PlannerMessageType } from '../../constants';
|
|
import { addAnalyticsEvent } from '../../util/analyticsUtils';
|
|
import { boundWithMinimumArea } from '../../util/geo-utils';
|
|
import { compressLegs, getTotalBikingDistance } from '../../util/legUtils';
|
|
import { getMapLayerOptions } from '../../util/mapLayerUtils';
|
|
import { getDefaultSettings, getSettings } from '../../util/planParamUtil';
|
|
import { getStartTimeWithColon } from '../../util/timeUtils';
|
|
|
|
/**
|
|
* Returns the index of selected itinerary. Attempts to look for
|
|
* the information in the location's state and pathname, respectively.
|
|
* Otherwise, pre-selects the first non-cancelled itinerary or, failing that,
|
|
* defaults to the index 0.
|
|
*
|
|
* @param {{ pathname: string, state: * }} location the current location object.
|
|
* @param {*} edges the itineraries retrieved from OTP.
|
|
* @param {number} defaultValue the default value, defaults to 0.
|
|
*/
|
|
export function getSelectedItineraryIndex(
|
|
{ pathname, state } = {},
|
|
edges = [],
|
|
) {
|
|
// path defines the selection in detail view
|
|
const lastURLSegment = pathname?.split('/').pop();
|
|
if (lastURLSegment !== '') {
|
|
const index = Number(pathname?.split('/').pop());
|
|
if (!Number.isNaN(index)) {
|
|
if (index >= edges.length) {
|
|
return 0;
|
|
}
|
|
return index;
|
|
}
|
|
}
|
|
|
|
// in summary view, look the location state
|
|
if (state?.selectedItineraryIndex !== undefined) {
|
|
if (state.selectedItineraryIndex < edges.length) {
|
|
return state.selectedItineraryIndex;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/*
|
|
* If state does not exist, for example when accessing the summary
|
|
* page by an external link, we check if an itinerary selection is
|
|
* supplied in URL and make that the selection.
|
|
*/
|
|
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Report any errors that happen when showing summary
|
|
*
|
|
* @param {Error|string|any} error
|
|
*/
|
|
export function reportError(error) {
|
|
addAnalyticsEvent({
|
|
category: 'Itinerary',
|
|
action: 'ErrorLoading',
|
|
name: 'ItineraryPage',
|
|
message: error.message || error,
|
|
stack: error.stack || null,
|
|
});
|
|
}
|
|
|
|
export function getTopics(legs, config) {
|
|
const itineraryTopics = [];
|
|
|
|
if (legs) {
|
|
const { realTime, feedIds } = config;
|
|
|
|
legs.forEach(leg => {
|
|
if (leg.transitLeg && leg.trip) {
|
|
const feedId = leg.trip.gtfsId.split(':')[0];
|
|
if (realTime && feedIds.includes(feedId)) {
|
|
itineraryTopics.push({
|
|
route: leg.route.gtfsId.split(':')[1],
|
|
shortName: leg.route.shortName,
|
|
type: leg.route.type,
|
|
feedId,
|
|
mode: leg.mode.toLowerCase(),
|
|
direction: Number(leg.trip.directionId),
|
|
tripStartTime: getStartTimeWithColon(
|
|
leg.trip.stoptimesForDate[0].scheduledDeparture,
|
|
),
|
|
tripId: leg.trip.gtfsId.split(':')[1],
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
return itineraryTopics;
|
|
}
|
|
|
|
export function getBounds(edges, from, to, viaPoints) {
|
|
// Decode all legs of all itineraries into latlong arrays,
|
|
// and concatenate into one big latlong array
|
|
const bounds = [
|
|
[from.lat, from.lon],
|
|
[to.lat, to.lon],
|
|
];
|
|
viaPoints.forEach(p => bounds.push([p.lat, p.lon]));
|
|
return boundWithMinimumArea(
|
|
bounds
|
|
.concat(
|
|
...edges.map(e =>
|
|
[].concat(
|
|
...e.node.legs.map(leg => polyline.decode(leg.legGeometry.points)),
|
|
),
|
|
),
|
|
)
|
|
.filter(a => a[0] && a[1]),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Compare two itinerary lists. If identical, return true
|
|
*
|
|
* @param {*} edges1
|
|
* @param {*} edges2
|
|
*/
|
|
const legProperties = [
|
|
'mode',
|
|
'from.lat',
|
|
'from.lon',
|
|
'to.lat',
|
|
'to.lon',
|
|
'from.stop.gtfsId',
|
|
'to.stop.gtfsId',
|
|
'trip.gtfsId',
|
|
];
|
|
|
|
export function isEqualItineraries(itins, itins2) {
|
|
if (!itins && !itins2) {
|
|
return true;
|
|
}
|
|
if (!itins || !itins2 || itins.length !== itins2.length) {
|
|
return false;
|
|
}
|
|
for (let i = 0; i < itins.length; i++) {
|
|
if (itins[i].node.legs.length !== itins2[i].node.legs.length) {
|
|
return false;
|
|
}
|
|
for (let j = 0; j < itins[i].node.legs.length; j++) {
|
|
if (
|
|
!isEqual(
|
|
pick(itins[i].node.legs[j], legProperties),
|
|
pick(itins2[i].node.legs[j], legProperties),
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function filterItinerariesByFeedId(plan, config) {
|
|
if (!plan?.edges) {
|
|
return plan;
|
|
}
|
|
const newEdges = [];
|
|
plan.edges.forEach(edge => {
|
|
let skip = false;
|
|
for (let i = 0; i < edge.node.legs.length; i++) {
|
|
const feedId = edge.node.legs[i].route?.gtfsId?.split(':')[0];
|
|
|
|
if (
|
|
feedId && // if feedId is undefined, leg is non transit -> don't drop
|
|
!config.feedIds.includes(feedId) // feedId is not allowed
|
|
) {
|
|
skip = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!skip) {
|
|
newEdges.push(edge);
|
|
}
|
|
});
|
|
return { ...plan, edges: newEdges };
|
|
}
|
|
|
|
const settingsToCompare = ['walkBoardCost', 'ticketTypes', 'walkReluctance'];
|
|
export function settingsLimitRouting(config) {
|
|
const defaultSettings = getDefaultSettings(config);
|
|
const currentSettings = getSettings(config);
|
|
const defaultSettingsToCompare = pick(defaultSettings, settingsToCompare);
|
|
const currentSettingsToCompare = pick(currentSettings, settingsToCompare);
|
|
|
|
return !(
|
|
isEqual(defaultSettingsToCompare, currentSettingsToCompare) &&
|
|
defaultSettings.modes.every(m => currentSettings.modes.includes(m))
|
|
);
|
|
}
|
|
|
|
export function setCurrentTimeToURL(config, match) {
|
|
if (config.NODE_ENV !== 'test' && !match.location?.query?.time) {
|
|
const newLocation = {
|
|
...match.location,
|
|
query: {
|
|
...match.location.query,
|
|
time: Math.floor(Date.now() / 1000).toString(),
|
|
},
|
|
};
|
|
match.router.replace(newLocation);
|
|
}
|
|
}
|
|
|
|
function configClient(itineraryTopics, config) {
|
|
const { realTime } = config;
|
|
const feedIds = Array.from(
|
|
new Set(itineraryTopics.map(topic => topic.feedId)),
|
|
);
|
|
let feedId;
|
|
/* handle multiple feedid case */
|
|
feedIds.forEach(fId => {
|
|
if (!feedId && realTime[fId]) {
|
|
feedId = fId;
|
|
}
|
|
});
|
|
const source = feedId && realTime[feedId];
|
|
if (source && source.active) {
|
|
return {
|
|
...source,
|
|
feedId,
|
|
options: itineraryTopics.length ? itineraryTopics : null,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function stopClient(context) {
|
|
const { client } = context.getStore('RealTimeInformationStore');
|
|
if (client) {
|
|
context.executeAction(stopRealTimeClient, client);
|
|
}
|
|
}
|
|
|
|
export function startClient(itineraryTopics, context) {
|
|
if (!isEmpty(itineraryTopics)) {
|
|
const clientConfig = configClient(itineraryTopics, context.config);
|
|
context.executeAction(startRealTimeClient, clientConfig);
|
|
}
|
|
}
|
|
|
|
export function updateClient(itineraryTopics, context) {
|
|
const { client, topics } = context.getStore('RealTimeInformationStore');
|
|
|
|
if (isEmpty(itineraryTopics)) {
|
|
stopClient(context);
|
|
return;
|
|
}
|
|
if (client) {
|
|
const clientConfig = configClient(itineraryTopics, context.config);
|
|
if (clientConfig) {
|
|
context.executeAction(changeRealTimeClientTopics, {
|
|
...clientConfig,
|
|
client,
|
|
oldTopics: topics,
|
|
});
|
|
return;
|
|
}
|
|
stopClient(context);
|
|
}
|
|
startClient(itineraryTopics, context);
|
|
}
|
|
|
|
export function addBikeStationMapForRentalVehicleItineraries() {
|
|
return getMapLayerOptions({
|
|
lockedMapLayers: ['vehicles', 'citybike', 'stop'],
|
|
selectedMapLayers: ['vehicles', 'citybike'],
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Return an object containing key vehicleRentalStations that is a list of rental
|
|
* station ids to hide on map.
|
|
*
|
|
* @param {Boolean} hasVehicleRentalStation if there are rental stations.
|
|
* @param {*} selectedItinerary itinerary which can contain rental stations.
|
|
*/
|
|
export function getRentalStationsToHideOnMap(
|
|
hasVehicleRentalStation,
|
|
selectedItinerary,
|
|
) {
|
|
const objectsToHide = { vehicleRentalStations: [] };
|
|
if (hasVehicleRentalStation) {
|
|
objectsToHide.vehicleRentalStations = selectedItinerary?.legs
|
|
?.filter(leg => leg.from.vehicleRentalStation)
|
|
.map(station => station.from?.vehicleRentalStation.stationId);
|
|
}
|
|
return objectsToHide;
|
|
}
|
|
|
|
// These are icons that contains sun
|
|
const dayNightIconIds = [1, 2, 21, 22, 23, 41, 42, 43, 61, 62, 71, 72, 73];
|
|
|
|
export function checkDayNight(iconId, time, lat, lon) {
|
|
const date = new Date(time);
|
|
const sunCalcTimes = SunCalc.getTimes(date, lat, lon);
|
|
const sunrise = sunCalcTimes.sunrise.getTime();
|
|
const sunset = sunCalcTimes.sunset.getTime();
|
|
if ((sunrise > time || sunset < time) && dayNightIconIds.includes(iconId)) {
|
|
// Night icon = iconId + 100
|
|
return iconId + 100;
|
|
}
|
|
return iconId;
|
|
}
|
|
|
|
const STREET_LEG_MODES = ['WALK', 'BICYCLE', 'CAR', 'SCOOTER'];
|
|
|
|
/**
|
|
* Filters away itineraries that don't use transit
|
|
*/
|
|
export function transitEdges(edges) {
|
|
if (!edges) {
|
|
return [];
|
|
}
|
|
return edges.filter(
|
|
edge => !edge.node.legs.every(leg => STREET_LEG_MODES.includes(leg.mode)),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Filters away itineraries that
|
|
* 1. don't use scooters
|
|
* 2. only use scooters (unless allowed by allowDirectScooterJourneys)
|
|
* 3. use scooters that are not vehicles
|
|
*/
|
|
export function scooterEdges(edges, allowDirectScooterJourneys) {
|
|
if (!edges) {
|
|
return [];
|
|
}
|
|
|
|
const filteredEdges = [];
|
|
|
|
edges.forEach(edge => {
|
|
let hasScooterLeg = false;
|
|
let hasNonScooterLeg = false;
|
|
let allScooterLegsHaveRentalVehicle = true;
|
|
|
|
edge.node.legs.forEach(leg => {
|
|
if (leg.mode === 'SCOOTER' && leg.from.rentalVehicle) {
|
|
hasScooterLeg = true;
|
|
} else if (leg.mode !== 'SCOOTER' && leg.mode !== 'WALK') {
|
|
hasNonScooterLeg = true;
|
|
}
|
|
|
|
if (leg.mode === 'SCOOTER' && !leg.from.rentalVehicle) {
|
|
allScooterLegsHaveRentalVehicle = false;
|
|
}
|
|
});
|
|
|
|
if (
|
|
hasScooterLeg &&
|
|
allScooterLegsHaveRentalVehicle &&
|
|
(hasNonScooterLeg || allowDirectScooterJourneys)
|
|
) {
|
|
filteredEdges.push(edge);
|
|
}
|
|
});
|
|
|
|
return filteredEdges;
|
|
}
|
|
|
|
/**
|
|
* Filters away plain walk
|
|
*/
|
|
export function filterWalk(edges) {
|
|
if (!edges) {
|
|
return [];
|
|
}
|
|
return edges.filter(
|
|
edge => !edge.node.legs.every(leg => leg.mode === 'WALK'),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Filters itineraries that don't use given mode
|
|
*/
|
|
export function filterItineraries(edges, modes) {
|
|
if (!edges) {
|
|
return [];
|
|
}
|
|
return edges.filter(edge =>
|
|
edge.node.legs.some(leg => modes.includes(leg.mode)),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Filters itineraries that are not the right route type
|
|
*/
|
|
export function filterItinerariesByRouteType(edges, types) {
|
|
if (!edges) {
|
|
return [];
|
|
}
|
|
return edges.filter(edge =>
|
|
edge.node.legs.some(leg => types.includes(leg.route?.type)),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Pick combination of itineraries for bike and transit
|
|
*/
|
|
export function mergeBikeTransitPlans(bikeParkPlan, bikeTransitPlan) {
|
|
// filter plain walking / biking away, and also no biking
|
|
const bikeParkEdges = transitEdges(bikeParkPlan?.edges).filter(
|
|
i => getTotalBikingDistance(i.node) > 0,
|
|
);
|
|
const bikePublicEdges = transitEdges(bikeTransitPlan?.edges).filter(
|
|
i => getTotalBikingDistance(i.node) > 0,
|
|
);
|
|
|
|
// show 6 bike + transit itineraries, preferably 3 of both kind.
|
|
// If there is not enough of a kind, take more from the other kind
|
|
let n1 = bikeParkEdges.length;
|
|
let n2 = bikePublicEdges.length;
|
|
if (n1 < 3) {
|
|
n2 = Math.min(6 - n1, n2);
|
|
} else if (n2 < 3) {
|
|
n1 = Math.min(6 - n2, n1);
|
|
} else {
|
|
n1 = 3;
|
|
n2 = 3;
|
|
}
|
|
return {
|
|
searchDateTime: bikeParkPlan.searchDateTime,
|
|
edges: [...bikeParkEdges.slice(0, n1), ...bikePublicEdges.slice(0, 3)].map(
|
|
edge => {
|
|
return {
|
|
...edge,
|
|
node: {
|
|
...edge.node,
|
|
legs: compressLegs(edge.node.legs),
|
|
},
|
|
};
|
|
},
|
|
),
|
|
bikeParkItineraryCount: n1,
|
|
bikePublicItineraryCount: n2,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Parse the car transit plan which possibly includes a direct route.
|
|
*/
|
|
export function parseCarTransitPlan(carTransitPlan) {
|
|
let carEdges = carTransitPlan?.edges || [];
|
|
let carDirectItineraryCount = 0;
|
|
let carPublicItineraryCount = carEdges.length;
|
|
const maxCarPublicCount = 5;
|
|
|
|
if (carEdges.length > 0) {
|
|
// If the first route is a direct route.
|
|
if (carEdges?.[0]?.node.legs.every(leg => !leg.transitLeg)) {
|
|
carDirectItineraryCount = 1;
|
|
carPublicItineraryCount -= 1;
|
|
} else if (carEdges.length > maxCarPublicCount) {
|
|
carPublicItineraryCount = maxCarPublicCount;
|
|
carEdges = carEdges.slice(0, maxCarPublicCount);
|
|
}
|
|
}
|
|
|
|
return {
|
|
searchDateTime: carTransitPlan.searchDateTime,
|
|
edges: carEdges,
|
|
carDirectItineraryCount,
|
|
carPublicItineraryCount,
|
|
};
|
|
}
|
|
|
|
export function getSortedEdges(edges, arriveBy) {
|
|
const sortedEdges = [...edges];
|
|
sortedEdges.sort((a, b) => {
|
|
if (a.node.end === b.node.end) {
|
|
return 0;
|
|
}
|
|
if (arriveBy) {
|
|
return b.node.end > a.node.end ? 1 : -1;
|
|
}
|
|
return a.node.end > b.node.end ? 1 : -1;
|
|
});
|
|
return sortedEdges;
|
|
}
|
|
|
|
/**
|
|
* Combine an external edge with the main transit edges.
|
|
*/
|
|
function sortAndMergePlans(
|
|
externalTransitEdges,
|
|
transitPlan,
|
|
arriveBy,
|
|
maxAdditionalEdges = 1,
|
|
) {
|
|
const transitPlanEdges = transitPlan.edges || [];
|
|
const maxTransitEdges =
|
|
externalTransitEdges.length > 0 ? 4 : transitPlanEdges.length;
|
|
|
|
// special case: if transitplan only has one walk itinerary, don't show external plan if it arrives later.
|
|
if (
|
|
transitPlanEdges.length === 1 &&
|
|
transitPlanEdges[0].node.legs.every(leg => leg.mode === 'WALK') &&
|
|
transitPlanEdges[0].node.end < externalTransitEdges[0]?.node.end
|
|
) {
|
|
return transitPlan;
|
|
}
|
|
|
|
return {
|
|
edges: getSortedEdges(
|
|
[
|
|
...externalTransitEdges.slice(0, maxAdditionalEdges),
|
|
...transitPlanEdges.slice(0, maxTransitEdges),
|
|
],
|
|
arriveBy,
|
|
).map(edge => {
|
|
return {
|
|
...edge,
|
|
node: {
|
|
...edge.node,
|
|
legs: compressLegs(edge.node.legs),
|
|
},
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Combine an external edge with the main transit edges.
|
|
*/
|
|
export function mergeExternalTransitPlan(
|
|
externalPlan,
|
|
transitPlan,
|
|
arriveBy,
|
|
allowedFlexRouteTypes,
|
|
) {
|
|
const externalTransitEdges = filterItinerariesByRouteType(
|
|
externalPlan.edges,
|
|
allowedFlexRouteTypes,
|
|
);
|
|
return sortAndMergePlans(externalTransitEdges, transitPlan, arriveBy);
|
|
}
|
|
|
|
/**
|
|
* Combine a scooter edge with the main transit edges.
|
|
*/
|
|
export function mergeScooterTransitPlan(
|
|
scooterPlan,
|
|
transitPlan,
|
|
allowDirectScooterJourneys,
|
|
arriveBy,
|
|
) {
|
|
const scooterTransitEdges = scooterEdges(
|
|
scooterPlan.edges,
|
|
allowDirectScooterJourneys,
|
|
);
|
|
return sortAndMergePlans(scooterTransitEdges, transitPlan, arriveBy);
|
|
}
|
|
|
|
/**
|
|
* Combine two sets of external plans.
|
|
*/
|
|
export function sortAndMergeExternalPlans(
|
|
plan,
|
|
planToMerge,
|
|
arriveBy,
|
|
maxTotalEdges = 5,
|
|
) {
|
|
const edges = plan.edges || [];
|
|
const edgesToMerge = planToMerge.edges || [];
|
|
return {
|
|
edges: getSortedEdges([...edges, ...edgesToMerge], arriveBy)
|
|
.slice(0, maxTotalEdges)
|
|
.map(edge => {
|
|
return {
|
|
...edge,
|
|
node: {
|
|
...edge.node,
|
|
legs: compressLegs(edge.node.legs),
|
|
},
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
const ITERATION_CANCEL_TIME = 20000; // ms, stop looking for more if something was found
|
|
|
|
export function quitIteration(plan, newPlan, planParams, startTime) {
|
|
if (
|
|
plan.edges.length >= planParams.numItineraries ||
|
|
(plan.edges.length &&
|
|
plan.edges.length * (Date.now() - startTime) > ITERATION_CANCEL_TIME)
|
|
) {
|
|
return true;
|
|
}
|
|
if (
|
|
plan.routingErrors?.some(
|
|
err => err.code === PlannerMessageType.WalkingBetterThanTransit,
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
if (!plan.edges.length && planParams.noIterationsForShortTrips) {
|
|
// searching short distance, and bike is available
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Enables Navigator assisted journey on itinerary selection if all of the following resolve as true:
|
|
* - a stored itinerary exists
|
|
* - the stored itinerary ends in future
|
|
* - the params stored along itinerary are identical to current URL parameters
|
|
*
|
|
* @param {{itinerary: itineraryShape, params: {from: string, to: string, queryTime: number, arriveBy: boolean, index: string}}} itinerary matchContext with URL params
|
|
* @param {matchShape} match matchContext with URL params
|
|
* @returns true if Navigator can be initialized with stored itinerary
|
|
*/
|
|
export const isStoredItineraryRelevant = ({ itinerary, params }, match) => {
|
|
if (!itinerary || !params) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
Date.parse(itinerary.end) > Date.now() &&
|
|
params.from === match.params.from &&
|
|
params.to === match.params.to &&
|
|
params.queryTime === match.location?.query?.time &&
|
|
params.arriveBy === match.location?.query?.arriveBy &&
|
|
params.hash === match.params.hash &&
|
|
params.secondHash === match.params.secondHash
|
|
);
|
|
};
|