Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ name: "CI/CD: Continuous integration and continuous deployment"
tags:
- "v*"
pull_request:
merge_group:

jobs:
build-check-test-push:
Expand Down
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ yarn-error.log
/templates
graphql.config.json
output/*
.env
.env.local
.env*
!.env.template
secrets
/test/results/*
6 changes: 4 additions & 2 deletions src/components/stopPoster/stopPoster.js
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ class StopPoster extends Component {
minimapZoneSymbols,
minimapZones,
legend,
intervalTimetable,
} = this.props;
if (!hasRoutesProp) {
return null;
Expand All @@ -353,6 +354,7 @@ class StopPoster extends Component {
const StopPosterTimetable = props => (
<div className={styles.timetable}>
<Timetable
intervalTimetable={intervalTimetable}
stopId={stopId}
date={date}
isSummerTimetable={isSummerTimetable}
Expand Down Expand Up @@ -426,13 +428,11 @@ class StopPoster extends Component {
<div className={styles.timetables}>
<StopPosterTimetable
segments={['saturdays']}
hideDetails
routeFilter={this.props.routeFilter}
/>
<Spacer width={10} />
<StopPosterTimetable
segments={['sundays']}
hideDetails
routeFilter={this.props.routeFilter}
/>
</div>
Expand Down Expand Up @@ -491,6 +491,7 @@ class StopPoster extends Component {
}

StopPoster.propTypes = {
intervalTimetable: PropTypes.bool,
stopId: PropTypes.string.isRequired,
date: PropTypes.string.isRequired,
isSummerTimetable: PropTypes.bool,
Expand All @@ -512,6 +513,7 @@ StopPoster.propTypes = {
};

StopPoster.defaultProps = {
intervalTimetable: false,
isSummerTimetable: false,
dateBegin: null,
dateEnd: null,
Expand Down
216 changes: 216 additions & 0 deletions src/components/timetable/departureUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import mapValues from 'lodash/mapValues';
import groupBy from 'lodash/groupBy';
import mean from 'lodash/mean';
import sortBy from 'lodash/sortBy';
import padStart from 'lodash/padStart';
import omit from 'lodash/omit';
import cloneDeep from 'lodash/cloneDeep';
import { trimRouteId } from 'util/domain';
import { normalizeDepartures } from './intervalsNormalizer.mjs';

/**
* @typedef {Object} DepartureGroup
* @property {number} hours
* @property {number} minutes
* @property {?string} note
* @property {string} routeId
* @property {string} direction
* @property {string[]} dayType
* @property {boolean} isNextDay
* @property {boolean} isAccessible
* @property {string} dateBegin
* @property {string} dateEnd
* @property {string} __typename
*/

/**
* @typedef {Object} HourInterval
* @property {string} hours - single hour or range like "05-07"
* @property {number} avgInterval - average interval in minutes, 60 if only one departure
*/

const DEPOT_RUNS_LETTER = 'H';

/**
* @param {DepartureGroup[]} departures
* @returns {DepartureGroup[]}
*/
const filterNonDepotDepartures = departures =>
departures.filter(d => !d.routeId.includes(DEPOT_RUNS_LETTER));

/**
* @param {number} n
* @returns {string}
*/
const padHour = n => padStart(String(n), 2, '0');

/**
* @param {Array<{hours: string, intervals: Object}>} entries
* @returns {Array<{hours: string, intervals: Object}>}
*/
const mergeConsecutiveHoursWithSameDepartures = entries => {
if (!entries.length) return [];

const merged = [];
let { hours: startHour, intervals: prevIntervals } = entries[0];
let endHour = startHour;

for (let i = 1; i < entries.length; i++) {
const { hours: currentHour, intervals } = entries[i];
const prevHourNum = parseInt(endHour, 10);

const sameDepartures = JSON.stringify(intervals) === JSON.stringify(prevIntervals);

if (sameDepartures && parseInt(currentHour, 10) === prevHourNum + 1) {
endHour = currentHour;
} else {
merged.push({
hours: startHour === endHour ? startHour : `${startHour}-${endHour}`,
intervals: prevIntervals,
});
startHour = currentHour;
endHour = currentHour;
prevIntervals = intervals;
}
}

merged.push({
hours: startHour === endHour ? startHour : `${startHour}-${endHour}`,
intervals: prevIntervals,
});

return merged;
};

/**
* @param {number[]} nums
* @returns {number}
*/
const calculateAverageInterval = nums => {
if (nums.length < 2) return 60;
const sorted = [...nums].sort((a, b) => a - b);
const intervals = sorted.slice(1).map((v, i) => v - sorted[i]);
return Math.round(mean(intervals));
};

/**
* @param {DepartureGroup[]} filteredDepartures
* @param {Set<string>} routeIds
* @returns {Object<string, {
* hours: string,
* isNextDay: boolean,
* intervals: Object<string, number>,
* lowestMinutes: Object<string, number>,
* highestMinutes: Object<string, number>
* }>}
*/
const groupDeparturesByHour = (filteredDepartures, routeIds) => {
return mapValues(
groupBy(filteredDepartures, d => `${d.hours}_${d.isNextDay}`),
hourGroup => {
const { hours, isNextDay } = hourGroup[0];

const routeGroups = groupBy(hourGroup, item => {
const trimmedRouteId = trimRouteId(item.routeId);
routeIds.add(trimmedRouteId);
return trimmedRouteId;
});

const intervals = {};
const lowestMinutes = {};
const highestMinutes = {};

for (const [routeId, items] of Object.entries(routeGroups)) {
const minutesArray = items.map(item => item.minutes);
intervals[routeId] = calculateAverageInterval(minutesArray);
lowestMinutes[routeId] = Math.min(...minutesArray);
highestMinutes[routeId] = Math.max(...minutesArray);
}

return {
hours: padHour(hours),
isNextDay,
intervals,
lowestMinutes,
highestMinutes,
};
},
);
};

