Skip to content

Commit f182886

Browse files
committed
AB#64925 interval timetable
1 parent d0f4e4a commit f182886

File tree

14 files changed

+996
-64
lines changed

14 files changed

+996
-64
lines changed

src/components/stopPoster/stopPoster.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,7 @@ class StopPoster extends Component {
328328
minimapZoneSymbols,
329329
minimapZones,
330330
legend,
331+
intervalTimetable,
331332
} = this.props;
332333
if (!hasRoutesProp) {
333334
return null;
@@ -353,6 +354,7 @@ class StopPoster extends Component {
353354
const StopPosterTimetable = props => (
354355
<div className={styles.timetable}>
355356
<Timetable
357+
intervalTimetable={intervalTimetable}
356358
stopId={stopId}
357359
date={date}
358360
isSummerTimetable={isSummerTimetable}
@@ -426,13 +428,11 @@ class StopPoster extends Component {
426428
<div className={styles.timetables}>
427429
<StopPosterTimetable
428430
segments={['saturdays']}
429-
hideDetails
430431
routeFilter={this.props.routeFilter}
431432
/>
432433
<Spacer width={10} />
433434
<StopPosterTimetable
434435
segments={['sundays']}
435-
hideDetails
436436
routeFilter={this.props.routeFilter}
437437
/>
438438
</div>
@@ -491,6 +491,7 @@ class StopPoster extends Component {
491491
}
492492

493493
StopPoster.propTypes = {
494+
intervalTimetable: PropTypes.bool,
494495
stopId: PropTypes.string.isRequired,
495496
date: PropTypes.string.isRequired,
496497
isSummerTimetable: PropTypes.bool,
@@ -512,6 +513,7 @@ StopPoster.propTypes = {
512513
};
513514

514515
StopPoster.defaultProps = {
516+
intervalTimetable: false,
515517
isSummerTimetable: false,
516518
dateBegin: null,
517519
dateEnd: null,
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import mapValues from 'lodash/mapValues';
2+
import groupBy from 'lodash/groupBy';
3+
import mean from 'lodash/mean';
4+
import sortBy from 'lodash/sortBy';
5+
import padStart from 'lodash/padStart';
6+
import omit from 'lodash/omit';
7+
import cloneDeep from 'lodash/cloneDeep';
8+
import { trimRouteId } from 'util/domain';
9+
import { normalizeDepartures } from './intervalsNormalizer.mjs';
10+
11+
/**
12+
* @typedef {Object} DepartureGroup
13+
* @property {number} hours
14+
* @property {number} minutes
15+
* @property {?string} note
16+
* @property {string} routeId
17+
* @property {string} direction
18+
* @property {string[]} dayType
19+
* @property {boolean} isNextDay
20+
* @property {boolean} isAccessible
21+
* @property {string} dateBegin
22+
* @property {string} dateEnd
23+
* @property {string} __typename
24+
*/
25+
26+
/**
27+
* @typedef {Object} HourInterval
28+
* @property {string} hours - single hour or range like "05-07"
29+
* @property {number} avgInterval - average interval in minutes, 60 if only one departure
30+
*/
31+
32+
const DEPOT_RUNS_LETTER = 'H';
33+
34+
/**
35+
* @param {DepartureGroup[]} departures
36+
* @returns {DepartureGroup[]}
37+
*/
38+
const filterNonDepotDepartures = departures =>
39+
departures.filter(d => !d.routeId.includes(DEPOT_RUNS_LETTER));
40+
41+
/**
42+
* @param {number} n
43+
* @returns {string}
44+
*/
45+
const padHour = n => padStart(String(n), 2, '0');
46+
47+
/**
48+
* @param {Array<{hours: string, intervals: Object}>} entries
49+
* @returns {Array<{hours: string, intervals: Object}>}
50+
*/
51+
const mergeConsecutiveHoursWithSameDepartures = entries => {
52+
if (!entries.length) return [];
53+
54+
const merged = [];
55+
let { hours: startHour, intervals: prevIntervals } = entries[0];
56+
let endHour = startHour;
57+
58+
for (let i = 1; i < entries.length; i++) {
59+
const { hours: currentHour, intervals } = entries[i];
60+
const prevHourNum = parseInt(endHour, 10);
61+
62+
const sameDepartures = JSON.stringify(intervals) === JSON.stringify(prevIntervals);
63+
64+
if (sameDepartures && parseInt(currentHour, 10) === prevHourNum + 1) {
65+
endHour = currentHour;
66+
} else {
67+
merged.push({
68+
hours: startHour === endHour ? startHour : `${startHour}-${endHour}`,
69+
intervals: prevIntervals,
70+
});
71+
startHour = currentHour;
72+
endHour = currentHour;
73+
prevIntervals = intervals;
74+
}
75+
}
76+
77+
merged.push({
78+
hours: startHour === endHour ? startHour : `${startHour}-${endHour}`,
79+
intervals: prevIntervals,
80+
});
81+
82+
return merged;
83+
};
84+
85+
/**
86+
* @param {number[]} nums
87+
* @returns {number}
88+
*/
89+
const calculateAverageInterval = nums => {
90+
if (nums.length < 2) return 60;
91+
const sorted = [...nums].sort((a, b) => a - b);
92+
const intervals = sorted.slice(1).map((v, i) => v - sorted[i]);
93+
return Math.round(mean(intervals));
94+
};
95+
96+
/**
97+
* @param {DepartureGroup[]} filteredDepartures
98+
* @param {Set<string>} routeIds
99+
* @returns {Object<string, {
100+
* hours: string,
101+
* isNextDay: boolean,
102+
* intervals: Object<string, number>,
103+
* lowestMinutes: Object<string, number>,
104+
* highestMinutes: Object<string, number>
105+
* }>}
106+
*/
107+
const groupDeparturesByHour = (filteredDepartures, routeIds) => {
108+
return mapValues(
109+
groupBy(filteredDepartures, d => `${d.hours}_${d.isNextDay}`),
110+
hourGroup => {
111+
const { hours, isNextDay } = hourGroup[0];
112+
113+
const routeGroups = groupBy(hourGroup, item => {
114+
const trimmedRouteId = trimRouteId(item.routeId);
115+
routeIds.add(trimmedRouteId);
116+
return trimmedRouteId;
117+
});
118+
119+
const intervals = {};
120+
const lowestMinutes = {};
121+
const highestMinutes = {};
122+
123+
for (const [routeId, items] of Object.entries(routeGroups)) {
124+
const minutesArray = items.map(item => item.minutes);
125+
intervals[routeId] = calculateAverageInterval(minutesArray);
126+
lowestMinutes[routeId] = Math.min(...minutesArray);
127+
highestMinutes[routeId] = Math.max(...minutesArray);
128+
}
129+
130+
return {
131+
hours: padHour(hours),
132+
isNextDay,
133+
intervals,
134+
lowestMinutes,
135+
highestMinutes,
136+
};
137+
},
138+
);
139+
};
140+
141+
/**
142+
* @param {Array<{
143+
* hours: string,
144+
* isNextDay: boolean,
145+
* intervals: Object<string, number>,
146+
* lowestMinutes: Object<string, number>,
147+
* highestMinutes: Object<string, number>
148+
* }>} sorted
149+
* @param {Set<string>} routeIds
150+
* @returns {{firstDepartures: Object<string, string>,
151+
* lastDepartures: Object<string, string>}}
152+
*/
153+
const calculateFirstAndLastDepartures = (sorted, routeIds) => {
154+
const routeIdsArray = [...routeIds];
155+
const firstDepartures = {};
156+
const lastDepartures = {};
157+
for (let i = 0; i < sorted.length; i++) {
158+
if (routeIdsArray.every(routeId => routeId in firstDepartures)) {
159+
break;
160+
}
161+
for (const routeId of routeIdsArray) {
162+
if (!firstDepartures[routeId] && sorted[i].intervals[routeId]) {
163+
firstDepartures[routeId] = `${sorted[i].hours}:${padHour(
164+
sorted[i].lowestMinutes[routeId],
165+
)}`;
166+
}
167+
}
168+
}
169+
for (let i = sorted.length - 1; i >= 0; i--) {
170+
if (routeIdsArray.every(routeId => routeId in lastDepartures)) {
171+
break;
172+
}
173+
for (const routeId of routeIdsArray) {
174+
if (!lastDepartures[routeId] && sorted[i].intervals[routeId]) {
175+
lastDepartures[routeId] = `${sorted[i].hours}:${padHour(
176+
sorted[i].highestMinutes[routeId],
177+
)}`;
178+
}
179+
}
180+
}
181+
return { firstDepartures, lastDepartures };
182+
};
183+
184+
/**
185+
* @param {DepartureGroup[]} departures
186+
* @returns {{
187+
* groupedDepartures: Array<{hours: string, intervals: Object}>,
188+
* routeIds: string[],
189+
* firstDepartures: Object<string, string>,
190+
* lastDepartures: Object<string, string>
191+
* }}
192+
*/
193+
export const prepareOrderedDepartureHoursByRoute = departures => {
194+
const filteredDepartures = filterNonDepotDepartures(departures);
195+
const routeIds = new Set();
196+
const grouped = groupDeparturesByHour(filteredDepartures, routeIds);
197+
198+
const sorted = Object.values(grouped).sort((a, b) => {
199+
const aTime = +a.hours + (a.isNextDay ? 24 : 0);
200+
const bTime = +b.hours + (b.isNextDay ? 24 : 0);
201+
return aTime - bTime;
202+
});
203+
204+
const { firstDepartures, lastDepartures } = calculateFirstAndLastDepartures(sorted, routeIds);
205+
206+
const normalized = normalizeDepartures(sorted);
207+
208+
const result = mergeConsecutiveHoursWithSameDepartures(normalized);
209+
210+
return {
211+
groupedDepartures: result,
212+
routeIds: Array.from(routeIds),
213+
firstDepartures,
214+
lastDepartures,
215+
};
216+
};

0 commit comments

Comments
 (0)