@@ -197,6 +208,7 @@ class Timetable extends Component {
dayNames.length > 1
? `${getWeekdayName(dayNames[0], 'en')} - ${getWeekdayName(dayNames[1], 'en')}`
: `${getWeekdayName(dayNames[0], 'en')}`;
+
return (
-
+ {intervalTimetable ? (
+
+ ) : (
+
+ )}
);
})}
@@ -223,6 +246,7 @@ class Timetable extends Component {
}
Timetable.defaultProps = {
+ intervalTimetable: false,
saturdays: null,
sundays: null,
isSummerTimetable: false,
@@ -241,9 +265,11 @@ Timetable.defaultProps = {
lang: 'fi',
showCoverPage: false,
useCompactLayout: false,
+ routeIdToModeMap: {},
};
Timetable.propTypes = {
+ intervalTimetable: PropTypes.bool,
saturdays: PropTypes.arrayOf(PropTypes.shape(TableRows.propTypes.departures)),
sundays: PropTypes.arrayOf(PropTypes.shape(TableRows.propTypes.departures)),
notes: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired,
@@ -274,6 +300,7 @@ Timetable.propTypes = {
lang: PropTypes.string,
showCoverPage: PropTypes.bool,
useCompactLayout: PropTypes.bool,
+ routeIdToModeMap: PropTypes.object,
};
export default Timetable;
diff --git a/src/components/timetable/timetableContainer.js b/src/components/timetable/timetableContainer.js
index c2095ce8..3a6cf43f 100644
--- a/src/components/timetable/timetableContainer.js
+++ b/src/components/timetable/timetableContainer.js
@@ -8,9 +8,11 @@ import flatMap from 'lodash/flatMap';
import groupBy from 'lodash/groupBy';
import uniq from 'lodash/uniq';
import pick from 'lodash/pick';
+import fromPairs from 'lodash/fromPairs';
+import get from 'lodash/get';
import apolloWrapper from 'util/apolloWrapper';
-import { isDropOffOnly, trimRouteId, filterRoute } from 'util/domain';
+import { isDropOffOnly, trimRouteId, filterRoute, isNumberVariant } from 'util/domain';
import Timetable from './timetable';
@@ -224,6 +226,7 @@ const timetableQuery = gql`
nodes {
destinationFi
destinationSe
+ mode
}
}
notes(date: $date) {
@@ -232,6 +235,11 @@ const timetableQuery = gql`
noteType
}
}
+ line {
+ nodes {
+ trunkRoute
+ }
+ }
}
}
@@ -294,6 +302,25 @@ const propsMapper = mapProps(props => {
filterDepartures(stop.departures.nodes, stop.routeSegments.nodes, props.routeFilter),
);
+ const routeIdToModeMap = fromPairs(
+ flatMap(props.data.stop.siblings.nodes, sibling =>
+ sibling.routeSegments.nodes
+ .filter(routeSegment => routeSegment.hasRegularDayDepartures === true)
+ .filter(routeSegment => !isNumberVariant(routeSegment.routeId))
+ .filter(routeSegment => !isDropOffOnly(routeSegment))
+ .filter(routeSegment =>
+ filterRoute({ routeId: routeSegment.routeId, filter: props.routeFilter }),
+ )
+ .map(seg => [
+ trimRouteId(seg.routeId),
+ {
+ mode: get(seg, 'route.nodes[0].mode'),
+ trunkRoute: seg.line.nodes[0].trunkRoute === '1',
+ },
+ ]),
+ ),
+ );
+
let notes = flatMap(props.data.stop.siblings.nodes, stop =>
flatMap(stop.routeSegments.nodes, getNotes(props.isSummerTimetable)),
);
@@ -416,6 +443,7 @@ const propsMapper = mapProps(props => {
notes,
dateBegin,
dateEnd,
+ intervalTimetable: props.intervalTimetable,
date: props.date,
isSummerTimetable: props.isSummerTimetable,
showValidityPeriod: props.showValidityPeriod,
@@ -441,6 +469,7 @@ const propsMapper = mapProps(props => {
lang: props.lang,
showCoverPage: props.showCoverPage,
useCompactLayout: props.useCompactLayout,
+ routeIdToModeMap,
};
});
@@ -449,6 +478,7 @@ const hoc = compose(graphql(timetableQuery), apolloWrapper(propsMapper));
const TimetableContainer = hoc(Timetable);
TimetableContainer.defaultProps = {
+ intervalTimetable: false,
dateBegin: null,
dateEnd: null,
isSummerTimetable: false,
@@ -465,9 +495,11 @@ TimetableContainer.defaultProps = {
lang: 'fi',
showCoverPage: false,
useCompactLayout: false,
+ routeIdToModeMap: {},
};
TimetableContainer.propTypes = {
+ intervalTimetable: PropTypes.bool,
stopId: PropTypes.string.isRequired,
date: PropTypes.string.isRequired,
dateBegin: PropTypes.string,
@@ -479,6 +511,7 @@ TimetableContainer.propTypes = {
showComponentName: PropTypes.bool,
standalone: PropTypes.bool,
printTimetablesAsA4: PropTypes.bool,
+ intervalTimeTable: PropTypes.bool,
printTimetablesAsGreyscale: PropTypes.bool,
specialSymbols: PropTypes.array,
showStopInformation: PropTypes.bool,
@@ -488,6 +521,7 @@ TimetableContainer.propTypes = {
lang: PropTypes.string,
showCoverPage: PropTypes.bool,
useCompactLayout: PropTypes.bool,
+ routeIdToModeMap: PropTypes.object,
};
export default TimetableContainer;
diff --git a/src/icons/clock.svg b/src/icons/clock.svg
new file mode 100644
index 00000000..a4721fe8
--- /dev/null
+++ b/src/icons/clock.svg
@@ -0,0 +1,64 @@
+
+
\ No newline at end of file
diff --git a/src/util/domain.js b/src/util/domain.js
index 6e7b153e..015e453e 100644
--- a/src/util/domain.js
+++ b/src/util/domain.js
@@ -7,7 +7,8 @@ import trunkIcon from 'icons/icon_trunk.svg';
import lRailIcon from 'icons/icon_L_rail.svg';
import zoneByShortId from 'data/zoneByShortId';
-import { weekdays } from 'moment/moment';
+
+export const BUS_MODE = 'BUS';
// TODO: Get routes from api?
const RAIL_ROUTE_ID_REGEXP = /^300[12]/;
@@ -60,29 +61,32 @@ function isULine(routeId) {
* @returns {String}
*/
function trimRouteId(routeId, skipULine) {
- if (isRailRoute(routeId) && isNumberVariant(routeId)) {
- return routeId.substring(0, 5).replace(RAIL_ROUTE_ID_REGEXP, '');
- }
- if (isRailRoute(routeId)) {
- return routeId.replace(RAIL_ROUTE_ID_REGEXP, '');
- }
- if (isSubwayRoute(routeId) && isNumberVariant(routeId)) {
- return routeId.substring(1, 5).replace(SUBWAY_ROUTE_ID_REGEXP, '');
- }
- if (isSubwayRoute(routeId)) {
- return routeId.replace(SUBWAY_ROUTE_ID_REGEXP, '');
- }
+ const trimAreaCodeAndLeadingZeros = () => {
+ if (isRailRoute(routeId) && isNumberVariant(routeId)) {
+ return routeId.substring(0, 5).replace(RAIL_ROUTE_ID_REGEXP, '');
+ }
+ if (isRailRoute(routeId)) {
+ return routeId.replace(RAIL_ROUTE_ID_REGEXP, '');
+ }
+ if (isSubwayRoute(routeId) && isNumberVariant(routeId)) {
+ return routeId.substring(1, 5).replace(SUBWAY_ROUTE_ID_REGEXP, '');
+ }
+ if (isSubwayRoute(routeId)) {
+ return routeId.replace(SUBWAY_ROUTE_ID_REGEXP, '');
+ }
- if (isULine(routeId) && !skipULine) {
- return routeId.substring(0, 5).replace(U_LINE_REGEX, 'U');
- }
+ if (isULine(routeId) && !skipULine) {
+ return routeId.substring(0, 5).replace(U_LINE_REGEX, 'U');
+ }
- if (isNumberVariant(routeId)) {
- // Do not show number variants
- return routeId.substring(1, 5).replace(/^[0]+/g, '');
- }
+ if (isNumberVariant(routeId)) {
+ // Do not show number variants
+ return routeId.substring(1, 5).replace(/^[0]+/g, '');
+ }
- return routeId.substring(1).replace(/^[0]+/g, '');
+ return routeId.substring(1).replace(/^[0]+/g, '');
+ };
+ return trimAreaCodeAndLeadingZeros().trim();
}
/**
@@ -113,14 +117,14 @@ const colorsByMode = {
TRAM: '#00985f',
RAIL: '#8c4799',
SUBWAY: '#ff6319',
- BUS: '#007AC9',
+ [BUS_MODE]: '#007AC9',
FERRY: '#00B9E4',
L_RAIL: '#0098A1',
LIGHT_L_RAIL: '#e5f4f5',
};
const iconsByMode = {
- BUS: busIcon,
+ [BUS_MODE]: busIcon,
TRAM: tramIcon,
RAIL: railIcon,
SUBWAY: subwayIcon,
diff --git a/src/util/processSVG.js b/src/util/processSVG.js
new file mode 100644
index 00000000..cb0b45db
--- /dev/null
+++ b/src/util/processSVG.js
@@ -0,0 +1,53 @@
+const uuidv4 = require('uuid/v4');
+
+// Pre-compile regexes outside the function
+const MAIN_REGEX = /`;
+ }
+ if (classAttr) {
+ // Use replace instead of split/map/join to avoid array allocation
+ return `class="${classAttr.replace(CLASS_ATTR_REGEX, c => `${prefix}-${c}`)}"`;
+ }
+ if (idAttr) return `id="${prefix}-${idAttr}"`;
+ if (urlId) return `url(#${prefix}-${urlId})`;
+ if (xlinkHref) return `xlink:href="#${prefix}-${xlinkHref}"`;
+ if (hrefAttr) return `href="#${prefix}-${hrefAttr}"`;
+ return match;
+ },
+ );
+};
+
+module.exports = {
+ processSVGWithUniqueIds,
+};
diff --git a/test/svg-processing/no_smoking.svg b/test/svg-processing/no_smoking.svg
new file mode 100644
index 00000000..a9a780fe
--- /dev/null
+++ b/test/svg-processing/no_smoking.svg
@@ -0,0 +1,50 @@
+
+
\ No newline at end of file
diff --git a/test/svg-processing/processSVG.test.mjs b/test/svg-processing/processSVG.test.mjs
new file mode 100644
index 00000000..9bd58645
--- /dev/null
+++ b/test/svg-processing/processSVG.test.mjs
@@ -0,0 +1,111 @@
+import test from 'node:test';
+import assert from 'node:assert';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import puppeteer from 'puppeteer';
+import { processSVGWithUniqueIds } from '../../src/util/processSVG.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const failedDir = path.join(__dirname, 'failed');
+const processedDir = path.join(__dirname, 'processed');
+
+// delete failed directory before running tests
+if (fs.existsSync(failedDir)) {
+ fs.rmSync(failedDir, { recursive: true });
+}
+
+// Get all SVG files in the current directory
+const svgFiles = fs.readdirSync(__dirname).filter(file => file.endsWith('.svg'));
+
+async function renderSvgToBuffer(browser, svgContent) {
+ const page = await browser.newPage();
+ await page.setViewport({ width: 600, height: 400 });
+
+ const html = `
+
+
+
+
+
+ ${svgContent}
+
+ `;
+
+ await page.setContent(html, { waitUntil: 'networkidle0' });
+ const screenshot = await page.screenshot({ type: 'png' });
+ await page.close();
+ return screenshot;
+}
+
+function buffersAreEqual(buf1, buf2) {
+ if (buf1.length !== buf2.length) return false;
+ return buf1.equals(buf2);
+}
+
+for (const svgFile of svgFiles) {
+ test(`processSVGWithUniqueIds visual regression - ${svgFile} should render identically`, async () => {
+ const svgPath = path.join(__dirname, svgFile);
+ const originalSvg = fs.readFileSync(svgPath, 'utf-8');
+ const processedSvg = processSVGWithUniqueIds(originalSvg);
+
+ const browser = await puppeteer.launch({ headless: true });
+
+ try {
+ const originalScreenshot = await renderSvgToBuffer(browser, originalSvg);
+ const processedScreenshot = await renderSvgToBuffer(browser, processedSvg);
+
+ const isEqual = buffersAreEqual(originalScreenshot, processedScreenshot);
+
+ if (!isEqual) {
+ if (!fs.existsSync(failedDir)) {
+ fs.mkdirSync(failedDir, { recursive: true });
+ }
+ const processedPath = path.join(failedDir, `processed_${svgFile}`);
+ fs.writeFileSync(processedPath, processedSvg);
+ console.log(`Saved processed SVG to: ${processedPath}`);
+ }
+
+ assert.ok(
+ isEqual,
+ `Processed SVG (${svgFile}) should render identically to the original SVG`,
+ );
+ } finally {
+ await browser.close();
+ }
+ });
+
+ test(`processSVGWithUniqueIds idempotency - ${svgFile} should render identically after double processing`, async () => {
+ const svgPath = path.join(__dirname, svgFile);
+ const originalSvg = fs.readFileSync(svgPath, 'utf-8');
+ const processedOnce = processSVGWithUniqueIds(originalSvg);
+ const processedTwice = processSVGWithUniqueIds(processedOnce);
+
+ const browser = await puppeteer.launch({ headless: true });
+
+ try {
+ const originalScreenshot = await renderSvgToBuffer(browser, originalSvg);
+ const doubleProcessedScreenshot = await renderSvgToBuffer(browser, processedTwice);
+
+ const isEqual = buffersAreEqual(originalScreenshot, doubleProcessedScreenshot);
+
+ if (!isEqual) {
+ if (!fs.existsSync(failedDir)) {
+ fs.mkdirSync(failedDir, { recursive: true });
+ }
+ const processedPath = path.join(failedDir, `double_processed_${svgFile}`);
+ fs.writeFileSync(processedPath, processedTwice);
+ console.log(`Saved double-processed SVG to: ${processedPath}`);
+ }
+
+ assert.ok(
+ isEqual,
+ `Double-processed SVG (${svgFile}) should render identically to the original SVG`,
+ );
+ } finally {
+ await browser.close();
+ }
+ });
+}
diff --git a/test/svg-processing/test_svg.svg b/test/svg-processing/test_svg.svg
new file mode 100644
index 00000000..ee5c6f4e
--- /dev/null
+++ b/test/svg-processing/test_svg.svg
@@ -0,0 +1,154 @@
+
+
\ No newline at end of file
diff --git a/test/svg-processing/tram_map.svg b/test/svg-processing/tram_map.svg
new file mode 100644
index 00000000..53923f0e
--- /dev/null
+++ b/test/svg-processing/tram_map.svg
@@ -0,0 +1,13562 @@
+
+
\ No newline at end of file
diff --git a/test/timetable/intervalMerging.test.mjs b/test/timetable/intervalMerging.test.mjs
new file mode 100644
index 00000000..ba0a76ab
--- /dev/null
+++ b/test/timetable/intervalMerging.test.mjs
@@ -0,0 +1,161 @@
+import test from 'node:test';
+import assert from 'node:assert';
+import { normalizeDepartures } from '../../src/components/timetable/intervalsNormalizer.mjs';
+
+function assertNormalized(input, expected) {
+ const output = normalizeDepartures(input);
+ assert.deepStrictEqual(output, expected);
+}
+
+test('No change', () => {
+ assertNormalized(
+ [
+ { hours: '06', intervals: { '7': 10 } },
+ { hours: '07', intervals: { '7': 10 } },
+ ],
+ [
+ { hours: '06', intervals: { '7': 10 } },
+ { hours: '07', intervals: { '7': 10 } },
+ ],
+ );
+});
+
+test('Single key basic normalize x', () => {
+ assertNormalized(
+ [
+ { hours: '01', intervals: { '7': 9 } },
+ { hours: '02', intervals: { '7': 10 } },
+ { hours: '03', intervals: { '7': 12 } },
+ { hours: '04', intervals: { '7': 11 } },
+ ],
+ [
+ { hours: '01', intervals: { '7': 9 } },
+ { hours: '02', intervals: { '7': 9 } },
+ { hours: '03', intervals: { '7': 11 } },
+ { hours: '04', intervals: { '7': 11 } },
+ ],
+ );
+});
+
+test('Real stop data test', () => {
+ assertNormalized(
+ [
+ { hours: '05', intervals: { '7': 12 } },
+ { hours: '06', intervals: { '7': 10, '16': 18 } },
+ { hours: '07', intervals: { '7': 9, '16': 20 } },
+ { hours: '08', intervals: { '7': 10, '16': 21 } },
+ { hours: '09', intervals: { '7': 10, '16': 22 } },
+ { hours: '10', intervals: { '7': 10, '16': 21 } },
+ { hours: '11', intervals: { '7': 10, '16': 21 } },
+ { hours: '12', intervals: { '7': 10, '16': 21 } },
+ { hours: '13', intervals: { '7': 10, '16': 22 } },
+ { hours: '14', intervals: { '7': 10, '16': 21 } },
+ { hours: '15', intervals: { '7': 10, '16': 22 } },
+ { hours: '16', intervals: { '7': 10, '16': 21 } },
+ { hours: '17', intervals: { '7': 10, '16': 21 } },
+ { hours: '18', intervals: { '7': 10, '16': 20 } },
+ { hours: '19', intervals: { '7': 12, '16': 20 } },
+ { hours: '20', intervals: { '7': 12, '16': 30 } },
+ { hours: '21', intervals: { '7': 12, '16': 29 } },
+ { hours: '22', intervals: { '7': 11 } },
+ { hours: '23', intervals: { '7': 20 } },
+ { hours: '00', intervals: { '7': 20 } },
+ { hours: '01', intervals: { '7': 60 } },
+ ],
+ [
+ { hours: '05', intervals: { '7': 12 } },
+ { hours: '06', intervals: { '7': 9, '16': 18 } },
+ { hours: '07', intervals: { '7': 9, '16': 20 } },
+ { hours: '08', intervals: { '7': 9, '16': 20 } },
+ { hours: '09', intervals: { '7': 9, '16': 21 } },
+ { hours: '10', intervals: { '7': 9, '16': 21 } },
+ { hours: '11', intervals: { '7': 9, '16': 21 } },
+ { hours: '12', intervals: { '7': 9, '16': 21 } },
+ { hours: '13', intervals: { '7': 9, '16': 21 } },
+ { hours: '14', intervals: { '7': 9, '16': 21 } },
+ { hours: '15', intervals: { '7': 9, '16': 21 } },
+ { hours: '16', intervals: { '7': 9, '16': 21 } },
+ { hours: '17', intervals: { '7': 9, '16': 21 } },
+ { hours: '18', intervals: { '7': 9, '16': 20 } },
+ { hours: '19', intervals: { '7': 11, '16': 20 } },
+ { hours: '20', intervals: { '7': 11, '16': 29 } },
+ { hours: '21', intervals: { '7': 11, '16': 29 } },
+ { hours: '22', intervals: { '7': 11 } },
+ { hours: '23', intervals: { '7': 20 } },
+ { hours: '00', intervals: { '7': 20 } },
+ { hours: '01', intervals: { '7': 60 } },
+ ],
+ );
+});
+
+test('Single key basic normalize', () => {
+ assertNormalized(
+ [
+ { hours: '01', intervals: { '7': 3 } },
+ { hours: '02', intervals: { '7': 2 } },
+ { hours: '03', intervals: { '7': 1 } },
+ { hours: '04', intervals: { '7': 0 } },
+ { hours: '05', intervals: { '7': 2 } },
+ { hours: '06', intervals: { '7': 3 } },
+ { hours: '07', intervals: { '7': 3 } },
+ ],
+ [
+ { hours: '01', intervals: { '7': 2 } },
+ { hours: '02', intervals: { '7': 2 } },
+ { hours: '03', intervals: { '7': 0 } },
+ { hours: '04', intervals: { '7': 0 } },
+ { hours: '05', intervals: { '7': 2 } },
+ { hours: '06', intervals: { '7': 2 } },
+ { hours: '07', intervals: { '7': 2 } },
+ ],
+ );
+});
+
+test('Multi-key normalization', () => {
+ assertNormalized(
+ [
+ { hours: '06', intervals: { '7': 15 } },
+ { hours: '07', intervals: { '7': 15, '16': 31 } },
+ { hours: '08', intervals: { '7': 16, '16': 20 } },
+ ],
+ [
+ { hours: '06', intervals: { '7': 15 } },
+ { hours: '07', intervals: { '7': 15, '16': 31 } },
+ { hours: '08', intervals: { '7': 15, '16': 20 } },
+ ],
+ );
+});
+
+test('Missing keys handled safely', () => {
+ assertNormalized(
+ [
+ { hours: '01', intervals: { '7': 5 } },
+ { hours: '02', intervals: {} },
+ { hours: '03', intervals: { '7': 3 } },
+ ],
+ [
+ { hours: '01', intervals: { '7': 5 } },
+ { hours: '02', intervals: {} },
+ { hours: '03', intervals: { '7': 3 } },
+ ],
+ );
+});
+
+test('Long chain normalize', () => {
+ assertNormalized(
+ [
+ { hours: '01', intervals: { '7': 5 } },
+ { hours: '02', intervals: { '7': 4 } },
+ { hours: '03', intervals: { '7': 3 } },
+ { hours: '04', intervals: { '7': 4 } },
+ { hours: '05', intervals: { '7': 5 } },
+ ],
+ [
+ { hours: '01', intervals: { '7': 4 } },
+ { hours: '02', intervals: { '7': 4 } },
+ { hours: '03', intervals: { '7': 3 } },
+ { hours: '04', intervals: { '7': 3 } },
+ { hours: '05', intervals: { '7': 5 } },
+ ],
+ );
+});