/**
* @param {Array<{
* hours: string,
* isNextDay: boolean,
* intervals: Object<string, number>,
* lowestMinutes: Object<string, number>,
* highestMinutes: Object<string, number>
* }>} sorted
* @param {Set<string>} routeIds
* @returns {{firstDepartures: Object<string, string>,
* lastDepartures: Object<string, string>}}
*/
const calculateFirstAndLastDepartures = (sorted, routeIds) => {
const routeIdsArray = [...routeIds];
const firstDepartures = {};
const lastDepartures = {};
for (let i = 0; i < sorted.length; i++) {
if (routeIdsArray.every(routeId => routeId in firstDepartures)) {
break;
}
for (const routeId of routeIdsArray) {
if (!firstDepartures[routeId] && sorted[i].intervals[routeId]) {
firstDepartures[routeId] = `${sorted[i].hours}:${padHour(
sorted[i].lowestMinutes[routeId],
)}`;
}
}
}
for (let i = sorted.length - 1; i >= 0; i--) {
if (routeIdsArray.every(routeId => routeId in lastDepartures)) {
break;
}
for (const routeId of routeIdsArray) {
if (!lastDepartures[routeId] && sorted[i].intervals[routeId]) {
lastDepartures[routeId] = `${sorted[i].hours}:${padHour(
sorted[i].highestMinutes[routeId],
)}`;
}
}
}
return { firstDepartures, lastDepartures };
};

/**
* @param {DepartureGroup[]} departures
* @returns {{
* groupedDepartures: Array<{hours: string, intervals: Object}>,
* routeIds: string[],
* firstDepartures: Object<string, string>,
* lastDepartures: Object<string, string>
* }}
*/
export const prepareOrderedDepartureHoursByRoute = departures => {
const filteredDepartures = filterNonDepotDepartures(departures);
const routeIds = new Set();
const grouped = groupDeparturesByHour(filteredDepartures, routeIds);

const sorted = Object.values(grouped).sort((a, b) => {
const aTime = +a.hours + (a.isNextDay ? 24 : 0);
const bTime = +b.hours + (b.isNextDay ? 24 : 0);
return aTime - bTime;
});

const { firstDepartures, lastDepartures } = calculateFirstAndLastDepartures(sorted, routeIds);

const normalized = normalizeDepartures(sorted);

const result = mergeConsecutiveHoursWithSameDepartures(normalized);

return {
groupedDepartures: result,
routeIds: Array.from(routeIds),
firstDepartures,
lastDepartures,
};
};
116 changes: 116 additions & 0 deletions src/components/timetable/intervalTimetable.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
.routeHeadings {
display: flex;
gap: 0.2em;
font-size: 20px;
align-items: center;
}

.interval {
color: var(--hsl-blue);
}

.timetableRoot {
color: black;
}

.timetableRoot > * {
margin: 0 0 0 calc(-1 * var(--border-radius));
padding: 0.2em calc(0.45em + var(--border-radius));
}

.timetableRoot > *:nth-child(odd) {
background-color: var(--timetable-accent-color);
}

.firstAndLastDepartures {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
gap: 0.2em;
padding: 0.2em 0 0.2em calc(0.45em + var(--border-radius));
margin: 0 0 0 calc(-1 * var(--border-radius));
}

.firstAndLastDepartures div {
display: flex;
justify-content: center;
}

.departureTitles {
flex-direction: column;
}

.firstAndLastDepartureValues {
align-items: center;
justify-content: center;
}

.timetableRoutes {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
padding: 0 0 0.5em 0.45em;
gap: 0.2em;
}

.timetableRoutes div {
justify-content: center;
}

.timetableMinutes {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
}

.icon {
width: 20px;
height: 20px;
justify-content: center;
}

.compactPaddingRight {
padding-right: calc(0.45em + var(--border-radius));
}

.routeHeadingsNonCompact {
padding: 0.2em 0 0.2em calc(0.45em + var(--border-radius));
}

.timetableMinutesNonCompact {
padding: 0.2em 0 0.2em calc(0.45em + var(--border-radius));
}

.flexContainer {
display: flex;
justify-content: space-between;
gap: calc((1 * var(--border-radius)) + 8px);
}

.leftPanel {
flex: 1;
}

.busRoutesContainer {
display: flex;
gap: 12px;
margin-left: calc(-1 * var(--border-radius));
padding: 0 0 0.4em 0.938em;
}

.rightPanel {
flex: 1;
}

.hours {
/* Default styles for hours div */
}

.hoursLong {
font-size: 19px;
}

.timetableMinutesLong {
height: 30px;
}

.intervalLong {
font-size: 19px;
}
Loading
Loading