From b132ec9c247e6c9d54424253f3b0c3073d0154ea Mon Sep 17 00:00:00 2001 From: Avi Date: Tue, 10 Feb 2026 12:08:47 +0100 Subject: [PATCH 1/4] AB#64925 interval timetable (#538) * AB#64925 interval timetable --- .github/workflows/ci-cd.yml | 1 - .gitignore | 4 +- src/components/stopPoster/stopPoster.js | 6 +- src/components/timetable/departureUtils.js | 216 ++++++++++++++++++ .../timetable/intervalTimetable.css | 116 ++++++++++ src/components/timetable/intervalTimetable.js | 214 +++++++++++++++++ .../timetable/intervalsNormalizer.mjs | 68 ++++++ src/components/timetable/tableHeader.js | 68 +++--- src/components/timetable/tableRows.css | 4 + src/components/timetable/tableRows.js | 14 +- src/components/timetable/timetable.css | 11 + src/components/timetable/timetable.js | 31 ++- .../timetable/timetableContainer.js | 36 ++- src/icons/clock.svg | 64 ++++++ src/util/domain.js | 50 ++-- test/timetable/intervalMerging.test.mjs | 161 +++++++++++++ 16 files changed, 997 insertions(+), 67 deletions(-) create mode 100644 src/components/timetable/departureUtils.js create mode 100644 src/components/timetable/intervalTimetable.css create mode 100644 src/components/timetable/intervalTimetable.js create mode 100644 src/components/timetable/intervalsNormalizer.mjs create mode 100644 src/icons/clock.svg create mode 100644 test/timetable/intervalMerging.test.mjs diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index a73b990a..45835755 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -9,7 +9,6 @@ name: "CI/CD: Continuous integration and continuous deployment" tags: - "v*" pull_request: - merge_group: jobs: build-check-test-push: diff --git a/.gitignore b/.gitignore index 61077aee..e010ccbf 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ yarn-error.log /templates graphql.config.json output/* -.env -.env.local +.env* +!.env.template secrets /test/results/* diff --git a/src/components/stopPoster/stopPoster.js b/src/components/stopPoster/stopPoster.js index b4bb811d..e63e8dbf 100644 --- a/src/components/stopPoster/stopPoster.js +++ b/src/components/stopPoster/stopPoster.js @@ -328,6 +328,7 @@ class StopPoster extends Component { minimapZoneSymbols, minimapZones, legend, + intervalTimetable, } = this.props; if (!hasRoutesProp) { return null; @@ -353,6 +354,7 @@ class StopPoster extends Component { const StopPosterTimetable = props => (
@@ -491,6 +491,7 @@ class StopPoster extends Component { } StopPoster.propTypes = { + intervalTimetable: PropTypes.bool, stopId: PropTypes.string.isRequired, date: PropTypes.string.isRequired, isSummerTimetable: PropTypes.bool, @@ -512,6 +513,7 @@ StopPoster.propTypes = { }; StopPoster.defaultProps = { + intervalTimetable: false, isSummerTimetable: false, dateBegin: null, dateEnd: null, diff --git a/src/components/timetable/departureUtils.js b/src/components/timetable/departureUtils.js new file mode 100644 index 00000000..608e4486 --- /dev/null +++ b/src/components/timetable/departureUtils.js @@ -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} routeIds + * @returns {Object, + * lowestMinutes: Object, + * highestMinutes: Object + * }>} + */ +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, + * lowestMinutes: Object, + * highestMinutes: Object + * }>} sorted + * @param {Set} routeIds + * @returns {{firstDepartures: Object, + * lastDepartures: Object}} + */ +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, + * lastDepartures: Object + * }} + */ +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, + }; +}; diff --git a/src/components/timetable/intervalTimetable.css b/src/components/timetable/intervalTimetable.css new file mode 100644 index 00000000..97777bba --- /dev/null +++ b/src/components/timetable/intervalTimetable.css @@ -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; +} diff --git a/src/components/timetable/intervalTimetable.js b/src/components/timetable/intervalTimetable.js new file mode 100644 index 00000000..61d2ca0a --- /dev/null +++ b/src/components/timetable/intervalTimetable.js @@ -0,0 +1,214 @@ +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 styles from './intervalTimetable.css'; +import TableRows from './tableRows'; + +/** + * @param {string} hoursRange - e.g., "01-06", "19-03", "01" + * @returns {boolean} - true if the range spans 5 or more hours + */ +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; +}; + +/** + * @param {Object} routeIdToModeMap + * @param {string} id + * @returns {{mode: string, trunkRoute: boolean}} + */ +const getRoute = (routeIdToModeMap, id) => routeIdToModeMap[id]; + +const IntervalDisplay = ({ departureIntervalsByRoute, routeIdToModeMap, isCompact }) => { + return ( + <> +
+ + {departureIntervalsByRoute.routeIds.map(routeId => ( +
+ + {routeId} +
+ ))} +
+
+
+ Ensimmäinen + Första + First +
+ {departureIntervalsByRoute.routeIds.map(routeId => ( +
+ {departureIntervalsByRoute.firstDepartures[routeId]} +
+ ))} +
+
+ {departureIntervalsByRoute.groupedDepartures.map(({ hours, intervals }) => { + const isLongInterval = spansFiveOrMoreHours(hours); + return ( + +
+ {hours} +
+ {departureIntervalsByRoute.routeIds.map(routeId => ( + +
+ {intervals[routeId] ? `${intervals[routeId]} min` : '-'} +
+
+ ))} +
+ ); + })} +
+
+
+ Viimeinen + Sista + Last +
+ {departureIntervalsByRoute.routeIds.map(routeId => ( +
+ {departureIntervalsByRoute.lastDepartures[routeId]} +
+ ))} +
+ + ); +}; + +IntervalDisplay.propTypes = { + departureIntervalsByRoute: PropTypes.object.isRequired, + routeIdToModeMap: PropTypes.object.isRequired, + isCompact: PropTypes.bool, +}; + +IntervalDisplay.defaultProps = { + isCompact: false, +}; + +const partitionToIntervalAndNonIntervalRoutes = routeIdToModeMap => { + const intervalRoutes = new Set(); + const normalBusRoutes = new Set(); + + for (const key in routeIdToModeMap) { + const routeDescription = routeIdToModeMap[key]; + if (routeDescription.mode === BUS_MODE && !routeDescription.trunkRoute) { + normalBusRoutes.add(key); + } else { + intervalRoutes.add(key); + } + } + + return { intervalRoutes, normalBusRoutes }; +}; + +const sortBusRoutesLast = (routeIds, routeIdToModeMap) => { + routeIds.sort((a, b) => { + const aIsBus = routeIdToModeMap[a]?.mode === BUS_MODE; + const bIsBus = routeIdToModeMap[b]?.mode === BUS_MODE; + if (aIsBus === bIsBus) return a.localeCompare(b); + return aIsBus ? 1 : -1; + }); +}; + +const IntervalTimetable = ({ routeIdToModeMap, departures }) => { + const { intervalRoutes, normalBusRoutes } = partitionToIntervalAndNonIntervalRoutes( + routeIdToModeMap, + ); + + const [nonBusDepartures, busDepartures] = partition(departures, it => + intervalRoutes.has(trimRouteId(it.routeId).replace(/[^0-9]/g, '')), + ); + + const departureIntervalsByRoute = prepareOrderedDepartureHoursByRoute(nonBusDepartures); + + sortBusRoutesLast(departureIntervalsByRoute.routeIds, routeIdToModeMap); + + return busDepartures.length > 0 ? ( +
+
+ +
+ +
+
+ +
+ + {Array.from(normalBusRoutes).join(', ')} +
+
+ 0} departures={busDepartures} /> +
+
+ ) : ( + + ); +}; + +IntervalTimetable.propTypes = { + combinedDay: PropTypes.string.isRequired, + routeIdToModeMap: PropTypes.object.isRequired, + departures: PropTypes.array.isRequired, + intervalTimetable: PropTypes.bool, + printableAsA4: PropTypes.bool, + useCompactLayout: PropTypes.bool, +}; + +IntervalTimetable.defaultProps = { + intervalTimetable: false, + printableAsA4: false, + useCompactLayout: false, +}; + +export default IntervalTimetable; diff --git a/src/components/timetable/intervalsNormalizer.mjs b/src/components/timetable/intervalsNormalizer.mjs new file mode 100644 index 00000000..5a985521 --- /dev/null +++ b/src/components/timetable/intervalsNormalizer.mjs @@ -0,0 +1,68 @@ +/** + * @param {number[]} arr + * @returns {number[]} + */ +function normalizeByContiguousClusters(arr) { + const n = arr.length; + if (n <= 1) return arr.slice(); + + const out = arr.slice(); + let i = 0; + + while (i < n) { + let segMin = out[i]; + let segMax = out[i]; + let j = i + 1; + + while (j < n) { + const v = out[j]; + const newMin = Math.min(segMin, v); + const newMax = Math.max(segMax, v); + if (newMax - newMin <= 1) { + segMin = newMin; + segMax = newMax; + j++; + } else break; + } + + for (let k = i; k < j; k++) out[k] = segMin; + + i = j; + } + + return out; +} + +/** + * + * @param {Array<{ + * hours: string, + * isNextDay: boolean, + * intervals: Record, + * lowestMinutes: Record, + * highestMinutes: Record + * }>} data + * @returns {Array<{ + * hours: string, + * isNextDay: boolean, + * intervals: Record, + * lowestMinutes: Record, + * highestMinutes: Record + * }>} + */ +export const normalizeDepartures = data => { + const result = data.map(r => ({ ...r, intervals: { ...r.intervals } })); + const keys = [...new Set(data.flatMap(r => Object.keys(r.intervals)))]; + + for (const key of keys) { + const keyData = data.map(row => row.intervals[key]); + const normalized = normalizeByContiguousClusters(keyData); + + for (let i = 0; i < result.length; i++) { + if (normalized[i] !== null && normalized[i] !== undefined) { + result[i].intervals[key] = normalized[i]; + } + } + } + return result; +}; diff --git a/src/components/timetable/tableHeader.js b/src/components/timetable/tableHeader.js index 36f5fd03..5a22a2a1 100644 --- a/src/components/timetable/tableHeader.js +++ b/src/components/timetable/tableHeader.js @@ -5,8 +5,9 @@ import classNames from 'classnames'; import styles from './tableHeader.css'; const TableHeader = props => { + const style = props.intervalTimetable ? { fontSize: '19.5px' } : undefined; const header = props.printAsA3 ? ( -
+
{' '}    @@ -18,7 +19,7 @@ const TableHeader = props => {
) : ( -
+
{' '}    @@ -36,36 +37,37 @@ const TableHeader = props => { {props.title} {header}
- -
-
Tunti
-
- min / linja Ajat ovat arvioituja -
-
- -
-
Timme
-
- min / linje Tiderna är beräknade -
-
- -
-
Hour
-
- min / route The times are estimates -
-
+ {props.intervalTimetable || ( + <> +
+
Tunti
+
+ min / linja Ajat ovat arvioituja +
+
+
+
Timme
+
+ min / linje Tiderna är beräknade +
+
+
+
Hour
+
+ min / route The times are estimates +
+
+ + )}
); }; @@ -74,6 +76,7 @@ TableHeader.defaultProps = { printingAsA4: false, printAsA3: false, useCompactLayout: false, + intervalTimetable: false, }; TableHeader.propTypes = { @@ -83,6 +86,7 @@ TableHeader.propTypes = { printingAsA4: PropTypes.bool, printAsA3: PropTypes.bool, useCompactLayout: PropTypes.bool, + intervalTimetable: PropTypes.bool, }; export default TableHeader; diff --git a/src/components/timetable/tableRows.css b/src/components/timetable/tableRows.css index e42445e2..ec0005f7 100644 --- a/src/components/timetable/tableRows.css +++ b/src/components/timetable/tableRows.css @@ -11,6 +11,10 @@ background-color: var(--timetable-accent-color); } +.root > *.noPadLeft { + padding-left: 0; +} + .a3root { margin: 0px; padding: 0px; diff --git a/src/components/timetable/tableRows.js b/src/components/timetable/tableRows.js index ce850367..c9e27e4d 100644 --- a/src/components/timetable/tableRows.js +++ b/src/components/timetable/tableRows.js @@ -31,7 +31,7 @@ Departure.propTypes = { }; const TableRow = props => ( - +
{props.hours}
{sortBy(props.departures, a => a.minutes).map((departure, index) => ( @@ -43,12 +43,14 @@ const TableRow = props => ( TableRow.defaultProps = { useCompactLayout: false, + className: undefined, }; TableRow.propTypes = { hours: PropTypes.string.isRequired, departures: PropTypes.arrayOf(PropTypes.shape(Departure.propTypes)).isRequired, useCompactLayout: PropTypes.bool, + className: PropTypes.string, }; const isEqualDepartureHour = (a, b) => { @@ -127,6 +129,7 @@ const TableRows = props => { props.departures, departure => (departure.isNextDay ? 24 : 0) + departure.hours, ); + const rows = Object.entries(departuresByHour).map(([hours, departures]) => ({ hour: hours, departures, @@ -161,7 +164,6 @@ const TableRows = props => { } const filteredDepartures = filterDuplicateDepartureHours(rowsByHour); - const useCompactLayout = true; return (
@@ -170,7 +172,8 @@ const TableRows = props => { key={`${departuresHour.hour}${departuresHour.departures}`} hours={departuresHour.hour} departures={departuresHour.departures} - useCompactLayout={useCompactLayout} + className={props.noPadLeft ? styles.noPadLeft : undefined} + useCompactLayout /> ))}
@@ -184,6 +187,11 @@ TableRows.propTypes = { ...Departure.propTypes, }), ).isRequired, + noPadLeft: PropTypes.bool, +}; + +TableRows.defaultProps = { + noPadLeft: false, }; export default TableRows; diff --git a/src/components/timetable/timetable.css b/src/components/timetable/timetable.css index f0887d43..ab890cb2 100644 --- a/src/components/timetable/timetable.css +++ b/src/components/timetable/timetable.css @@ -11,6 +11,17 @@ margin-bottom: 10px; } +.icon { + width: 20px; + height: 20px; +} + +.validFrom { + padding: 0.313em 0.45em; + color: black; + font-size: 12.75px; +} + .root.a3 { border-radius: 30px; padding: 15px 0px 30px 0px; diff --git a/src/components/timetable/timetable.js b/src/components/timetable/timetable.js index 6b7e6bb8..a115caf0 100644 --- a/src/components/timetable/timetable.js +++ b/src/components/timetable/timetable.js @@ -7,6 +7,7 @@ import classNames from 'classnames'; import TableHeader from './tableHeader'; import TableRows from './tableRows'; import SimpleRoutes from './simpleRoutes'; +import IntervalTimetable from './intervalTimetable'; import styles from './timetable.css'; @@ -75,6 +76,8 @@ class Timetable extends Component { } render() { + const { combinedDays, routeIdToModeMap, intervalTimetable } = this.props; + const date = new Date(`${this.props.date}T00:00:00`); if (!this.props.hasDepartures) { return null; } @@ -96,6 +99,7 @@ class Timetable extends Component { j++; } } + return (
: ''}
)} - {this.props.showValidityPeriod && ( + {this.props.showValidityPeriod && !intervalTimetable && (
)}
- {this.props.showComponentName && ( + {this.props.showComponentName && !intervalTimetable && (
Pysäkkiaikataulu  
Hållplatstidtabell
Stop timetable
)} +
+ Aikataulu alkaen {formatDate(date)} - / Tidtabeller fran {formatDate(date)} - /Timetables + from {formatDate(date)} - +
+ {this.props.standalone && (
@@ -197,6 +206,7 @@ class Timetable extends Component { dayNames.length > 1 ? `${getWeekdayName(dayNames[0], 'en')} - ${getWeekdayName(dayNames[1], 'en')}` : `${getWeekdayName(dayNames[0], 'en')}`; + return (
- + {intervalTimetable ? ( + + ) : ( + + )}
); })} @@ -223,6 +244,7 @@ class Timetable extends Component { } Timetable.defaultProps = { + intervalTimetable: false, saturdays: null, sundays: null, isSummerTimetable: false, @@ -241,9 +263,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 +298,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/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 } }, + ], + ); +}); From 67653cacf557754ee4a680bd739f72f237aabfd3 Mon Sep 17 00:00:00 2001 From: Sebastian Szeszko Date: Wed, 11 Feb 2026 12:04:03 +0100 Subject: [PATCH 2/4] fix: showing the info for interval even when not interval timetable --- src/components/timetable/timetable.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/timetable/timetable.js b/src/components/timetable/timetable.js index a115caf0..ce1dc2da 100644 --- a/src/components/timetable/timetable.js +++ b/src/components/timetable/timetable.js @@ -173,10 +173,12 @@ class Timetable extends Component {
Stop timetable
)} -
- Aikataulu alkaen {formatDate(date)} - / Tidtabeller fran {formatDate(date)} - /Timetables - from {formatDate(date)} - -
+ {intervalTimetable && ( +
+ Aikataulu alkaen {formatDate(date)} - / Tidtabeller fran {formatDate(date)} - + /Timetables from {formatDate(date)} - +
+ )} {this.props.standalone && ( From 140e95fc4c4b93cbe81828c38500783b12edb226 Mon Sep 17 00:00:00 2001 From: Sebastian Szeszko Date: Wed, 11 Feb 2026 09:26:25 +0100 Subject: [PATCH 3/4] AB#64926 make svgs use styles with randomized names --- scripts/store.js | 4 +- src/components/inlineSVG.js | 2 +- src/util/processSVG.js | 53 + test/svg-processing/no_smoking.svg | 50 + test/svg-processing/processSVG.test.mjs | 111 + test/svg-processing/test_svg.svg | 154 + test/svg-processing/tram_map.svg | 13562 ++++++++++++++++++++++ 7 files changed, 13934 insertions(+), 2 deletions(-) create mode 100644 src/util/processSVG.js create mode 100644 test/svg-processing/no_smoking.svg create mode 100644 test/svg-processing/processSVG.test.mjs create mode 100644 test/svg-processing/test_svg.svg create mode 100644 test/svg-processing/tram_map.svg diff --git a/scripts/store.js b/scripts/store.js index 3ea31e34..1200edba 100644 --- a/scripts/store.js +++ b/scripts/store.js @@ -14,6 +14,7 @@ const createEmptyTemplate = require('./util/createEmptyTemplate'); const cleanup = require('./util/cleanup'); const { JORE_GRAPHQL_URL } = require('../constants'); +const { processSVGWithUniqueIds } = require('../src/util/processSVG'); // Must cleanup knex, otherwise the process keeps going. cleanup(() => { @@ -269,9 +270,10 @@ async function saveAreaImages(slots) { .where({ name: imageName }) .first(); - const svgContent = get(slot, 'image.svg', ''); + let svgContent = get(slot, 'image.svg', ''); if (svgContent) { + svgContent = processSVGWithUniqueIds(svgContent); const newImage = { name: imageName, svg: svgContent, diff --git a/src/components/inlineSVG.js b/src/components/inlineSVG.js index bb56445b..d8f96fb2 100644 --- a/src/components/inlineSVG.js +++ b/src/components/inlineSVG.js @@ -1,4 +1,4 @@ -import React, { useRef, useEffect } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; const InlineSVG = ({ src, ...otherProps }) => { 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 From 779a2653dab6f61a45e9a3be6880ebe2b0bc1052 Mon Sep 17 00:00:00 2001 From: Sebastian Szeszko Date: Tue, 24 Feb 2026 14:08:31 +0100 Subject: [PATCH 4/4] bump version --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 364e7320..4d621810 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hsl-map-publisher", - "version": "1.1.7", + "version": "1.2.0", "description": "HSL Map Publisher", "main": "index.js", "scripts": { @@ -93,7 +93,7 @@ "graphql": "^0.11.7", "graphql-tag": "^2.5.0", "haversine": "^1.1.1", - "hsl-map-style": "hsldevcom/hsl-map-style#zone_e_map_styles", + "hsl-map-style": "hsldevcom/hsl-map-style#master", "html-webpack-plugin": "^4.0.0-alpha", "ioredis": "^5.0.6", "knex": "^2.0.0",