Skip to content
Open
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
3 changes: 1 addition & 2 deletions .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 All @@ -20,5 +19,5 @@ jobs:
with:
checkAndTestOutsideDocker: true
codeCoverageEnabled: true
performRelease: false
performRelease: true
checkAndTestInsideDocker: false
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,15 @@ yarn-error.log
/templates
graphql.config.json
output/*
<<<<<<< Updated upstream
.env
<<<<<<< Updated upstream
.env.local
=======
=======
.env*
!.env.template
>>>>>>> Stashed changes
>>>>>>> Stashed changes
secrets
/test/results/*
40 changes: 32 additions & 8 deletions src/components/inlineSVG.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,38 @@
import React, { useRef, useEffect } from 'react';
import React, { useRef, useEffect, useMemo } from 'react';
import PropTypes from 'prop-types';

const InlineSVG = ({ src, ...otherProps }) => {
return (
<div
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: src }}
{...otherProps}
/>
);
const svgId = Math.random()
.toString(36)
.slice(2, 12);
const processedSVG = useMemo(() => {
const prefix = `svg-${svgId}`;

return src.replace(
/<style>([\s\S]*?)<\/style>|class="([^"]+)"|id="([^"]+)"/g,
(match, styles, classAttr, idAttr) => {
if (styles) {
return `<style>${styles
// Class selectors: only match when followed by { or , (selector context)
.replace(/(^|[^\w-])\.([a-zA-Z_][\w-]*)(?=\s*[{,])/gm, `$1.${prefix}-$2`)
// ID selectors: only match when followed by { or , (selector context)
.replace(/(^|[^\w-])#([a-zA-Z_][\w-]*)(?=\s*[{,])/gm, `$1#${prefix}-$2`)}</style>`;
}
if (classAttr) {
return `class="${classAttr
.split(/\s+/)
.map(c => `${prefix}-${c}`)
.join(' ')}"`;
}
if (idAttr) {
return `id="${prefix}-${idAttr}"`;
}
return match;
},
);
}, [svgId, src]);

return <div dangerouslySetInnerHTML={{ __html: processedSVG }} {...otherProps} />;

Check warning on line 35 in src/components/inlineSVG.js

View workflow job for this annotation

GitHub Actions / build-check-test-push / Build, check, test, push

Dangerous property 'dangerouslySetInnerHTML' found
};

InlineSVG.propTypes = {
Expand Down
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,
};
};
Loading
Loading