digitransit-ui/app/component/itinerary/ItineraryPage.js

1621 lines
48 KiB
JavaScript

/* eslint-disable react/no-array-index-key */
/* eslint-disable no-nested-ternary */
import { matchShape, routerShape } from 'found';
import isEmpty from 'lodash/isEmpty';
import isEqual from 'lodash/isEqual';
import polyline from 'polyline-encoded';
import PropTypes from 'prop-types';
import React, { cloneElement, useEffect, useRef, useState } from 'react';
import { FormattedMessage, intlShape } from 'react-intl';
import { fetchQuery } from 'react-relay';
import { saveFutureRoute } from '../../action/FutureRoutesActions';
import { startLocationWatch } from '../../action/PositionActions';
import { saveSearch } from '../../action/SearchActions';
import { TransportMode } from '../../constants';
import { mapLayerShape } from '../../store/MapLayerStore';
import {
clearLatestNavigatorItinerary,
getDialogState,
getGeolocationState,
getLatestNavigatorItinerary,
setDialogState,
} from '../../store/localStorage';
import { addAnalyticsEvent } from '../../util/analyticsUtils';
import { getWeatherData } from '../../util/apiUtils';
import { isIOS } from '../../util/browser';
import { boundWithMinimumArea, GeodeticToEcef } from '../../util/geo-utils';
import {
getIntermediatePlaces,
otpToLocation,
parseLatLon,
} from '../../util/otpStrings';
import { getItineraryPagePath, streetHash } from '../../util/path';
import {
getPlanParams,
getSettings,
planQueryNeeded,
PLANTYPE,
} from '../../util/planParamUtil';
import {
configShape,
mapLayerOptionsShape,
relayShape,
} from '../../util/shapes';
import { epochToTime } from '../../util/timeUtils';
import { getAllNetworksOfType } from '../../util/vehicleRentalUtils';
import DesktopView from '../DesktopView';
import Loading from '../Loading';
import MobileView from '../MobileView';
import ItineraryPageMap from '../map/ItineraryPageMap';
import AlternativeItineraryBar from './AlternativeItineraryBar';
import CustomizeSearch from './CustomizeSearch';
import { spinnerPosition } from './ItineraryList';
import ItineraryListContainer from './ItineraryListContainer';
import ItineraryPageControls from './ItineraryPageControls';
import {
addBikeStationMapForRentalVehicleItineraries,
checkDayNight,
filterItinerariesByFeedId,
filterItinerariesByRouteType,
filterWalk,
getBounds,
getRentalStationsToHideOnMap,
getSelectedItineraryIndex,
getSortedEdges,
getTopics,
isEqualItineraries,
isStoredItineraryRelevant,
mergeBikeTransitPlans,
mergeExternalTransitPlan,
mergeScooterTransitPlan,
parseCarTransitPlan,
quitIteration,
reportError,
scooterEdges,
setCurrentTimeToURL,
settingsLimitRouting,
sortAndMergeExternalPlans,
stopClient,
updateClient,
} from './ItineraryPageUtils';
import ItineraryTabs from './ItineraryTabs';
import { useItineraryContext } from './context/ItineraryContext';
import { REDUCER_ACTION_TYPES } from './context/useItineraryReducer';
import NaviContainer from './navigator/NaviContainer';
import NaviGeolocationInfoModal from './navigator/navigatorgeolocation/NaviGeolocationInfoModal';
import NavigatorIntroModal from './navigator/navigatorintro/NavigatorIntroModal';
import { planConnection } from './queries/PlanConnection';
const MAX_QUERY_COUNT = 4; // number of attempts to collect enough itineraries
const streetHashes = [
streetHash.walk,
streetHash.bike,
streetHash.bikeAndVehicle,
streetHash.car,
streetHash.carAndVehicle,
streetHash.parkAndRide,
];
const altTransitHash = [
streetHash.bikeAndVehicle,
streetHash.carAndVehicle,
streetHash.parkAndRide,
];
const noTransitHash = [streetHash.walk, streetHash.bike, streetHash.car];
const LOADSTATE = {
UNSET: 'unset',
LOADING: 'loading',
DONE: 'done',
};
const showVehiclesThresholdMinutes = 720;
const emptyState = {
earlierEdges: [],
laterEdges: [],
plan: {},
separatorPosition: undefined,
routingFeedbackPosition: undefined,
error: undefined,
topNote: undefined,
bottomNote: undefined,
loading: LOADSTATE.DONE,
startCursor: undefined,
endCursor: undefined,
};
const emptyPlan = { plan: {}, loading: LOADSTATE.DONE };
const unset = { plan: {}, loading: LOADSTATE.UNSET };
const noFocus = { center: undefined, zoom: undefined, bounds: undefined };
export default function ItineraryPage(props, context) {
const headerRef = useRef(null);
const mwtRef = useRef();
const mobileRef = useRef();
const ariaRef = useRef('summary-page.title');
const mapLayerRef = useRef();
const [state, setState] = useState({
...emptyState,
loading: LOADSTATE.UNSET,
});
const [relaxState, setRelaxState] = useState(emptyPlan);
const [relaxScooterState, setRelaxScooterState] = useState(emptyPlan);
const [relaxFlexState, setRelaxFlexState] = useState(emptyPlan);
const [combinedRelaxState, setCombinedRelaxState] = useState(emptyPlan);
const [scooterState, setScooterState] = useState(unset);
const [combinedState, setCombinedState] = useState(emptyPlan);
const [flexState, setFlexState] = useState(unset);
const [isNavigatorIntroDismissed, setNavigatorIntroDismissed] = useState(
getDialogState('navi-intro'),
);
const [locationPermissionsLoadState, setLocationPermissionsLoadState] =
useState(LOADSTATE.UNSET);
const [isGeolocationInfoOpen, setGeolocationInfoOpen] = useState(false);
const altStates = {
[PLANTYPE.WALK]: useState(unset),
[PLANTYPE.BIKE]: useState(unset),
[PLANTYPE.CAR]: useState(unset),
[PLANTYPE.BIKEPARK]: useState(unset),
[PLANTYPE.BIKETRANSIT]: useState(unset),
[PLANTYPE.PARKANDRIDE]: useState(unset),
[PLANTYPE.CARTRANSIT]: useState(unset),
};
// combination of bikePark and bikeTransit
const [bikePublicState, setBikePublicState] = useState({ plan: {} });
// combination of direct car routing and cars with transit
const [carPublicState, setCarPublicState] = useState({ plan: {} });
const [settingsState, setSettingsState] = useState({
settingsOpen: false,
settingsChanged: 0,
});
const [weatherState, setWeatherState] = useState({ loading: false });
const [topicsState, setTopicsState] = useState(null);
const [mapState, setMapState] = useState({});
const [naviMode, setNaviMode] = useState(false);
const itineraryContext = useItineraryContext();
const { config, router, executeAction } = context;
const { match, breakpoint } = props;
const { params, location } = match;
const { hash, secondHash } = params;
const { query } = location;
const detailView = altTransitHash.includes(hash) ? secondHash : hash;
function altLoading() {
return Object.values(altStates).some(
st => st[0].loading === LOADSTATE.LOADING,
);
}
function altLoadingDone() {
return Object.values(altStates).every(
st => st[0].loading === LOADSTATE.DONE,
);
}
function stopClientAndUpdateTopics() {
stopClient(context);
setTopicsState(null);
}
const selectStreetMode = newStreetMode => {
addAnalyticsEvent({
category: 'Itinerary',
action: 'OpenItineraryDetailsWithMode',
name: newStreetMode,
});
const newLocationState = {
...location,
state: {
...location.state,
selectedItineraryIndex: 0,
},
};
const basePath = getItineraryPagePath(params.from, params.to);
let pagePath = basePath;
if (newStreetMode) {
pagePath = `${pagePath}/${newStreetMode}`;
}
newLocationState.pathname = basePath;
router.replace(newLocationState);
newLocationState.pathname = pagePath;
router.push(newLocationState);
};
const resetItineraryPageSelection = () => {
if (location.state?.selectedItineraryIndex) {
router.replace({
...location,
state: {
...location.state,
selectedItineraryIndex: 0,
},
});
}
};
function mapHashToPlan() {
switch (hash) {
case streetHash.walk:
return altStates[PLANTYPE.WALK][0].plan;
case streetHash.bike:
return altStates[PLANTYPE.BIKE][0].plan;
case streetHash.car:
return altStates[PLANTYPE.CAR][0].plan;
case streetHash.bikeAndVehicle:
return bikePublicState.plan;
case streetHash.carAndVehicle:
return carPublicState.plan;
case streetHash.parkAndRide:
return altStates[PLANTYPE.PARKANDRIDE][0].plan;
default:
if (
!filterWalk(combinedState.plan?.edges).length &&
!settingsState.settingsChanged
) {
// Note: plan and scooter plan are merged, but relaxed ones are not
// because a relaxed scooter search is performed separately
// and shown only if basic relaxed search finds no journeys.
if (relaxState.plan?.edges?.length > 0) {
return relaxState.plan;
}
if (combinedRelaxState.plan?.edges?.length > 0) {
return combinedRelaxState.plan;
}
}
return combinedState.plan;
}
}
function makeWeatherQuery() {
const from = otpToLocation(params.from);
const time = query.time ? query.time * 1000 : Date.now();
setWeatherState({ ...weatherState, loading: true });
const newState = { loading: false, weatherData: undefined };
getWeatherData(config.URL.WEATHER_DATA, time, from.lat, from.lon)
.then(res => {
if (Array.isArray(res) && res.length === 3) {
const temperature = Number(res[0].ParameterValue);
const windSpeed = Number(res[1].ParameterValue);
const iconIndex = parseInt(res[2].ParameterValue, 10);
if (
!Number.isNaN(temperature) &&
!Number.isNaN(windSpeed) &&
!Number.isNaN(iconIndex)
) {
newState.weatherData = {
temperature,
windSpeed,
// Icon spec: www.ilmatieteenlaitos.fi/latauspalvelun-pikaohje -> Sääsymbolien selitykset ennusteissa
iconId: checkDayNight(iconIndex, time, from.lat, from.lon),
time: epochToTime(time, config),
};
}
}
setWeatherState(newState);
})
.catch(() => {
setWeatherState(newState);
});
}
async function iterateQuery(planParams, reps) {
let plan;
const trials = reps || (planParams.modes.directOnly ? 1 : MAX_QUERY_COUNT);
const arriveBy = !!planParams.datetime.latestArrival;
const startTime = Date.now();
for (let i = 0; i < trials; i++) {
// eslint-disable-next-line no-await-in-loop
const result = await fetchQuery(
props.relayEnvironment,
planConnection,
planParams,
{
force: true,
},
).toPromise();
if (!plan) {
plan = result.plan;
} else if (arriveBy) {
plan = {
...plan,
pageInfo: {
...plan.pageInfo,
startCursor: result.plan.pageInfo.startCursor,
},
edges: plan.edges.concat(result.plan.edges),
};
} else {
plan = {
...plan,
pageInfo: {
...plan.pageInfo,
endCursor: result.plan.pageInfo.endCursor,
},
edges: plan.edges.concat(result.plan.edges),
};
}
if (quitIteration(plan, result.plan, planParams, startTime)) {
break;
}
if (arriveBy) {
if (!plan.pageInfo.startCursor) {
break;
}
planParams.before = plan.pageInfo.startCursor; // eslint-disable-line no-param-reassign
planParams.last = planParams.numItineraries - plan.edges.length; // eslint-disable-line no-param-reassign
} else {
if (!plan.pageInfo.endCursor) {
break;
}
planParams.after = plan.pageInfo.endCursor; // eslint-disable-line no-param-reassign
planParams.first = planParams.numItineraries - plan.edges.length; // eslint-disable-line no-param-reassign
}
}
return plan;
}
async function makeAltQuery(planType) {
const altState = altStates[planType];
if (!planQueryNeeded(config, match, planType)) {
altState[1]({ plan: {}, loading: LOADSTATE.DONE });
return;
}
altState[1]({ loading: LOADSTATE.LOADING });
const planParams = getPlanParams(config, match, planType);
try {
const plan = await iterateQuery(
planParams,
planParams.maxQueryIterations,
);
altState[1]({ plan, loading: LOADSTATE.DONE });
} catch (error) {
altState[1]({ plan: {}, loading: LOADSTATE.DONE });
}
}
async function makeRelaxedQuery() {
if (!planQueryNeeded(config, match, PLANTYPE.TRANSIT, true)) {
setRelaxState(emptyPlan);
return;
}
setRelaxState({ loading: LOADSTATE.LOADING });
const planParams = getPlanParams(config, match, PLANTYPE.TRANSIT, true);
try {
const plan = await iterateQuery(
planParams,
planParams.maxQueryIterations,
);
setRelaxState({ plan, loading: LOADSTATE.DONE });
} catch (error) {
setRelaxState(emptyPlan);
}
}
async function makeMainQuery() {
if (!planQueryNeeded(config, match, PLANTYPE.TRANSIT)) {
setState(emptyState);
return;
}
ariaRef.current = 'itinerary-page.loading-itineraries';
setState({ ...emptyState, loading: LOADSTATE.LOADING });
const planParams = getPlanParams(config, match, PLANTYPE.TRANSIT);
try {
const plan = await iterateQuery(
planParams,
planParams.maxQueryIterations,
);
setState({ ...emptyState, plan, loading: LOADSTATE.DONE });
ariaRef.current = 'itinerary-page.itineraries-loaded';
} catch (error) {
reportError(error);
setState(emptyPlan);
}
}
async function makeScooterQuery() {
if (!planQueryNeeded(config, match, PLANTYPE.SCOOTERTRANSIT)) {
setScooterState(emptyPlan);
return;
}
setScooterState({ loading: LOADSTATE.LOADING });
const planParams = getPlanParams(
config,
match,
PLANTYPE.SCOOTERTRANSIT,
false, // no relaxed settings
);
try {
const plan = await iterateQuery(
planParams,
planParams.maxQueryIterations,
);
setScooterState({ plan, loading: LOADSTATE.DONE });
} catch (error) {
reportError(error);
setScooterState(emptyPlan);
}
}
async function makeRelaxedScooterQuery() {
if (!planQueryNeeded(config, match, PLANTYPE.SCOOTERTRANSIT, true)) {
setRelaxScooterState(emptyPlan);
return;
}
setRelaxScooterState({ loading: LOADSTATE.LOADING });
const allScooterNetworks = getAllNetworksOfType(
context.config,
TransportMode.Scooter,
);
const planParams = getPlanParams(
config,
match,
PLANTYPE.SCOOTERTRANSIT,
true, // force relaxed settings
);
const tunedParams = {
...planParams,
allowedRentalNetworks: allScooterNetworks,
};
try {
const plan = await iterateQuery(
tunedParams,
tunedParams.maxQueryIterations,
);
const scooterPlan = { edges: scooterEdges(plan.edges) };
setRelaxScooterState({ plan: scooterPlan, loading: LOADSTATE.DONE });
} catch (error) {
setRelaxScooterState(emptyPlan);
}
}
async function makeFlexQuery() {
if (!planQueryNeeded(config, match, PLANTYPE.FLEXTRANSIT)) {
setFlexState(emptyPlan);
return;
}
setFlexState({ loading: LOADSTATE.LOADING });
const planParams = getPlanParams(
config,
match,
PLANTYPE.FLEXTRANSIT,
false, // no relaxed settings
);
try {
const plan = await iterateQuery(planParams);
setFlexState({ plan, loading: LOADSTATE.DONE });
} catch (error) {
reportError(error);
setFlexState(emptyPlan);
}
}
async function makeRelaxedFlexQuery() {
if (!planQueryNeeded(config, match, PLANTYPE.FLEXTRANSIT, true)) {
setRelaxFlexState(emptyPlan);
return;
}
setRelaxFlexState({ loading: LOADSTATE.LOADING });
const planParams = getPlanParams(
config,
match,
PLANTYPE.FLEXTRANSIT,
true, // force relaxed settings
);
const tunedParams = {
...planParams,
};
try {
const plan = await iterateQuery(
tunedParams,
tunedParams.maxQueryIterations,
);
const flexPlan = {
edges: filterItinerariesByRouteType(
plan.edges,
config.allowedFlexRouteTypes,
),
};
setRelaxFlexState({ plan: flexPlan, loading: LOADSTATE.DONE });
} catch (error) {
setRelaxFlexState(emptyPlan);
}
}
const onLater = async () => {
addAnalyticsEvent({
event: 'sendMatomoEvent',
category: 'Itinerary',
action: 'ShowLaterItineraries',
name: null,
});
const relaxed =
filterWalk(state.plan?.edges).length === 0 &&
relaxState.plan?.edges?.length > 0;
const origPlan = relaxed ? relaxState.plan : state.plan;
const planParams = getPlanParams(config, match, PLANTYPE.TRANSIT, relaxed);
const arriveBy = !!planParams.datetime.latestArrival;
planParams.after = state.endCursor || origPlan.pageInfo.endCursor;
if (!planParams.after) {
const newState = arriveBy
? { topNote: 'no-more-route-msg' }
: { bottomNote: 'no-more-route-msg' };
setState({ ...state, ...newState, loadingMore: undefined });
return;
}
planParams.transitOnly = true;
setState({
...state,
loadingMore: arriveBy ? spinnerPosition.top : spinnerPosition.bottom,
});
ariaRef.current = 'itinerary-page.loading-itineraries';
let plan;
try {
plan = await iterateQuery(planParams, 1);
} catch (error) {
setState({ ...state, loadingMore: undefined });
return;
}
const edges = getSortedEdges(plan.edges, arriveBy);
if (edges.length === 0) {
const newState = arriveBy
? { topNote: 'no-more-route-msg' }
: { bottomNote: 'no-more-route-msg' };
setState({ ...state, ...newState, loadingMore: undefined });
return;
}
ariaRef.current = 'itinerary-page.itineraries-loaded';
const newState = {
...state,
loadingMore: undefined,
endCursor: plan.pageInfo.endCursor,
};
// place separators. First click sets feedback button to place
// where user clicked before/after button. Further clicks above the itinerary list
// set a separator line there and clicks below the list move feedback button down
if (arriveBy) {
// user clicked button above itinerary list
const separators = state.routingFeedbackPosition
? {
separatorPosition: edges.length,
routingFeedbackPosition:
state.routingFeedbackPosition + edges.length,
}
: { routingFeedbackPosition: edges.length };
setState({
...newState,
...separators,
earlierEdges: [...edges, ...state.earlierEdges],
});
} else {
// user clicked button below itinerary list
setState({
...newState,
routingFeedbackPosition:
origPlan.edges.length +
state.earlierEdges.length +
state.laterEdges.length,
laterEdges: [...state.laterEdges, ...edges],
});
}
if (arriveBy) {
resetItineraryPageSelection();
}
};
const onEarlier = async () => {
addAnalyticsEvent({
event: 'sendMatomoEvent',
category: 'Itinerary',
action: 'ShowLaterItineraries',
name: null,
});
const relaxed =
filterWalk(state.plan?.edges).length === 0 &&
relaxState.plan?.edges?.length > 0;
const origPlan = relaxed ? relaxState.plan : state.plan;
const planParams = getPlanParams(config, match, PLANTYPE.TRANSIT, relaxed);
const arriveBy = !!planParams.datetime.latestArrival;
planParams.before = state.startCursor || origPlan.pageInfo.startCursor;
if (!planParams.before) {
const newState = arriveBy
? { bottomNote: 'no-more-route-msg' }
: { topNote: 'no-more-route-msg' };
setState({ ...state, ...newState, loadingMore: undefined });
return;
}
planParams.last = planParams.numItineraries;
planParams.transitOnly = true;
setState({
...state,
loadingMore: arriveBy ? spinnerPosition.bottom : spinnerPosition.top,
});
ariaRef.current = 'itinerary-page.loading-itineraries';
let plan;
try {
plan = await iterateQuery(planParams, 1);
} catch (error) {
setState({ ...state, loadingMore: undefined });
return;
}
const edges = getSortedEdges(plan.edges, arriveBy);
if (edges.length === 0) {
const newState = arriveBy
? { bottomNote: 'no-more-route-msg' }
: { topNote: 'no-more-route-msg' };
setState({ ...state, ...newState, loadingMore: undefined });
return;
}
ariaRef.current = 'itinerary-page.itineraries-loaded';
const newState = {
...state,
loadingMore: undefined,
startCursor: plan.pageInfo.startCursor,
};
if (arriveBy) {
// user clicked button below itinerary list
setState({
...newState,
routingFeedbackPosition:
origPlan.edges.length +
state.earlierEdges.length +
state.laterEdges.length,
laterEdges: [...state.laterEdges, ...edges],
});
} else {
// user clicked button above itinerary list
const separators = state.routingFeedbackPosition
? {
separatorPosition: edges.length,
routingFeedbackPosition:
state.routingFeedbackPosition + edges.length,
}
: { routingFeedbackPosition: edges.length };
setState({
...newState,
...separators,
earlierEdges: [...edges, ...state.earlierEdges],
});
}
if (!arriveBy) {
resetItineraryPageSelection();
}
};
// make the map to obey external navigation
function navigateMap() {
// map sticks to user location if tracking is on, so set it off
if (mwtRef.current?.disableMapTracking) {
mwtRef.current.disableMapTracking();
}
// map will not react to location props unless they change or update is forced
if (mwtRef.current?.forceRefresh) {
mwtRef.current.forceRefresh();
}
}
const getCombinedPlanEdges = () => {
return [
...(state.earlierEdges || []),
...(mapHashToPlan()?.edges || []),
...(state.laterEdges || []),
];
};
const getItinerarySelection = () => {
const hasNoTransitItineraries = filterWalk(state.plan?.edges).length === 0;
const plan = mapHashToPlan();
let combinedEdges;
// Remove old itineraries if new query cannot find a route
if (state.error) {
combinedEdges = [];
} else if (streetHashes.includes(hash)) {
combinedEdges = plan?.edges || [];
} else {
combinedEdges = getCombinedPlanEdges();
if (!hasNoTransitItineraries) {
// don't show plain walking in transit itinerary list
combinedEdges = filterWalk(combinedEdges);
}
}
const selectedIndex = getSelectedItineraryIndex(location, combinedEdges);
return { plan, combinedEdges, selectedIndex, hasNoTransitItineraries };
};
const setNavigation = isEnabled => {
if (mobileRef.current) {
setTimeout(
() => mobileRef.current.setBottomSheet(isEnabled ? 'bottom' : 'middle'),
10,
);
}
if (!isEnabled) {
setMapState(noFocus);
navigateMap();
clearLatestNavigatorItinerary();
}
setNaviMode(isEnabled);
};
/**
* Watch the location permission state and trigger the navigator view
* once the permission check has finished or timed out.
*/
useEffect(() => {
let interval;
if (locationPermissionsLoadState === LOADSTATE.LOADING) {
const startTime = Date.now();
interval = setInterval(() => {
const geolocationState = getGeolocationState();
if (geolocationState !== 'prompt' || Date.now() - startTime > 30000) {
clearInterval(interval);
setLocationPermissionsLoadState(LOADSTATE.DONE);
}
}, 1000);
}
return () => clearInterval(interval);
}, [locationPermissionsLoadState]);
/**
* Show the navigator view when permissions are resolved and the navigator intro is dismissed,
*/
useEffect(() => {
if (
locationPermissionsLoadState === LOADSTATE.DONE &&
isNavigatorIntroDismissed &&
getLatestNavigatorItinerary()
) {
setNavigation(true);
}
}, [locationPermissionsLoadState, isNavigatorIntroDismissed]);
const startNavigation = itinerary => {
const itineraryWithParams = {
itinerary,
params: {
from: params.from,
to: params.to,
arriveBy: query.arriveBy,
queryTime: query.time,
hash,
secondHash,
updatedAt: Date.now(),
origin: GeodeticToEcef(
itinerary.legs[0].from.lat,
itinerary.legs[0].from.lon,
),
},
};
itineraryContext.dispatch({
payload: itineraryWithParams,
type: REDUCER_ACTION_TYPES.SET_ITINERARY_LEGS_AND_PARAMS,
});
if (
locationPermissionsLoadState === LOADSTATE.DONE ||
!isNavigatorIntroDismissed
) {
// location permission check has already finished or intro view must be shown
setNavigation(true);
} else if (isNavigatorIntroDismissed) {
// trigger location permission check before navigator.
executeAction(startLocationWatch);
setLocationPermissionsLoadState(LOADSTATE.LOADING);
}
};
const startNavigationWithAnalytics = itinerary => {
addAnalyticsEvent({
category: 'Itinerary',
event: 'navigator',
action: 'cta_click',
});
startNavigation(itinerary);
};
// save url-defined location to old searches
function saveUrlSearch(endpoint) {
const parts = endpoint.split('::'); // label::lat,lon
if (parts.length !== 2) {
return;
}
const label = parts[0];
const ll = parseLatLon(parts[1]);
const names = label.split(','); // addr or name, city
if (names.length < 2 || Number.isNaN(ll.lat) || Number.isNaN(ll.lon)) {
return;
}
const layer =
/\d/.test(names[0]) && names[0].indexOf(' ') >= 0 ? 'address' : 'venue';
executeAction(saveSearch, {
item: {
geometry: { coordinates: [ll.lon, ll.lat] },
properties: {
name: names[0],
id: label,
gid: label,
layer,
label,
localadmin: names[names.length - 1],
},
type: 'Feature',
},
type: 'endpoint',
});
}
function updateLocalStorage(saveEndpoints) {
const pathArray = decodeURIComponent(location.pathname)
.substring(1)
.split('/');
// endpoints to oldSearches store
if (saveEndpoints && isIOS && query.save) {
if (query.save === '1' || query.save === '2') {
saveUrlSearch(pathArray[1]); // origin
}
if (query.save === '1' || query.save === '3') {
saveUrlSearch(pathArray[2]); // destination
}
const newLocation = { ...location };
delete newLocation.query.save;
router.replace(newLocation);
}
// update future routes, too
const originArray = pathArray[1].split('::');
const destinationArray = pathArray[2].split('::');
// make sure endpoints are valid locations and time is defined
if (!query.time || originArray.length < 2 || destinationArray.length < 2) {
return;
}
const itinerarySearch = {
origin: {
address: originArray[0],
coordinates: { ...parseLatLon(originArray[1]) },
},
destination: {
address: destinationArray[0],
coordinates: { ...parseLatLon(destinationArray[1]) },
},
...query,
};
executeAction(saveFutureRoute, itinerarySearch);
}
function showVehicles() {
const now = Date.now() / 1000;
const startTime = query.time;
const diff = Math.abs((now - startTime) / 60);
// Vehicles are typically not shown if they are not in transit. But for some quirk in mqtt, if you
// search for a route for example tomorrow, real time vehicle would be shown.
const inRange = diff <= showVehiclesThresholdMinutes;
return !!(
inRange &&
config.showVehiclesOnItineraryPage &&
!noTransitHash.includes(hash) &&
(breakpoint === 'large' || hash)
);
}
useEffect(() => {
setCurrentTimeToURL(config, match);
updateLocalStorage(true);
if (isStoredItineraryRelevant(itineraryContext, match)) {
setNavigation(true);
} else {
clearLatestNavigatorItinerary();
}
return () => {
if (showVehicles()) {
stopClient(context);
}
};
}, []);
useEffect(() => {
setCombinedState({ ...emptyState, loading: LOADSTATE.LOADING });
makeScooterQuery();
makeFlexQuery();
makeMainQuery();
Object.keys(altStates).forEach(key => makeAltQuery(key));
// note: relaxed scooter query is not made unless some modes are disabled
// so, if no itineraries are found with standard settings, scooter is not suggested
// maybe it should be?
if (settingsLimitRouting(config) && !settingsState.settingsChanged) {
makeRelaxedQuery();
makeRelaxedScooterQuery();
makeRelaxedFlexQuery();
}
}, [
settingsState.settingsChanged,
params.from,
params.to,
query.time,
query.arriveBy,
query.intermediatePlaces,
]);
useEffect(() => {
navigateMap();
setMapState(noFocus);
if (detailView) {
// If itinerary is not found in detail view, go back to summary view
if (altLoadingDone() && !mapHashToPlan()?.edges?.length) {
selectStreetMode(); // back to root view
}
} else if (naviMode) {
// turn off tracking when user navigates away from tracking view
setNavigation(false);
}
setTimeout(() => mwtRef.current?.map?.updateZoom(), 1);
}, [hash, secondHash]);
useEffect(() => {
// update stored future searches
updateLocalStorage(false);
}, [location.pathname, query]);
useEffect(() => {
// vehicles on map
if (showVehicles()) {
const { combinedEdges, selectedIndex } = getItinerarySelection();
const selected = combinedEdges.length
? combinedEdges[selectedIndex]
: null;
const itineraryTopics = getTopics(selected?.node.legs, config);
const { client } = context.getStore('RealTimeInformationStore');
// Client may not be initialized yet if there was an client before ComponentDidMount
if (!naviMode && (!isEqual(itineraryTopics, topicsState) || !client)) {
updateClient(itineraryTopics, context);
}
if (!isEqual(itineraryTopics, topicsState) && !naviMode) {
// eslint-disable-next-line react/no-did-update-set-state
setTopicsState(itineraryTopics);
}
} else if (!isEmpty(topicsState)) {
stopClientAndUpdateTopics();
}
}, [
hash,
combinedState.plan,
relaxState.plan,
bikePublicState.plan,
carPublicState.plan,
altStates[PLANTYPE.PARKANDRIDE][0].plan,
location.state?.selectedItineraryIndex,
combinedRelaxState.plan,
naviMode,
]);
useEffect(() => {
if (config.showWeatherInformation) {
makeWeatherQuery();
}
}, [params.from, query.time]);
// merge two separate bike + transit plans into one
useEffect(() => {
if (
altStates[PLANTYPE.BIKEPARK][0].loading === LOADSTATE.DONE &&
altStates[PLANTYPE.BIKETRANSIT][0].loading === LOADSTATE.DONE
) {
const plan = mergeBikeTransitPlans(
altStates[PLANTYPE.BIKEPARK][0].plan,
altStates[PLANTYPE.BIKETRANSIT][0].plan,
);
setBikePublicState({ plan });
}
}, [
altStates[PLANTYPE.BIKEPARK][0].plan,
altStates[PLANTYPE.BIKETRANSIT][0].plan,
]);
// merge direct car and car transit plans into one
useEffect(() => {
const settings = getSettings(config);
if (
altStates[PLANTYPE.CARTRANSIT][0].loading === LOADSTATE.DONE &&
settings.includeCarSuggestions &&
config.carBoardingModes !== undefined
) {
const plan = parseCarTransitPlan(altStates[PLANTYPE.CARTRANSIT][0].plan);
setCarPublicState({ plan });
}
}, [altStates[PLANTYPE.CARTRANSIT][0].plan]);
// merge the main plan, the scooter plan and the flex plan into one
useEffect(() => {
if (
state.loading === LOADSTATE.DONE &&
scooterState.loading === LOADSTATE.DONE &&
flexState.loading === LOADSTATE.DONE
) {
let plan = mergeScooterTransitPlan(
scooterState.plan,
state.plan,
config.vehicleRental.allowDirectScooterJourneys,
match.location.query.arriveBy === 'true',
);
if (flexState.plan?.edges) {
plan = mergeExternalTransitPlan(
flexState.plan,
plan,
match.location.query.arriveBy === 'true',
config.allowedFlexRouteTypes,
);
}
setCombinedState({ plan, loading: LOADSTATE.DONE });
resetItineraryPageSelection();
}
}, [scooterState.plan, state.plan, flexState.plan]);
// merge the relaxed scooter plan and the relaxed flex plan into one
useEffect(() => {
if (
relaxScooterState.loading === LOADSTATE.DONE &&
relaxFlexState.loading === LOADSTATE.DONE
) {
const plan = sortAndMergeExternalPlans(
relaxScooterState.plan,
relaxFlexState.plan,
match.location.query.arriveBy === 'true',
);
setCombinedRelaxState({ plan, loading: LOADSTATE.DONE });
resetItineraryPageSelection();
}
}, [relaxScooterState.plan, relaxFlexState.plan]);
const setMWTRef = ref => {
mwtRef.current = ref;
};
const focusToPoint = (lat, lon) => {
if (mobileRef.current) {
mobileRef.current.setBottomSheet('bottom');
}
navigateMap();
setMapState({ center: { lat, lon }, zoom: 18, bounds: null });
};
const focusToLeg = leg => {
if (mobileRef.current) {
mobileRef.current.setBottomSheet('bottom');
}
navigateMap();
const bounds = boundWithMinimumArea(
[]
.concat(
[
[leg.from.lat, leg.from.lon],
[leg.to.lat, leg.to.lon],
],
polyline.decode(leg.legGeometry.points),
)
.filter(a => a[0] && a[1]),
);
setMapState({ bounds, center: undefined, zoom: undefined });
setTimeout(() => mwtRef.current?.map?.updateZoom(), 1);
};
const changeHash = index => {
const subPath = altTransitHash.includes(hash) ? `/${hash}` : '';
addAnalyticsEvent({
event: 'sendMatomoEvent',
category: 'Itinerary',
action: 'OpenItineraryDetails',
name: index,
});
const newLocationState = {
...location,
state: {
...location.state,
selectedItineraryIndex: index,
},
};
const pagePath = `${getItineraryPagePath(
params.from,
params.to,
)}${subPath}/${index}`;
newLocationState.pathname = pagePath;
router.replace(newLocationState);
};
const showSettingsPanel = (open, changeScooterSettings) => {
addAnalyticsEvent({
event: 'sendMatomoEvent',
category: 'ItinerarySettings',
action: 'ExtraSettingsPanelClick',
name: open ? 'ExtraSettingsPanelOpen' : 'ExtraSettingsPanelClose',
});
if (open) {
setSettingsState({
...settingsState,
settingsOpen: true,
settingsOnOpen: getSettings(config),
changeScooterSettings,
});
if (breakpoint !== 'large') {
router.push({
...location,
state: {
...location.state,
itinerarySettingsOpen: true,
},
});
}
return;
}
if (
settingsState.changeScooterSettings &&
settingsState.settingsOnOpen.scooterNetworks.length <
getSettings(config).scooterNetworks.length
) {
addAnalyticsEvent({
category: 'ItinerarySettings',
action: 'SettingsEnableScooterNetwork',
name: 'AfterOnlyScooterRoutesFound',
});
}
const settingsChanged = !isEqual(
settingsState.settingsOnOpen,
getSettings(config),
)
? settingsState.settingsChanged + 1
: settingsState.settingsChanged;
setSettingsState({
...settingsState,
settingsOpen: false,
settingsChanged,
changeScooterSettings: false,
});
if (settingsChanged && detailView) {
// Ensures returning to the list view after changing the settings in detail view.
selectStreetMode();
}
if (breakpoint !== 'large') {
router.go(-1);
}
};
const toggleSettings = () => {
showSettingsPanel(!settingsState.settingsOpen);
};
const focusToHeader = () => {
setTimeout(() => {
if (headerRef.current) {
headerRef.current.focus();
}
}, 500);
};
function renderMap(from, to, viaPoints, planEdges, activeIndex) {
const mwtProps = {};
if (mapState.bounds) {
mwtProps.bounds = mapState.bounds;
} else if (mapState.center) {
mwtProps.lat = mapState.center.lat;
mwtProps.lon = mapState.center.lon;
mwtProps.zoom = mapState.zoom;
} else {
mwtProps.bounds = getBounds(planEdges, from, to, viaPoints);
}
const itineraryContainsDepartureFromVehicleRentalStation = planEdges?.[
activeIndex
]?.node.legs.some(leg => leg.from.vehicleRentalStation);
const mapLayerOptions = itineraryContainsDepartureFromVehicleRentalStation
? addBikeStationMapForRentalVehicleItineraries(planEdges)
: props.mapLayerOptions;
const objectsToHide = getRentalStationsToHideOnMap(
itineraryContainsDepartureFromVehicleRentalStation,
planEdges?.[activeIndex]?.node,
);
const explicitItinerary =
!!detailView && naviMode && !!itineraryContext.itinerary
? itineraryContext.itinerary
: undefined;
return (
<ItineraryPageMap
{...mwtProps}
from={from}
to={to}
viaPoints={viaPoints}
mapLayers={props.mapLayers}
mapLayerOptions={mapLayerOptions}
setMWTRef={setMWTRef}
mapLayerRef={mapLayerRef}
breakpoint={breakpoint}
planEdges={planEdges}
topics={topicsState}
active={activeIndex}
showActiveOnly={!!detailView}
showVehicles={showVehicles()}
showDurationBubble={planEdges?.[0]?.node.legs?.length === 1}
objectsToHide={objectsToHide}
itinerary={explicitItinerary}
showBackButton={!naviMode}
isLocationPopupEnabled={!naviMode}
realtimeTransfers={!!explicitItinerary}
/>
);
}
const itinerarySelection = getItinerarySelection();
const { combinedEdges, selectedIndex, hasNoTransitItineraries } =
itinerarySelection;
let { plan } = itinerarySelection;
const toggleNavigatorIntro = () => {
setDialogState('navi-intro');
setNavigatorIntroDismissed(true);
executeAction(startLocationWatch);
setLocationPermissionsLoadState(LOADSTATE.LOADING);
};
const toggleGeolocationInfo = () => {
setGeolocationInfoOpen(!isGeolocationInfoOpen);
};
const cancelNavigatorUsage = () => {
setNavigation(false);
};
const walkPlan = altStates[PLANTYPE.WALK][0].plan;
const bikePlan = altStates[PLANTYPE.BIKE][0].plan;
const carPlan = altStates[PLANTYPE.CAR][0].plan;
const parkRidePlan = altStates[PLANTYPE.PARKANDRIDE][0].plan;
const bikePublicPlan = bikePublicState.plan;
const carPublicPlan = carPublicState.plan;
const settings = getSettings(config);
const showRelaxedPlanNotifier = plan === relaxState.plan;
const showCombinedPlanNotifier = plan === combinedRelaxState.plan;
let rentalVehicleNotifierId = null;
if (showCombinedPlanNotifier) {
if (relaxFlexState.plan?.edges && relaxScooterState.plan?.edges) {
rentalVehicleNotifierId = 'e-scooter-or-taxi';
} else if (relaxFlexState.plan?.edges) {
rentalVehicleNotifierId = 'taxi';
} else if (relaxScooterState.plan?.edges) {
rentalVehicleNotifierId = 'e-scooter';
}
}
/* NOTE: as a temporary solution, do filtering by feedId in UI */
if (config.feedIdFiltering && plan) {
plan = filterItinerariesByFeedId(plan, config);
}
const from = otpToLocation(params.from);
const to = otpToLocation(params.to);
const viaPoints = getIntermediatePlaces(query);
const hasItineraries = combinedEdges.length > 0;
if (hasItineraries && match.routes.some(route => route.printPage)) {
return cloneElement(props.content, {
itinerary: combinedEdges[selectedIndex],
focusToPoint,
from,
to,
});
}
const searchTime = plan?.searchDateTime
? Date.parse(plan.searchDateTime)
: query.time
? query.time * 1000
: Date.now();
// no map on mobile summary view
const map =
!detailView && breakpoint !== 'large'
? null
: renderMap(
from,
to,
viaPoints,
combinedEdges,
selectedIndex,
detailView,
);
const desktop = breakpoint === 'large';
// must wait alternatives to render correct notifier
const loadingAlt = altLoading();
const waitAlternatives = hasNoTransitItineraries && loadingAlt;
const loading =
combinedState.loading === LOADSTATE.LOADING ||
(relaxState.loading === LOADSTATE.LOADING && hasNoTransitItineraries) ||
(combinedRelaxState.loading === LOADSTATE.LOADING &&
hasNoTransitItineraries) ||
waitAlternatives ||
(streetHashes.includes(hash) && loadingAlt); // viewing unfinished alt plan
const settingsDrawer = settingsState.settingsOpen ? (
<div className={desktop ? 'offcanvas' : 'offcanvas-mobile'}>
<CustomizeSearch onToggleClick={toggleSettings} mobile={!desktop} />
</div>
) : null;
// in mobile, settings drawer hides other content
const panelHidden = !desktop && settingsDrawer !== null;
let content; // bottom content of itinerary panel
if (panelHidden) {
content = null;
} else if (loading) {
content = (
<div style={{ position: 'relative', height: 200 }}>
<Loading />
</div>
);
} else if (detailView) {
if (naviMode) {
content = (
<div>
{!isNavigatorIntroDismissed ||
locationPermissionsLoadState === LOADSTATE.LOADING ? (
<>
<NavigatorIntroModal
isOpen
onPrimaryClick={toggleNavigatorIntro}
onClose={cancelNavigatorUsage}
onOpenGeolocationInfo={toggleGeolocationInfo}
/>
{isGeolocationInfoOpen && (
<NaviGeolocationInfoModal
isOpen
onClose={toggleGeolocationInfo}
/>
)}
</>
) : (
<NaviContainer
focusToLeg={focusToLeg}
relayEnvironment={props.relayEnvironment}
setNavigation={setNavigation}
mapRef={mwtRef.current}
mapLayerRef={mapLayerRef}
isNavigatorIntroDismissed={isNavigatorIntroDismissed}
settings={settings}
/>
)}
</div>
);
} else {
let carEmissions = carPlan?.edges?.[0]?.node.emissionsPerPerson?.co2;
// show navi if search is not in past and not more than 24 hours in future
const presentSearch =
Date.parse(combinedEdges[selectedIndex]?.node.end) > Date.now() &&
Date.parse(combinedEdges[selectedIndex]?.node.start) <
Date.now() + 24 * 3600 * 1000;
const navigateHook =
!desktop && config.navigation && presentSearch
? () =>
startNavigationWithAnalytics(combinedEdges[selectedIndex]?.node)
: undefined;
carEmissions = carEmissions ? Math.round(carEmissions) : undefined;
content = (
<ItineraryTabs
isMobile={!desktop}
tabIndex={selectedIndex}
changeHash={changeHash}
plan={plan}
planEdges={combinedEdges}
focusToPoint={focusToPoint}
focusToLeg={focusToLeg}
carEmissions={carEmissions}
bikePublicItineraryCount={bikePublicPlan.bikePublicItineraryCount}
carPublicItineraryCount={carPublicPlan.carPublicItineraryCount}
openSettings={showSettingsPanel}
relayEnvironment={props.relayEnvironment}
startNavigation={navigateHook}
/>
);
}
} else {
if (state.loading === LOADSTATE.UNSET) {
return null; // do not render 'no itineraries' before searches
}
const settingsNotification =
!showRelaxedPlanNotifier && // show only on notifier about limitations
settingsLimitRouting(config) &&
!isEqualItineraries(state.plan?.edges, relaxState.plan?.edges) &&
relaxState.plan?.edges?.length > 0 &&
!settingsState.settingsChanged &&
!hash; // no notifier on p&r or bike&public lists
content = (
<ItineraryListContainer
activeIndex={selectedIndex}
planEdges={combinedEdges}
params={params}
bikeParkItineraryCount={bikePublicPlan.bikeParkItineraryCount}
carDirectItineraryCount={carPublicPlan.carDirectItineraryCount}
showRelaxedPlanNotifier={showRelaxedPlanNotifier}
rentalVehicleNotifierId={rentalVehicleNotifierId}
separatorPosition={hash ? undefined : state.separatorPosition}
onLater={onLater}
onEarlier={onEarlier}
focusToHeader={focusToHeader}
loading={loading}
loadingMore={state.loadingMore}
settingsNotification={settingsNotification}
routingFeedbackPosition={
hash ? undefined : state.routingFeedbackPosition
}
topNote={state.topNote}
bottomNote={state.bottomNote}
searchTime={searchTime}
routingErrors={plan?.routingErrors}
from={from}
to={to}
error={state.error}
walking={walkPlan?.edges?.length > 0}
biking={bikePlan?.edges?.length > 0 || !!bikePublicPlan?.edges?.length}
driving={
(settings.includeCarSuggestions && carPlan?.edges?.length > 0) ||
!!carPublicPlan?.edges?.length ||
!!parkRidePlan?.edges?.length
}
/>
);
}
const showCarPublicPlan = carPublicPlan.carPublicItineraryCount > 0;
const showAltBar =
!detailView &&
!panelHidden &&
!streetHashes.includes(hash) &&
(loadingAlt || // show shimmer
walkPlan?.edges?.length ||
bikePlan?.edges?.length ||
bikePublicPlan?.edges?.length ||
parkRidePlan?.edges?.length ||
(settings.includeCarSuggestions && carPlan?.edges?.length) ||
carPublicPlan?.edges?.length);
const alternativeItineraryBar = showAltBar ? (
<AlternativeItineraryBar
selectStreetMode={selectStreetMode}
weatherData={weatherState.weatherData}
walkPlan={walkPlan}
bikePlan={bikePlan}
bikePublicPlan={bikePublicPlan}
parkRidePlan={parkRidePlan}
carPlan={
settings.includeCarSuggestions && !showCarPublicPlan
? carPlan
: undefined
}
carPublicPlan={showCarPublicPlan ? carPublicPlan : undefined}
loading={loading || loadingAlt || weatherState.loading}
/>
) : null;
const header = !detailView && !panelHidden && (
<>
<div role="status" className="sr-only" id="status" aria-live="polite">
<FormattedMessage id={ariaRef.current} />
</div>
<ItineraryPageControls params={params} toggleSettings={toggleSettings} />
{alternativeItineraryBar}
</>
);
if (desktop) {
const title = (
<span ref={headerRef} tabIndex={-1}>
<FormattedMessage
id={detailView ? 'itinerary-page.title' : 'summary-page.title'}
defaultMessage="Itinerary suggestions"
/>
</span>
);
// in detail view or parkride and bike+public, back button should pop out last path segment
const bckBtnFallback =
detailView || altTransitHash.includes(hash) ? 'pop' : undefined;
return (
<DesktopView
title={title}
header={header}
bckBtnFallback={bckBtnFallback}
content={content}
settingsDrawer={settingsDrawer}
map={map}
scrollable
/>
);
}
return (
<MobileView
header={header}
content={content}
settingsDrawer={settingsDrawer}
map={map}
mapRef={mwtRef.current}
ref={mobileRef}
match={match}
enableBottomScroll={!naviMode}
/>
);
}
ItineraryPage.contextTypes = {
config: configShape,
executeAction: PropTypes.func.isRequired,
getStore: PropTypes.func,
router: routerShape.isRequired,
match: matchShape.isRequired,
intl: intlShape.isRequired,
};
ItineraryPage.propTypes = {
match: matchShape.isRequired,
content: PropTypes.node,
map: PropTypes.shape({ type: PropTypes.func.isRequired }),
breakpoint: PropTypes.string.isRequired,
relayEnvironment: relayShape.isRequired,
mapLayers: mapLayerShape.isRequired,
mapLayerOptions: mapLayerOptionsShape.isRequired,
};
ItineraryPage.defaultProps = {
content: undefined,
map: undefined,
};