From 8148057a09b0701b049ed9f66d399303bd86828a Mon Sep 17 00:00:00 2001 From: Sebastian Szeszko Date: Tue, 26 May 2026 12:04:49 +0200 Subject: [PATCH 1/3] interval timetable changes --- src/components/timetable/departureUtils.js | 43 +++ .../timetable/intervalTimetable.css | 212 +++++++++++---- src/components/timetable/intervalTimetable.js | 252 +++++++++++------- src/components/timetable/timetable.css | 5 + src/components/timetable/timetable.js | 1 + 5 files changed, 360 insertions(+), 153 deletions(-) diff --git a/src/components/timetable/departureUtils.js b/src/components/timetable/departureUtils.js index 608e4486..74af8bf4 100644 --- a/src/components/timetable/departureUtils.js +++ b/src/components/timetable/departureUtils.js @@ -214,3 +214,46 @@ export const prepareOrderedDepartureHoursByRoute = departures => { lastDepartures, }; }; + +/** + * Groups route IDs by their mode+trunk combination. + * Routes in the same group share mode and trunkRoute flag. + * + * @param {string[]} routeIds + * @param {Object.} routeIdToModeMap + * @returns {Array<{key: string, routeIds: string[], mode: string, trunkRoute: boolean}>} + * Ordered list of groups preserving original route order. + */ +export const groupRoutesByModeAndTrunk = (routeIds, routeIdToModeMap) => { + const groupMap = new Map(); + const groupOrder = []; + + for (const routeId of routeIds) { + const desc = routeIdToModeMap[routeId]; + if (!desc) continue; + const key = `${desc.mode}_${desc.trunkRoute ? '1' : '0'}`; + if (!groupMap.has(key)) { + groupMap.set(key, { key, routeIds: [], mode: desc.mode, trunkRoute: !!desc.trunkRoute }); + groupOrder.push(key); + } + groupMap.get(key).routeIds.push(routeId); + } + + return groupOrder.map(k => groupMap.get(k)); +}; + +/** + * For a group with 2+ routes, computes the synthetic "combined" column data: + * - intervals: max interval per hour range across all routes in the group + * - firstDepartures / lastDepartures: always null (shown as blank in the UI) + * + * @param {string[]} groupRouteIds + * @param {Array<{hours: string, intervals: Object.}>} groupedDepartures + * @returns {Array<{hours: string, maxInterval: number|null}>} + */ +export const computeCombinedColumn = (groupRouteIds, groupedDepartures) => { + return groupedDepartures.map(({ hours, intervals }) => { + const vals = groupRouteIds.map(id => intervals[id]).filter(v => v != null); + return { hours, maxInterval: vals.length > 0 ? Math.max(...vals) : null }; + }); +}; diff --git a/src/components/timetable/intervalTimetable.css b/src/components/timetable/intervalTimetable.css index 97777bba..683f3591 100644 --- a/src/components/timetable/intervalTimetable.css +++ b/src/components/timetable/intervalTimetable.css @@ -1,83 +1,185 @@ -.routeHeadings { +/* ─── Outer container ────────────────────────────────────────────────────── */ + +.intervalDisplay { + position: relative; + color: black; + /* Stretch to fill .root including its horizontal padding */ + margin-left: calc(-1 * var(--border-radius) / 2); + margin-right: calc(-1 * var(--border-radius) / 2); + padding-left: calc(var(--border-radius) / 2); + padding-right: calc(var(--border-radius) / 2); +} + +/* ─── Stripe background — absolutely fills the container ─────────────────── */ + +.stripeBackground { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; display: flex; - gap: 0.2em; - font-size: 20px; - align-items: center; + flex-direction: column; + pointer-events: none; + /* padding-top/bottom set inline to align stripes with interval rows */ } -.interval { - color: var(--hsl-blue); +.stripeRow { + width: 100%; + flex-shrink: 0; } -.timetableRoot { - color: black; +.stripeRowAlt { + background-color: var(--timetable-accent-color); } -.timetableRoot > * { - margin: 0 0 0 calc(-1 * var(--border-radius)); - padding: 0.2em calc(0.45em + var(--border-radius)); +/* ─── Column layout — on top of stripes ──────────────────────────────────── */ + +.columnLayout { + position: relative; + z-index: 1; + display: flex; + flex-direction: row; + align-items: stretch; } -.timetableRoot > *:nth-child(odd) { - background-color: var(--timetable-accent-color); +.routeGroupsContainer { + flex: 1; + display: flex; + flex-direction: row; + align-items: stretch; + justify-content: space-evenly; } -.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)); +/* ─── Hours column ───────────────────────────────────────────────────────── */ + +.hoursColumn { + display: flex; + flex-direction: column; + flex-shrink: 0; } -.firstAndLastDepartures div { +/* ─── Route group wrapper — single border element ────────────────────────── */ + +.routeGroup { display: flex; - justify-content: center; + flex-direction: row; + border-radius: 8px; + margin: 0 3px; } -.departureTitles { +.routeGroupBordered { + border: 2px solid; +} + +/* ─── Individual route column ────────────────────────────────────────────── */ + +.routeColumn { + display: flex; flex-direction: column; + align-items: center; + min-width: 60px; + padding: 0 4px; +} + +/* ─── Combined headway column — left border separator ────────────────────── */ + +.combinedColumn { + position: relative; + width: 90px; + min-width: 90px; + flex-shrink: 0; } -.firstAndLastDepartureValues { +.combinedColumn::before { + content: ''; + position: absolute; + left: 0; + top: 8px; + bottom: 8px; + width: 2px; + background-color: var(--combined-separator-color); +} + +/* ─── Heading cell (fixed height set inline) ─────────────────────────────── */ + +.headingCell { + display: flex; align-items: center; justify-content: center; + width: 100%; + box-sizing: border-box; } -.timetableRoutes { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); - padding: 0 0 0.5em 0.45em; - gap: 0.2em; -} +/* ─── Departure value cell (fixed height set inline) ─────────────────────── */ -.timetableRoutes div { +.departureCell { + display: flex; + align-items: center; justify-content: center; + font-size: 14px; + width: 100%; + box-sizing: border-box; } -.timetableMinutes { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); +/* ─── Label cell in hours column (fixed height set inline) ───────────────── */ + +.labelCell { + display: flex; + align-items: center; + box-sizing: border-box; } -.icon { - width: 20px; - height: 20px; +/* ─── Individual interval cell (height set inline) ───────────────────────── */ + +.intervalCell { + display: flex; + align-items: center; justify-content: center; + width: 100%; + box-sizing: border-box; } -.compactPaddingRight { - padding-right: calc(0.45em + var(--border-radius)); +/* ─── Hours interval cell (height set inline) ────────────────────────────── */ + +.intervalHoursCell { + display: flex; + align-items: center; + box-sizing: border-box; } -.routeHeadingsNonCompact { - padding: 0.2em 0 0.2em calc(0.45em + var(--border-radius)); +/* ─── Route heading ──────────────────────────────────────────────────────── */ + +.routeHeadings { + display: flex; + gap: 0.2em; + font-size: 20px; + align-items: center; +} + +/* ─── Combined headway heading text ─────────────────────────────────────── */ + +.combinedHeading { + display: flex; + flex-direction: column; + font-size: 11px; + line-height: 1.3; + text-align: center; + word-break: break-word; + overflow-wrap: break-word; } -.timetableMinutesNonCompact { - padding: 0.2em 0 0.2em calc(0.45em + var(--border-radius)); +/* ─── Departure label stacks ─────────────────────────────────────────────── */ + +.departureTitles { + display: flex; + flex-direction: column; + font-size: 13px; + line-height: 1.3; } +/* ─── Two-panel split layout ─────────────────────────────────────────────── */ + .flexContainer { display: flex; justify-content: space-between; @@ -88,6 +190,10 @@ flex: 1; } +.rightPanel { + flex: 1; +} + .busRoutesContainer { display: flex; gap: 12px; @@ -95,22 +201,10 @@ padding: 0 0 0.4em 0.938em; } -.rightPanel { - flex: 1; -} - -.hours { - /* Default styles for hours div */ -} - -.hoursLong { - font-size: 19px; -} +/* ─── Icon ───────────────────────────────────────────────────────────────── */ -.timetableMinutesLong { - height: 30px; -} - -.intervalLong { - font-size: 19px; +.icon { + width: 20px; + height: 20px; + flex-shrink: 0; } diff --git a/src/components/timetable/intervalTimetable.js b/src/components/timetable/intervalTimetable.js index 61d2ca0a..567a2cce 100644 --- a/src/components/timetable/intervalTimetable.js +++ b/src/components/timetable/intervalTimetable.js @@ -1,116 +1,185 @@ import React from 'react'; import PropTypes from 'prop-types'; + import classNames from 'classnames'; -import { Row, WrappingRow } from 'components/util'; + import InlineSVG from 'components/inlineSVG'; import clockIcon from 'icons/clock.svg'; import { getIcon, getColor, trimRouteId, BUS_MODE } from 'util/domain'; import partition from 'lodash/partition'; -import { prepareOrderedDepartureHoursByRoute } from './departureUtils'; +import { + prepareOrderedDepartureHoursByRoute, + groupRoutesByModeAndTrunk, + computeCombinedColumn, +} from './departureUtils'; import styles from './intervalTimetable.css'; import TableRows from './tableRows'; +const INTERVAL_ROW_HEIGHT = 26; +const HEADING_ROW_HEIGHT = 44; +const DEPARTURE_ROW_HEIGHT = 28; + +const getRoute = (routeIdToModeMap, id) => routeIdToModeMap[id]; + /** - * @param {string} hoursRange - e.g., "01-06", "19-03", "01" - * @returns {boolean} - true if the range spans 5 or more hours + * Background stripe layer — absolutely fills the container, padded to align + * with the interval rows in the column layout above/below. */ -const spansFiveOrMoreHours = hoursRange => { - const parts = hoursRange.split('-'); - if (parts.length !== 2) return false; - const start = parseInt(parts[0], 10); - const end = parseInt(parts[1], 10); - const span = end < start ? 24 - start + end : end - start; - return span >= 5; +const StripeBackground = ({ groupedDepartures, paddingTop }) => { + const rows = [ + { key: 'first', height: DEPARTURE_ROW_HEIGHT }, + ...groupedDepartures.map(({ hours }) => ({ + key: hours, + height: INTERVAL_ROW_HEIGHT, + })), + { key: 'last', height: DEPARTURE_ROW_HEIGHT }, + ]; + return ( +
+ {rows.map(({ key, height }, rowIdx) => ( +
+ ))} +
+ ); }; -/** - * @param {Object} routeIdToModeMap - * @param {string} id - * @returns {{mode: string, trunkRoute: boolean}} - */ -const getRoute = (routeIdToModeMap, id) => routeIdToModeMap[id]; +StripeBackground.propTypes = { + groupedDepartures: PropTypes.array.isRequired, + paddingTop: PropTypes.number.isRequired, +}; const IntervalDisplay = ({ departureIntervalsByRoute, routeIdToModeMap, isCompact }) => { + const { + groupedDepartures, + routeIds, + firstDepartures, + lastDepartures, + } = departureIntervalsByRoute; + + const columnGroups = groupRoutesByModeAndTrunk(routeIds, routeIdToModeMap); + return ( - <> -
- - {departureIntervalsByRoute.routeIds.map(routeId => ( -
- - {routeId} +
+ {/* Stripe background — absolutely fills the container, offset by fixed row heights */} + + + {/* Column layout — on top of stripes */} +
+ {/* Hours column */} +
+
+
- ))} -
-
-
- Ensimmäinen - Första - First -
- {departureIntervalsByRoute.routeIds.map(routeId => ( -
- {departureIntervalsByRoute.firstDepartures[routeId]} +
+
+ Ensimmäinen + Första / First +
- ))} -
-
- {departureIntervalsByRoute.groupedDepartures.map(({ hours, intervals }) => { - const isLongInterval = spansFiveOrMoreHours(hours); - return ( - + {groupedDepartures.map(({ hours }) => ( +
+ {hours} +
+ ))} +
+
+ Viimeinen + Sista / Last +
+
+
+ + {/* Route groups */} +
+ {columnGroups.map(group => { + const groupColor = getColor({ mode: group.mode, trunkRoute: group.trunkRoute }); + const hasCombined = group.routeIds.length >= 2; + const combinedIntervals = hasCombined + ? computeCombinedColumn(group.routeIds, groupedDepartures) + : null; + + return (
- {hours} -
- {departureIntervalsByRoute.routeIds.map(routeId => ( - + key={group.key} + className={classNames(styles.routeGroup, { + [styles.routeGroupBordered]: hasCombined, + })} + style={hasCombined ? { borderColor: groupColor } : undefined}> + {group.routeIds.map(routeId => ( +
+
+
+ + {routeId} +
+
+
+ {firstDepartures[routeId] || ''} +
+ {groupedDepartures.map(({ hours, intervals }) => ( +
+ + {intervals[routeId] ? `${intervals[routeId]} min` : '-'} + +
+ ))} +
+ {lastDepartures[routeId] || ''} +
+
+ ))} + {hasCombined && (
- {intervals[routeId] ? `${intervals[routeId]} min` : '-'} + className={classNames(styles.routeColumn, styles.combinedColumn)} + style={{ '--combined-separator-color': groupColor }}> +
+
+ + Yhteinen vuoroväli + Gemensam turtäthet + Combined headway + +
+
+
+ {combinedIntervals.map(({ hours, maxInterval }) => ( +
+ + {maxInterval ? `${maxInterval} min` : '-'} + +
+ ))} +
- - ))} - - ); - })} -
-
-
- Viimeinen - Sista - Last + )} +
+ ); + })}
- {departureIntervalsByRoute.routeIds.map(routeId => ( -
- {departureIntervalsByRoute.lastDepartures[routeId]} -
- ))}
- +
); }; @@ -167,13 +236,10 @@ const IntervalTimetable = ({ routeIdToModeMap, departures }) => {
-
@@ -188,8 +254,6 @@ const IntervalTimetable = ({ routeIdToModeMap, departures }) => { ) : ( diff --git a/src/components/timetable/timetable.css b/src/components/timetable/timetable.css index ab890cb2..9e8f82c2 100644 --- a/src/components/timetable/timetable.css +++ b/src/components/timetable/timetable.css @@ -27,6 +27,11 @@ padding: 15px 0px 30px 0px; } +.root.interval { + padding-left: calc(var(--border-radius) / 2); + padding-right: calc(var(--border-radius) / 2); +} + .root.standalone { font-size: 22px; } diff --git a/src/components/timetable/timetable.js b/src/components/timetable/timetable.js index ce1dc2da..bf296fa1 100644 --- a/src/components/timetable/timetable.js +++ b/src/components/timetable/timetable.js @@ -107,6 +107,7 @@ class Timetable extends Component { [styles.printable]: this.props.printableAsA4, [styles.standalone]: this.props.standalone, [styles.greyscale]: this.props.greyscale, + [styles.interval]: this.props.intervalTimetable, })} ref={ref => { this.content = ref; From 2960b2fa64fbe7aed5ec4d5226e919497235e413 Mon Sep 17 00:00:00 2001 From: Sebastian Szeszko Date: Wed, 27 May 2026 21:59:38 +0200 Subject: [PATCH 2/3] fix interval timetable combined with normal timetable WIP --- src/components/timetable/intervalTimetable.css | 8 +++++--- src/components/timetable/intervalTimetable.js | 5 +++-- src/components/timetable/tableRows.css | 6 ++++-- src/components/timetable/tableRows.js | 7 +++---- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/components/timetable/intervalTimetable.css b/src/components/timetable/intervalTimetable.css index 683f3591..e5921c7f 100644 --- a/src/components/timetable/intervalTimetable.css +++ b/src/components/timetable/intervalTimetable.css @@ -182,22 +182,24 @@ .flexContainer { display: flex; + flex-wrap: wrap; justify-content: space-between; gap: calc((1 * var(--border-radius)) + 8px); } .leftPanel { - flex: 1; + flex: 1 1 auto; + min-width: 0; } .rightPanel { - flex: 1; + flex: 1 1 300px; + min-width: 300px; } .busRoutesContainer { display: flex; gap: 12px; - margin-left: calc(-1 * var(--border-radius)); padding: 0 0 0.4em 0.938em; } diff --git a/src/components/timetable/intervalTimetable.js b/src/components/timetable/intervalTimetable.js index 567a2cce..76457f91 100644 --- a/src/components/timetable/intervalTimetable.js +++ b/src/components/timetable/intervalTimetable.js @@ -13,6 +13,7 @@ import { computeCombinedColumn, } from './departureUtils'; import styles from './intervalTimetable.css'; +import tableRowsStyles from './tableRows.css'; import TableRows from './tableRows'; const INTERVAL_ROW_HEIGHT = 26; @@ -233,7 +234,7 @@ const IntervalTimetable = ({ routeIdToModeMap, departures }) => { return busDepartures.length > 0 ? (
-
+
{ {Array.from(normalBusRoutes).join(', ')}
- 0} departures={busDepartures} /> +
) : ( diff --git a/src/components/timetable/tableRows.css b/src/components/timetable/tableRows.css index ec0005f7..2963a4b2 100644 --- a/src/components/timetable/tableRows.css +++ b/src/components/timetable/tableRows.css @@ -11,8 +11,10 @@ background-color: var(--timetable-accent-color); } -.root > *.noPadLeft { - padding-left: 0; +/* Used inside the interval timetable right panel where the container is already inset */ +.root.inset > * { + margin: 0 calc(-0.5 * var(--border-radius)); + padding: 0 calc(var(--border-radius) * 0.5); } .a3root { diff --git a/src/components/timetable/tableRows.js b/src/components/timetable/tableRows.js index c9e27e4d..de36f8cf 100644 --- a/src/components/timetable/tableRows.js +++ b/src/components/timetable/tableRows.js @@ -166,13 +166,12 @@ const TableRows = props => { const filteredDepartures = filterDuplicateDepartureHours(rowsByHour); return ( -
+
{filteredDepartures.map(departuresHour => ( ))} @@ -187,11 +186,11 @@ TableRows.propTypes = { ...Departure.propTypes, }), ).isRequired, - noPadLeft: PropTypes.bool, + className: PropTypes.string, }; TableRows.defaultProps = { - noPadLeft: false, + className: undefined, }; export default TableRows; From 185ca790904ff8eb63011d71570f07fb3184d78b Mon Sep 17 00:00:00 2001 From: Sebastian Szeszko Date: Thu, 28 May 2026 08:12:23 +0200 Subject: [PATCH 3/3] Release --- .github/workflows/ci-cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 45835755..a6dd556c 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -19,5 +19,5 @@ jobs: with: checkAndTestOutsideDocker: true codeCoverageEnabled: true - performRelease: false + performRelease: true checkAndTestInsideDocker: false