mirror of
https://github.com/HSLdevcom/digitransit-ui
synced 2025-07-06 01:00:37 +02:00
578 lines
17 KiB
JavaScript
578 lines
17 KiB
JavaScript
/* eslint-disable import/no-unresolved */
|
|
import { DateTime } from 'luxon';
|
|
import PropTypes from 'prop-types';
|
|
import React from 'react';
|
|
import { FormattedMessage, intlShape } from 'react-intl';
|
|
import cx from 'classnames';
|
|
import sortBy from 'lodash/sortBy';
|
|
import { matchShape, routerShape } from 'found';
|
|
import { enrichPatterns } from '@digitransit-util/digitransit-util';
|
|
import connectToStores from 'fluxible-addons-react/connectToStores';
|
|
import { configShape } from '../../util/shapes';
|
|
import CallAgencyWarning from './CallAgencyWarning';
|
|
import RoutePatternSelect from './RoutePatternSelect';
|
|
import RouteNotification from './RouteNotification';
|
|
import { DATE_FORMAT, ExtendedRouteTypes } from '../../constants';
|
|
import {
|
|
startRealTimeClient,
|
|
stopRealTimeClient,
|
|
changeRealTimeClientTopics,
|
|
} from '../../action/realTimeClientAction';
|
|
import {
|
|
getCancelationsForRoute,
|
|
getAlertsForObject,
|
|
checkActiveDisruptions,
|
|
getActiveAlertSeverityLevel,
|
|
} from '../../util/alertUtils';
|
|
import { isActiveDate } from '../../util/patternUtils';
|
|
import {
|
|
PREFIX_DISRUPTION,
|
|
PREFIX_ROUTES,
|
|
PREFIX_STOPS,
|
|
PREFIX_TIMETABLE,
|
|
} from '../../util/path';
|
|
import { addAnalyticsEvent } from '../../util/analyticsUtils';
|
|
import { isIOS } from '../../util/browser';
|
|
import { unixTime, unixToYYYYMMDD } from '../../util/timeUtils';
|
|
import { saveSearch } from '../../action/SearchActions';
|
|
import Icon from '../Icon';
|
|
|
|
const Tab = {
|
|
Disruptions: PREFIX_DISRUPTION,
|
|
Stops: PREFIX_STOPS,
|
|
Timetable: PREFIX_TIMETABLE,
|
|
};
|
|
|
|
const getActiveTab = pathname => {
|
|
if (pathname.indexOf(`/${Tab.Disruptions}`) > -1) {
|
|
return Tab.Disruptions;
|
|
}
|
|
if (pathname.indexOf(`/${Tab.Stops}`) > -1) {
|
|
return Tab.Stops;
|
|
}
|
|
if (pathname.indexOf(`/${Tab.Timetable}`) > -1) {
|
|
return Tab.Timetable;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
class RouteControlPanel extends React.Component {
|
|
static contextTypes = {
|
|
getStore: PropTypes.func.isRequired,
|
|
executeAction: PropTypes.func.isRequired,
|
|
intl: intlShape.isRequired,
|
|
router: routerShape.isRequired,
|
|
config: configShape.isRequired,
|
|
};
|
|
|
|
static propTypes = {
|
|
route: PropTypes.shape({
|
|
mode: PropTypes.string.isRequired,
|
|
gtfsId: PropTypes.string.isRequired,
|
|
longName: PropTypes.string,
|
|
shortName: PropTypes.string,
|
|
patterns: PropTypes.arrayOf(PropTypes.shape({})),
|
|
type: PropTypes.number,
|
|
agency: PropTypes.shape({
|
|
name: PropTypes.string.isRequired,
|
|
}).isRequired,
|
|
}).isRequired,
|
|
match: matchShape.isRequired,
|
|
breakpoint: PropTypes.string.isRequired,
|
|
noInitialServiceDay: PropTypes.bool,
|
|
language: PropTypes.string,
|
|
tripStartTime: PropTypes.string,
|
|
};
|
|
|
|
static defaultProps = {
|
|
language: 'fi',
|
|
noInitialServiceDay: false,
|
|
tripStartTime: undefined,
|
|
};
|
|
|
|
constructor(props) {
|
|
super(props);
|
|
this.state = {
|
|
focusedTab: getActiveTab(props.match.location.pathname),
|
|
};
|
|
this.stopTabRef = React.createRef();
|
|
this.timetableTabRef = React.createRef();
|
|
this.disruptionTabRef = React.createRef();
|
|
this.tabRefs = [
|
|
this.stopTabRef,
|
|
this.timetableTabRef,
|
|
this.disruptionTabRef,
|
|
];
|
|
}
|
|
|
|
// gets called if pattern has not been visited before
|
|
componentDidMount() {
|
|
const { match, route, noInitialServiceDay } = this.props;
|
|
const { config, router } = this.context;
|
|
const { location } = match;
|
|
|
|
if (!route || !route.patterns) {
|
|
return;
|
|
}
|
|
|
|
if (noInitialServiceDay) {
|
|
return;
|
|
}
|
|
|
|
if (isIOS && location.query.save) {
|
|
this.context.executeAction(saveSearch, {
|
|
item: {
|
|
properties: {
|
|
mode: route.mode,
|
|
gtfsId: route.gtfsId,
|
|
longName: route.longName,
|
|
shortName: route.shortName,
|
|
layer: `route-${route.mode}`,
|
|
link: location.pathname,
|
|
agency: { name: route.agency.name },
|
|
},
|
|
type: 'Route',
|
|
},
|
|
type: 'search',
|
|
});
|
|
}
|
|
|
|
let sortedPatternsByCountOfTrips;
|
|
const tripsExists = route.patterns ? 'trips' in route.patterns[0] : false;
|
|
|
|
if (tripsExists) {
|
|
sortedPatternsByCountOfTrips = sortBy(
|
|
sortBy(route.patterns, 'code').reverse(),
|
|
'trips.length',
|
|
).reverse();
|
|
}
|
|
const pattern =
|
|
sortedPatternsByCountOfTrips !== undefined
|
|
? sortedPatternsByCountOfTrips[0]
|
|
: route.patterns.find(({ code }) => code === match.params.patternId);
|
|
|
|
if (!pattern) {
|
|
return;
|
|
}
|
|
|
|
const selectedPattern = sortedPatternsByCountOfTrips?.find(
|
|
sorted => sorted.code === match.params.patternId,
|
|
);
|
|
|
|
if (match.params.type === PREFIX_TIMETABLE && !location.query?.serviceDay) {
|
|
const enrichedPattern = enrichPatterns(
|
|
[selectedPattern],
|
|
false,
|
|
this.context.config.itinerary.serviceTimeRange,
|
|
);
|
|
const isSameWeek = DateTime.fromFormat(
|
|
enrichedPattern[0].minAndMaxDate[0],
|
|
DATE_FORMAT,
|
|
).hasSame(DateTime.now(), 'week');
|
|
if (
|
|
location.search.indexOf('serviceDay') === -1 ||
|
|
(location.query.serviceDay &&
|
|
Number(location.query.serviceDay) <
|
|
Number(enrichedPattern[0].minAndMaxDate[0]))
|
|
) {
|
|
if (isSameWeek) {
|
|
router.replace(
|
|
`${decodeURIComponent(match.location.pathname)}?serviceDay=${
|
|
enrichedPattern[0].minAndMaxDate[0]
|
|
}`,
|
|
);
|
|
} else {
|
|
router.replace(
|
|
`${decodeURIComponent(match.location.pathname)}?serviceDay=${
|
|
enrichedPattern[0].activeDates[0]
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const { realTime } = config;
|
|
|
|
if (!realTime) {
|
|
return;
|
|
}
|
|
|
|
const routeParts = route.gtfsId.split(':');
|
|
const feedId = routeParts[0];
|
|
const source = realTime[feedId];
|
|
if (!source || !source.active) {
|
|
return;
|
|
}
|
|
if (isActiveDate(selectedPattern)) {
|
|
this.startClient(selectedPattern);
|
|
}
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
const { client } = this.context.getStore('RealTimeInformationStore');
|
|
if (client) {
|
|
this.context.executeAction(stopRealTimeClient, client);
|
|
}
|
|
}
|
|
|
|
onPatternChange = newPattern => {
|
|
addAnalyticsEvent({
|
|
category: 'Route',
|
|
action: 'ToggleDirection',
|
|
name: null,
|
|
});
|
|
const { match, route } = this.props;
|
|
const { config, executeAction, getStore, router } = this.context;
|
|
const { client, topics } = getStore('RealTimeInformationStore');
|
|
const { type } = match.params;
|
|
|
|
const pattern =
|
|
type === PREFIX_TIMETABLE
|
|
? enrichPatterns(
|
|
route.patterns.filter(x => x.code === newPattern),
|
|
false,
|
|
this.context.config.itinerary.serviceTimeRange,
|
|
)
|
|
: route.patterns.filter(x => x.code === newPattern);
|
|
const isActivePattern = isActiveDate(pattern[0]);
|
|
|
|
// if config contains mqtt feed and old client has not been removed
|
|
if (client) {
|
|
const { realTime } = config;
|
|
const routeParts = route.gtfsId.split(':');
|
|
const feedId = routeParts[0];
|
|
const source = realTime[feedId];
|
|
|
|
if (isActivePattern) {
|
|
const id = source.routeSelector(this.props);
|
|
executeAction(changeRealTimeClientTopics, {
|
|
...source,
|
|
feedId,
|
|
options: [
|
|
{
|
|
route: id,
|
|
feedId,
|
|
mode: route.mode.toLowerCase(),
|
|
gtfsId: routeParts[1],
|
|
headsign: pattern[0].headsign,
|
|
},
|
|
],
|
|
oldTopics: topics,
|
|
client,
|
|
});
|
|
} else {
|
|
// Close MQTT, we don't want to show vehicles when pattern is in future / past
|
|
executeAction(stopRealTimeClient, client);
|
|
}
|
|
} else if (isActivePattern) {
|
|
this.startClient(pattern[0]);
|
|
}
|
|
|
|
let newPathname = decodeURIComponent(match.location.pathname).replace(
|
|
new RegExp(`${match.params.patternId}(.*)`),
|
|
newPattern,
|
|
);
|
|
if (type === PREFIX_TIMETABLE) {
|
|
const today = unixToYYYYMMDD(unixTime(), config);
|
|
if (pattern[0].minAndMaxDate && today < pattern[0].minAndMaxDate[0]) {
|
|
newPathname += `?serviceDay=${pattern[0].minAndMaxDate[0]}`;
|
|
}
|
|
if (match.query && match.query.serviceDay) {
|
|
newPathname += `?serviceDay=${match.query.serviceDay}`;
|
|
}
|
|
}
|
|
router.replace(newPathname);
|
|
};
|
|
|
|
startClient(pattern) {
|
|
const { config, executeAction } = this.context;
|
|
const { match, route, tripStartTime } = this.props;
|
|
const { realTime } = config;
|
|
if (config.NODE_ENV === 'test' || !realTime) {
|
|
return;
|
|
}
|
|
|
|
const routeParts = route.gtfsId.split(':');
|
|
const feedId = routeParts[0];
|
|
const source = realTime[feedId];
|
|
const id =
|
|
pattern.code !== match.params.patternId
|
|
? routeParts[1]
|
|
: source.routeSelector(this.props);
|
|
if (!source || !source.active) {
|
|
return;
|
|
}
|
|
|
|
const patternIdSplit = match.params.patternId.split(':');
|
|
const direction = patternIdSplit[patternIdSplit.length - 2];
|
|
|
|
executeAction(startRealTimeClient, {
|
|
...source,
|
|
feedId,
|
|
options: [
|
|
{
|
|
route: id,
|
|
// add some information from the context
|
|
// to compensate potentially missing feed data
|
|
feedId,
|
|
mode: route.mode.toLowerCase(),
|
|
gtfsId: routeParts[1],
|
|
headsign: pattern.headsign,
|
|
direction,
|
|
tripStartTime,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
changeTab = tab => {
|
|
const path = `/${PREFIX_ROUTES}/${this.props.route.gtfsId}/${tab}/${
|
|
this.props.match.params.patternId || ''
|
|
}${
|
|
this.props.match.location.query.serviceDay
|
|
? `?serviceDay=${this.props.match.location.query.serviceDay}`
|
|
: ''
|
|
}`;
|
|
this.context.router.replace(path);
|
|
let action;
|
|
switch (tab) {
|
|
case PREFIX_TIMETABLE:
|
|
action = 'OpenTimetableTab';
|
|
break;
|
|
case PREFIX_STOPS:
|
|
action = 'OpenStopsTab';
|
|
break;
|
|
case PREFIX_DISRUPTION:
|
|
action = 'OpenDisruptionsTab';
|
|
break;
|
|
default:
|
|
action = 'Unknown';
|
|
break;
|
|
}
|
|
addAnalyticsEvent({
|
|
category: 'Route',
|
|
action,
|
|
name: null,
|
|
});
|
|
};
|
|
|
|
/* eslint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions, jsx-a11y/anchor-is-valid */
|
|
render() {
|
|
const { breakpoint, match, route, language } = this.props;
|
|
const { patternId } = match.params;
|
|
const { config } = this.context;
|
|
|
|
const routeNotifications = [];
|
|
if (
|
|
config.NODE_ENV !== 'test' &&
|
|
config.routeNotifications &&
|
|
config.routeNotifications.length > 0
|
|
) {
|
|
for (let i = 0; i < config.routeNotifications.length; i++) {
|
|
const notification = config.routeNotifications[i];
|
|
if (notification.showForRoute?.(route)) {
|
|
routeNotifications.push(
|
|
<RouteNotification
|
|
key={notification.id}
|
|
header={notification.header[language]}
|
|
content={notification.content[language]}
|
|
link={notification.link?.[language]}
|
|
id={notification.id}
|
|
closeButtonLabel={notification.closeButtonLabel?.[language]}
|
|
/>,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const activeTab = getActiveTab(match.location.pathname);
|
|
const currentTime = unixTime();
|
|
const selectedPattern = route?.patterns?.find(
|
|
pattern => pattern.code === patternId,
|
|
);
|
|
const hasActiveAlert = checkActiveDisruptions(
|
|
currentTime,
|
|
getCancelationsForRoute(
|
|
route,
|
|
patternId,
|
|
currentTime,
|
|
config.routeCancelationAlertValidity,
|
|
),
|
|
getAlertsForObject(selectedPattern),
|
|
);
|
|
|
|
const hasActiveServiceAlerts = getActiveAlertSeverityLevel(
|
|
getAlertsForObject(selectedPattern),
|
|
currentTime,
|
|
);
|
|
|
|
const disruptionClassName =
|
|
(hasActiveAlert && 'active-disruption-alert') ||
|
|
(hasActiveServiceAlerts && 'active-service-alert');
|
|
|
|
const useCurrentTime = activeTab === Tab.Stops;
|
|
|
|
const countOfButtons = 3;
|
|
|
|
let disruptionIcon;
|
|
if (disruptionClassName === 'active-disruption-alert') {
|
|
disruptionIcon = (
|
|
<Icon
|
|
className="disrution-icon"
|
|
img="icon-icon_caution-no-excl-no-stroke"
|
|
/>
|
|
);
|
|
} else if (disruptionClassName === 'active-service-alert') {
|
|
disruptionIcon = (
|
|
<Icon className="service-alert-icon" img="icon-icon_info" />
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cx('route-page-control-panel-container', activeTab, {
|
|
'bp-large': breakpoint === 'large',
|
|
})}
|
|
>
|
|
<div className="header-for-printing">
|
|
<h1>
|
|
{config.title}
|
|
{` - `}
|
|
<FormattedMessage id="route-guide" defaultMessage="Route guide" />
|
|
</h1>
|
|
</div>
|
|
{route.type === ExtendedRouteTypes.CallAgency && (
|
|
<CallAgencyWarning route={route} />
|
|
)}
|
|
<div
|
|
className={cx('route-control-panel', {
|
|
'bp-large': breakpoint === 'large',
|
|
})}
|
|
aria-live="polite"
|
|
>
|
|
{routeNotifications}
|
|
{patternId && (
|
|
<RoutePatternSelect
|
|
params={match.params}
|
|
route={route}
|
|
onSelectChange={this.onPatternChange}
|
|
gtfsId={route.gtfsId}
|
|
className={cx({ 'bp-large': breakpoint === 'large' })}
|
|
useCurrentTime={useCurrentTime}
|
|
/>
|
|
)}
|
|
{/* eslint-disable jsx-a11y/interactive-supports-focus */}
|
|
<div
|
|
className="route-tabs"
|
|
role="tablist"
|
|
onKeyDown={e => {
|
|
const tabs = [Tab.Stops, Tab.Timetable, Tab.Disruptions];
|
|
const tabCount = tabs.length;
|
|
const activeIndex = tabs.indexOf(this.state.focusedTab);
|
|
let index;
|
|
switch (e.nativeEvent.code) {
|
|
case 'ArrowLeft':
|
|
index = (activeIndex - 1 + tabCount) % tabCount;
|
|
this.tabRefs[index].current.focus();
|
|
this.setState({ focusedTab: tabs[index] });
|
|
break;
|
|
case 'ArrowRight':
|
|
index = (activeIndex + 1) % tabCount;
|
|
this.tabRefs[index].current.focus();
|
|
this.setState({ focusedTab: tabs[index] });
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}}
|
|
>
|
|
{/* eslint-enable jsx-a11y/interactive-supports-focus */}
|
|
<button
|
|
type="button"
|
|
className={cx({ 'is-active': activeTab === Tab.Stops })}
|
|
onClick={() => {
|
|
this.changeTab(Tab.Stops);
|
|
}}
|
|
tabIndex={activeTab === Tab.Stops ? 0 : -1}
|
|
role="tab"
|
|
{...(activeTab === Tab.Stops ? { id: 'route-tab' } : {})}
|
|
ref={this.stopTabRef}
|
|
aria-selected={activeTab === Tab.Stops}
|
|
style={{
|
|
'--totalCount': `${countOfButtons}`,
|
|
}}
|
|
>
|
|
<div>
|
|
<FormattedMessage id="stops" defaultMessage="Stops" />
|
|
</div>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={cx({ 'is-active': activeTab === Tab.Timetable })}
|
|
onClick={() => {
|
|
this.changeTab(Tab.Timetable);
|
|
}}
|
|
tabIndex={activeTab === Tab.Timetable ? 0 : -1}
|
|
role="tab"
|
|
ref={this.timetableTabRef}
|
|
aria-selected={activeTab === Tab.Timetable}
|
|
style={{
|
|
'--totalCount': `${countOfButtons}`,
|
|
}}
|
|
>
|
|
<div>
|
|
<FormattedMessage id="timetable" defaultMessage="Timetable" />
|
|
</div>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={cx({
|
|
activeAlert: hasActiveAlert,
|
|
'is-active': activeTab === Tab.Disruptions,
|
|
})}
|
|
onClick={() => {
|
|
this.changeTab(Tab.Disruptions);
|
|
}}
|
|
tabIndex={activeTab === Tab.Disruptions ? 0 : -1}
|
|
role="tab"
|
|
ref={this.disruptionTabRef}
|
|
aria-selected={activeTab === Tab.Disruptions}
|
|
style={{
|
|
'--totalCount': `${countOfButtons}`,
|
|
}}
|
|
>
|
|
<div
|
|
className={`tab-route-disruption ${
|
|
disruptionClassName || `no-alerts`
|
|
}`}
|
|
>
|
|
{disruptionIcon}
|
|
<FormattedMessage
|
|
id="disruptions"
|
|
defaultMessage="Disruptions"
|
|
/>
|
|
<span className="sr-only">
|
|
{disruptionClassName ? (
|
|
<FormattedMessage id="disruptions-tab.sr-disruptions" />
|
|
) : (
|
|
<FormattedMessage id="disruptions-tab.sr-no-disruptions" />
|
|
)}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
const connectedComponent = connectToStores(
|
|
RouteControlPanel,
|
|
['PreferencesStore'],
|
|
context => ({
|
|
language: context.getStore('PreferencesStore').getLanguage(),
|
|
}),
|
|
);
|
|
|
|
export { connectedComponent as default, RouteControlPanel as Component };
|