Skip to content

Commit b132ec9

Browse files
authored
AB#64925 interval timetable (#538)
* AB#64925 interval timetable
1 parent 8468f80 commit b132ec9

File tree

16 files changed

+997
-67
lines changed

16 files changed

+997
-67
lines changed

.github/workflows/ci-cd.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ name: "CI/CD: Continuous integration and continuous deployment"
99
tags:
1010
- "v*"
1111
pull_request:
12-
merge_group:
1312

1413
jobs:
1514
build-check-test-push:

.gitignore

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ yarn-error.log
88
/templates
99
graphql.config.json
1010
output/*
11-
.env
12-
.env.local
11+
.env*
12+
!.env.template
1313
secrets
1414
/test/results/*

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+
};
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
.routeHeadings {
2+
display: flex;
3+
gap: 0.2em;
4+
font-size: 20px;
5+
align-items: center;
6+
}
7+
8+
.interval {
9+
color: var(--hsl-blue);
10+
}
11+
12+
.timetableRoot {
13+
color: black;
14+
}
15+
16+
.timetableRoot > * {
17+
margin: 0 0 0 calc(-1 * var(--border-radius));
18+
padding: 0.2em calc(0.45em + var(--border-radius));
19+
}
20+
21+
.timetableRoot > *:nth-child(odd) {
22+
background-color: var(--timetable-accent-color);
23+
}
24+
25+
.firstAndLastDepartures {
26+
display: grid;
27+
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
28+
gap: 0.2em;
29+
padding: 0.2em 0 0.2em calc(0.45em + var(--border-radius));
30+
margin: 0 0 0 calc(-1 * var(--border-radius));
31+
}
32+
33+
.firstAndLastDepartures div {
34+
display: flex;
35+
justify-content: center;
36+
}
37+
38+
.departureTitles {
39+
flex-direction: column;
40+
}
41+
42+
.firstAndLastDepartureValues {
43+
align-items: center;
44+
justify-content: center;
45+
}
46+
47+
.timetableRoutes {
48+
display: grid;
49+
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
50+
padding: 0 0 0.5em 0.45em;
51+
gap: 0.2em;
52+
}
53+
54+
.timetableRoutes div {
55+
justify-content: center;
56+
}
57+
58+
.timetableMinutes {
59+
display: grid;
60+
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
61+
}
62+
63+
.icon {
64+
width: 20px;
65+
height: 20px;
66+
justify-content: center;
67+
}
68+
69+
.compactPaddingRight {
70+
padding-right: calc(0.45em + var(--border-radius));
71+
}
72+
73+
.routeHeadingsNonCompact {
74+
padding: 0.2em 0 0.2em calc(0.45em + var(--border-radius));
75+
}
76+
77+
.timetableMinutesNonCompact {
78+
padding: 0.2em 0 0.2em calc(0.45em + var(--border-radius));
79+
}
80+
81+
.flexContainer {
82+
display: flex;
83+
justify-content: space-between;
84+
gap: calc((1 * var(--border-radius)) + 8px);
85+
}
86+
87+
.leftPanel {
88+
flex: 1;
89+
}
90+
91+
.busRoutesContainer {
92+
display: flex;
93+
gap: 12px;
94+
margin-left: calc(-1 * var(--border-radius));
95+
padding: 0 0 0.4em 0.938em;
96+
}
97+
98+
.rightPanel {
99+
flex: 1;
100+
}
101+
102+
.hours {
103+
/* Default styles for hours div */
104+
}
105+
106+
.hoursLong {
107+
font-size: 19px;
108+
}
109+
110+
.timetableMinutesLong {
111+
height: 30px;
112+
}
113+
114+
.intervalLong {
115+
font-size: 19px;
116+
}

0 commit comments

Comments
 (0)