mirror of
https://github.com/HSLdevcom/digitransit-ui
synced 2025-07-06 01:00:37 +02:00
491 lines
15 KiB
JavaScript
491 lines
15 KiB
JavaScript
import PropTypes from 'prop-types';
|
|
import React, { useState, useEffect } from 'react';
|
|
import { DateTime } from 'luxon';
|
|
import uniqBy from 'lodash/uniqBy';
|
|
import sortBy from 'lodash/sortBy';
|
|
import groupBy from 'lodash/groupBy';
|
|
import padStart from 'lodash/padStart';
|
|
import { FormattedMessage, intlShape } from 'react-intl';
|
|
import { matchShape, routerShape } from 'found';
|
|
import { useFragment } from 'react-relay';
|
|
import { connectToStores } from 'fluxible-addons-react';
|
|
import cx from 'classnames';
|
|
import { configShape } from '../../util/shapes';
|
|
import Icon from '../Icon';
|
|
import FilterTimeTableModal from './FilterTimeTableModal';
|
|
import TimeTableOptionsPanel from './TimeTableOptionsPanel';
|
|
import TimetableRow from './TimetableRow';
|
|
import { DATE_FORMAT, RealtimeStateType } from '../../constants';
|
|
import SecondaryButton from '../SecondaryButton';
|
|
import { addAnalyticsEvent } from '../../util/analyticsUtils';
|
|
import DateSelect from './DateSelect';
|
|
import ScrollableWrapper from '../ScrollableWrapper';
|
|
import { replaceQueryParams } from '../../util/queryUtils';
|
|
import { PREFIX_STOPS } from '../../util/path';
|
|
import { TimetableFragment } from './queries/TimetableFragment';
|
|
|
|
const mapStopTimes = stoptimesObject =>
|
|
stoptimesObject
|
|
.map(stoptime =>
|
|
stoptime.stoptimes
|
|
.filter(st => st.pickupType !== 'NONE')
|
|
.map(st => ({
|
|
id: stoptime.pattern.code,
|
|
name: stoptime.pattern.route.shortName || stoptime.pattern.headsign,
|
|
scheduledDeparture: st.scheduledDeparture,
|
|
serviceDay: st.serviceDay,
|
|
headsign: stoptime.pattern.headsign,
|
|
longName: stoptime.pattern.route.longName,
|
|
isCanceled: st.realtimeState === RealtimeStateType.Canceled,
|
|
mode: stoptime.pattern.route.mode,
|
|
})),
|
|
)
|
|
.reduce((acc, val) => acc.concat(val), []);
|
|
|
|
const groupArrayByHour = stoptimesArray =>
|
|
groupBy(stoptimesArray, stoptime =>
|
|
Math.floor(stoptime.scheduledDeparture / (60 * 60)),
|
|
);
|
|
|
|
const printStop = e => {
|
|
e.stopPropagation();
|
|
window.print();
|
|
};
|
|
|
|
const printStopPDF = (e, stopPDFURL) => {
|
|
e.stopPropagation();
|
|
window.open(stopPDFURL.href);
|
|
};
|
|
|
|
const getDuplicatedRoutes = stop => {
|
|
const routesToCheck = mapStopTimes(stop.stoptimesForServiceDate)
|
|
.map(o => ({
|
|
shortName: o.name,
|
|
headsign: o.headsign,
|
|
}))
|
|
.filter(
|
|
(item, index, self) =>
|
|
index ===
|
|
self.findIndex(
|
|
o => o.headsign === item.headsign && o.shortName === item.shortName,
|
|
),
|
|
);
|
|
|
|
const routesWithDupes = [];
|
|
Object.entries(groupBy(routesToCheck, 'shortName')).forEach(([key, value]) =>
|
|
value.length > 1 ? routesWithDupes.push(key) : undefined,
|
|
);
|
|
|
|
return routesWithDupes;
|
|
};
|
|
|
|
const dateForPrinting = date => {
|
|
const selectedDate = DateTime.fromFormat(date, DATE_FORMAT);
|
|
return (
|
|
<div className="printable-date-container">
|
|
<div className="printable-date-icon">
|
|
<Icon className="large-icon" img="icon-icon_schedule" />
|
|
</div>
|
|
<div className="printable-date-right">
|
|
<div className="printable-date-header">
|
|
<FormattedMessage id="date" defaultMessage="Date" />
|
|
</div>
|
|
<div className="printable-date-content">
|
|
{selectedDate.toFormat('ccc dd.LL.yyyy')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const formTimeRow = (timetableMap, hour, showRoutes) => {
|
|
const sortedArr = timetableMap[hour].sort(
|
|
(time1, time2) => time1.scheduledDeparture - time2.scheduledDeparture,
|
|
);
|
|
|
|
const filteredRoutes = sortedArr
|
|
.map(
|
|
time =>
|
|
showRoutes.filter(o => o === time.name || o === time.id).length > 0 &&
|
|
DateTime.fromSeconds(
|
|
time.serviceDay + time.scheduledDeparture,
|
|
).toFormat('HH'),
|
|
)
|
|
.filter(o => o === padStart(hour % 24, 2, '0'));
|
|
|
|
return filteredRoutes;
|
|
};
|
|
|
|
const createTimeTableRows = (timetableMap, showRoutes) =>
|
|
Object.keys(timetableMap)
|
|
.sort((a, b) => a - b)
|
|
.map(hour => (
|
|
<TimetableRow
|
|
key={hour}
|
|
title={padStart(hour % 24, 2, '0')}
|
|
stoptimes={timetableMap[hour]}
|
|
showRoutes={showRoutes}
|
|
timerows={formTimeRow(timetableMap, hour, showRoutes)}
|
|
/>
|
|
));
|
|
|
|
function Timetable(
|
|
{ stop: stopRef, startDate, onDateChange, date, language },
|
|
{ router, match, config, intl },
|
|
) {
|
|
const stop = useFragment(TimetableFragment, stopRef);
|
|
if (!stop) {
|
|
const path = `/${PREFIX_STOPS}`;
|
|
router.replace(path);
|
|
}
|
|
const [showRoutes, setShowRoutes] = useState([]);
|
|
const [showFilterModal, setShowFilterModal] = useState(false);
|
|
|
|
const setParams = (routes, newDate) => {
|
|
replaceQueryParams(router, match, {
|
|
routes,
|
|
date: newDate,
|
|
});
|
|
};
|
|
|
|
const setRouteVisibilityState = val => {
|
|
setShowRoutes(val.showRoutes);
|
|
const showRoutesNew = val.showRoutes.length
|
|
? val.showRoutes.join(',')
|
|
: undefined;
|
|
setParams(showRoutesNew, date);
|
|
};
|
|
|
|
// Reset options if stop changes
|
|
useEffect(() => {
|
|
setShowRoutes([]);
|
|
setShowFilterModal(false);
|
|
}, [stop.gtfsId]);
|
|
|
|
useEffect(() => {
|
|
if (match.location.query.routes) {
|
|
setShowRoutes(match.location.query.routes?.split(',') || []);
|
|
}
|
|
}, [match]);
|
|
|
|
// Check if stop is constant operation
|
|
const { constantOperationStops } = config;
|
|
const stopId = stop.gtfsId;
|
|
const { locale } = intl;
|
|
if (constantOperationStops && constantOperationStops[stopId]) {
|
|
return (
|
|
<div className="stop-constant-operation-container">
|
|
<div style={{ width: '85%' }}>
|
|
<span>{constantOperationStops[stopId][locale].text}</span>
|
|
<span style={{ display: 'inline-block' }}>
|
|
<a
|
|
href={constantOperationStops[stopId][locale].link}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
{constantOperationStops[stopId][locale].link}
|
|
</a>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
// Leave out all the routes without a shortname to avoid flooding of
|
|
// long distance buses being falsely positived as duplicates
|
|
// then look foor routes operating under the same number but
|
|
// different headsigns
|
|
const duplicateRoutes = getDuplicatedRoutes(stop);
|
|
const variantList = groupBy(
|
|
sortBy(
|
|
uniqBy(
|
|
mapStopTimes(
|
|
stop.stoptimesForServiceDate.filter(o => o.pattern.route.shortName),
|
|
)
|
|
.map(o => {
|
|
const obj = Object.assign(o);
|
|
obj.groupId = `${o.name}-${o.headsign}`;
|
|
obj.duplicate = !!duplicateRoutes.includes(o.name);
|
|
return obj;
|
|
})
|
|
.filter(o => o.duplicate === true),
|
|
'groupId',
|
|
),
|
|
'name',
|
|
),
|
|
'name',
|
|
);
|
|
|
|
let variantsWithMarks = [];
|
|
|
|
Object.keys(variantList).forEach(key => {
|
|
variantsWithMarks.push(
|
|
variantList[key].map((o, i) => {
|
|
const obj = Object.assign(o);
|
|
obj.duplicate = '*'.repeat(i + 1);
|
|
return obj;
|
|
}),
|
|
);
|
|
});
|
|
|
|
variantsWithMarks = [].concat(...variantsWithMarks);
|
|
|
|
const routesWithDetails = mapStopTimes(stop.stoptimesForServiceDate).map(
|
|
o => {
|
|
const obj = Object.assign(o);
|
|
const getDuplicate = variantsWithMarks.find(
|
|
o2 => o2.name === o.name && o2.headsign === o.headsign && o2.duplicate,
|
|
);
|
|
obj.duplicate = getDuplicate ? getDuplicate.duplicate : false;
|
|
return obj;
|
|
},
|
|
);
|
|
const timetableMap = groupArrayByHour(routesWithDetails);
|
|
const { locationType } = stop;
|
|
const stopIdSplitted = stop.gtfsId.split(':');
|
|
const stopTimetableHandler =
|
|
config.timetables && config.timetables[stopIdSplitted[0]];
|
|
const stopPDFURL =
|
|
stopTimetableHandler &&
|
|
config.URL.STOP_TIMETABLES[stopIdSplitted[0]] &&
|
|
locationType !== 'STATION' &&
|
|
date
|
|
? stopTimetableHandler.stopTimetableUrlResolver(
|
|
config.URL.STOP_TIMETABLES[stopIdSplitted[0]],
|
|
stop,
|
|
date,
|
|
language,
|
|
)
|
|
: null;
|
|
const virtualMonitorUrl =
|
|
config.stopCard?.header?.virtualMonitorBaseUrl &&
|
|
`${
|
|
config.stopCard.header.virtualMonitorBaseUrl
|
|
}/${locationType.toLowerCase()}/${stop.gtfsId}`;
|
|
const timeTableRows = createTimeTableRows(timetableMap, showRoutes);
|
|
const timeDifferenceDays = DateTime.fromFormat(date, DATE_FORMAT).diffNow(
|
|
'days',
|
|
).days;
|
|
return (
|
|
<>
|
|
<ScrollableWrapper>
|
|
<div className="timetable scroll-target">
|
|
{showFilterModal === true ? (
|
|
<FilterTimeTableModal
|
|
stop={stop}
|
|
setRoutes={setRouteVisibilityState}
|
|
showFilterModal={setShowFilterModal}
|
|
showRoutesList={showRoutes}
|
|
/>
|
|
) : null}
|
|
<div className="timetable-topbar">
|
|
<DateSelect
|
|
startDate={startDate}
|
|
selectedDate={date}
|
|
onDateChange={e => {
|
|
onDateChange(e);
|
|
const showRoutesNew = showRoutes.length
|
|
? showRoutes.join(',')
|
|
: undefined;
|
|
setParams(showRoutesNew, e);
|
|
addAnalyticsEvent({
|
|
category: 'Stop',
|
|
action: 'ChangeTimetableDay',
|
|
name: null,
|
|
});
|
|
}}
|
|
dateFormat={DATE_FORMAT}
|
|
/>
|
|
<TimeTableOptionsPanel
|
|
showRoutes={showRoutes}
|
|
showFilterModal={setShowFilterModal}
|
|
stop={stop}
|
|
/>
|
|
</div>
|
|
<div className="timetable-for-printing-header">
|
|
<h1>
|
|
<FormattedMessage id="timetable" defaultMessage="Timetable" />
|
|
</h1>
|
|
</div>
|
|
<div className="timetable-for-printing">{dateForPrinting(date)}</div>
|
|
{timeTableRows.length > 0 ? (
|
|
<div className="timetable-note">
|
|
<h2>
|
|
<FormattedMessage
|
|
id="departures-by-hour"
|
|
defaultMessage="Departures by hour"
|
|
/>{' '}
|
|
<FormattedMessage
|
|
id="departures-by-hour-minutes-route"
|
|
defaultMessage="(minutes/route)"
|
|
/>
|
|
</h2>
|
|
</div>
|
|
) : (
|
|
<div className="no-timetable-found-container">
|
|
<div className="no-timetable-found">
|
|
<div
|
|
className={cx(
|
|
'flex-horizontal',
|
|
'timetable-notification',
|
|
'info',
|
|
)}
|
|
>
|
|
<Icon
|
|
className={cx('no-timetable-icon', 'caution')}
|
|
img="icon-icon_info"
|
|
color="#0074be"
|
|
/>
|
|
{timeDifferenceDays > 30 ? (
|
|
<FormattedMessage
|
|
id="departures-not-found-time-threshold"
|
|
defaultMessage="No departures found"
|
|
/>
|
|
) : (
|
|
<FormattedMessage
|
|
id="departures-not-found"
|
|
defaultMessage="No departures found"
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="momentum-scroll timetable-content-container">
|
|
<div className="timetable-time-headers">
|
|
<div className="hour">
|
|
<FormattedMessage id="hour" defaultMessage="Hour" />
|
|
</div>
|
|
<div className="minutes-per-route">
|
|
<FormattedMessage
|
|
id="minutes-or-route"
|
|
defaultMessage="Min/Route"
|
|
/>
|
|
</div>
|
|
</div>
|
|
{timeTableRows}
|
|
<div
|
|
className="route-remarks"
|
|
style={{
|
|
display:
|
|
variantsWithMarks.filter(o => o.duplicate).length > 0
|
|
? 'block'
|
|
: 'none',
|
|
}}
|
|
>
|
|
<h1>
|
|
<FormattedMessage
|
|
id="explanations"
|
|
defaultMessage="Explanations"
|
|
/>
|
|
:
|
|
</h1>
|
|
{variantsWithMarks.map(o => (
|
|
<div className="remark-row" key={`${o.id}-${o.headsign}`}>
|
|
<span>{`${o.name}${o.duplicate} = ${o.headsign}`}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ScrollableWrapper>
|
|
<div className="after-scrollable-area" />
|
|
<div className="stop-page-action-bar">
|
|
<div className="print-button-container">
|
|
<SecondaryButton
|
|
ariaLabel="print"
|
|
buttonName="print"
|
|
buttonClickAction={e => {
|
|
printStop(e);
|
|
addAnalyticsEvent({
|
|
category: 'Stop',
|
|
action: 'PrintTimetable',
|
|
name: null,
|
|
});
|
|
}}
|
|
buttonIcon="icon-icon_print"
|
|
smallSize
|
|
/>
|
|
{stopPDFURL && (
|
|
<SecondaryButton
|
|
ariaLabel="print-timetable"
|
|
buttonName="print-timetable"
|
|
buttonClickAction={e => {
|
|
printStopPDF(e, stopPDFURL);
|
|
addAnalyticsEvent({
|
|
category: 'Stop',
|
|
action: 'PrintWeeklyTimetable',
|
|
name: null,
|
|
});
|
|
}}
|
|
buttonIcon="icon-icon_print"
|
|
smallSize
|
|
/>
|
|
)}
|
|
{virtualMonitorUrl && (
|
|
<SecondaryButton
|
|
ariaLabel="stop-virtual-monitor"
|
|
buttonName="stop-virtual-monitor"
|
|
buttonClickAction={e => {
|
|
e.preventDefault();
|
|
window.open(virtualMonitorUrl, '_blank ');
|
|
}}
|
|
smallSize
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
Timetable.propTypes = {
|
|
stop: PropTypes.shape({
|
|
url: PropTypes.string,
|
|
gtfsId: PropTypes.string,
|
|
locationType: PropTypes.string,
|
|
stoptimesForServiceDate: PropTypes.arrayOf(
|
|
PropTypes.shape({
|
|
pattern: PropTypes.shape({
|
|
route: PropTypes.shape({
|
|
shortName: PropTypes.string,
|
|
mode: PropTypes.string.isRequired,
|
|
agency: PropTypes.shape({
|
|
name: PropTypes.string.isRequired,
|
|
}).isRequired,
|
|
}).isRequired,
|
|
}).isRequired,
|
|
stoptimes: PropTypes.arrayOf(
|
|
PropTypes.shape({
|
|
realtimeState: PropTypes.string.isRequired,
|
|
scheduledDeparture: PropTypes.number.isRequired,
|
|
serviceDay: PropTypes.number.isRequired,
|
|
}),
|
|
).isRequired,
|
|
}),
|
|
),
|
|
}).isRequired,
|
|
startDate: PropTypes.string.isRequired,
|
|
onDateChange: PropTypes.func.isRequired,
|
|
date: PropTypes.string.isRequired,
|
|
language: PropTypes.string.isRequired,
|
|
};
|
|
|
|
Timetable.contextTypes = {
|
|
router: routerShape.isRequired,
|
|
match: matchShape.isRequired,
|
|
config: configShape.isRequired,
|
|
intl: intlShape.isRequired,
|
|
};
|
|
|
|
Timetable.displayName = 'Timetable';
|
|
|
|
const connectedComponent = connectToStores(
|
|
Timetable,
|
|
['PreferencesStore'],
|
|
context => ({
|
|
language: context.getStore('PreferencesStore').getLanguage(),
|
|
}),
|
|
);
|
|
|
|
export { connectedComponent as default, Timetable as Component };
|