diff --git a/scripts/server.js b/scripts/server.js index 01e17675..f9e6e622 100644 --- a/scripts/server.js +++ b/scripts/server.js @@ -62,8 +62,11 @@ const cancelSignalRedis = new Redis(REDIS_CONNECTION_STRING); async function generatePoster(buildId, component, props, index) { const { stopId, date, template, selectedRuleTemplates } = props; - // RuleTemplates are not available for TerminalPoster - const data = component !== 'TerminalPoster' ? await getStopInfo({ stopId, date }) : null; + // RuleTemplates are not available for TerminalPoster and LineTimetable + const data = + component !== 'TerminalPoster' && component !== 'LineTimetable' + ? await getStopInfo({ stopId, date }) + : null; // Checks if any rule template will match the stop, and returns *the first one*. // If no match, returns the default template. diff --git a/src/components/app.js b/src/components/app.js index 5cd9195b..11daadc2 100644 --- a/src/components/app.js +++ b/src/components/app.js @@ -10,6 +10,7 @@ import StopPoster from 'components/stopPoster/stopPosterContainer'; import Timetable from 'components/timetable/timetableContainer'; import A3StopPoster from 'components/a3stopPoster/a3StopPosterContainer'; import TerminalPoster from 'components/stopPoster/terminalPosterContainer'; +import LineTimetable from 'components/lineTimetable/lineTimetableContainer'; import renderQueue from 'util/renderQueue'; const components = { @@ -17,6 +18,7 @@ const components = { Timetable, A3StopPoster, TerminalPoster, + LineTimetable, }; const graphqlUrl = process.env.JORE_GRAPHQL_URL || 'https://kartat.hsl.fi/jore/graphql'; diff --git a/src/components/lineTimetable/allStopsList.css b/src/components/lineTimetable/allStopsList.css new file mode 100644 index 00000000..1c96db48 --- /dev/null +++ b/src/components/lineTimetable/allStopsList.css @@ -0,0 +1,26 @@ +.stopListsContainer { + margin-top: 2rem; + max-width: 1171px; +} + +.stopList { + margin-bottom: 2rem; + padding-left: 1.2rem; +} + +.lineInfoText { + font-family: GothamRounded-Medium; + font-weight: bold; + font-size: 2rem; +} + +.stopListText { + font-size: medium; + font-family: GothamRounded-Book; +} + +@media print { + .stopListsContainer { + page-break-inside: avoid; + } +} diff --git a/src/components/lineTimetable/allStopsList.js b/src/components/lineTimetable/allStopsList.js new file mode 100644 index 00000000..6d822576 --- /dev/null +++ b/src/components/lineTimetable/allStopsList.js @@ -0,0 +1,71 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styles from './allStopsList.css'; + +const TEXT_LANG = { + FI: 'fi', + SE: 'se', +}; + +const getLineInfoText = (lang, lineId) => { + let infoText = null; + switch (lang) { + case TEXT_LANG.FI: + infoText = `Linjan ${lineId} pysäkit:`; + break; + + case TEXT_LANG.SE: + infoText = `Hållplatser for linje ${lineId}:`; + break; + + default: + infoText = ''; + } + + return infoText; +}; + +const parseStopNames = stops => { + return { + namesFi: stops.map(item => { + return item.stop.nameFi; + }), + namesSe: stops.map(item => { + return item.stop.nameSe; + }), + }; +}; + +const StopList = props => { + const { stops, lang, lineId } = props; + return ( +
+

{getLineInfoText(lang, lineId)}

+

{stops.join(' - ')}

+
+ ); +}; + +StopList.propTypes = { + stops: PropTypes.array.isRequired, + lang: PropTypes.string.isRequired, + lineId: PropTypes.string.isRequired, +}; + +const AllStopsList = props => { + const { stops, lineId } = props; + const parsedStopLists = parseStopNames(stops); + return ( +
+ + +
+ ); +}; + +AllStopsList.propTypes = { + stops: PropTypes.array.isRequired, + lineId: PropTypes.string.isRequired, +}; + +export default AllStopsList; diff --git a/src/components/lineTimetable/lineTableColumns.css b/src/components/lineTimetable/lineTableColumns.css new file mode 100644 index 00000000..c1cb9eac --- /dev/null +++ b/src/components/lineTimetable/lineTableColumns.css @@ -0,0 +1,31 @@ +.departureRowContainer { + max-width: 1171px; + font-family: GothamRounded-Medium; +} + +.departureRowContainer > *:nth-child(odd) { + background-color: #e8e8e8; +} + +.departureRow { + font-size: 1.2rem; + margin-left: 2rem; + padding-top: 5px; + padding-bottom: 5px; +} + +.departureColumnContainer { + flex-grow: 1; + align-items: normal; +} + +.tableContainer { + display: flex; + margin-top: 1rem; +} + +@media print { + .departureRow { + page-break-inside: avoid; + } +} diff --git a/src/components/lineTimetable/lineTableColumns.js b/src/components/lineTimetable/lineTableColumns.js new file mode 100644 index 00000000..74efdd76 --- /dev/null +++ b/src/components/lineTimetable/lineTableColumns.js @@ -0,0 +1,92 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { combineConsecutiveDays } from '../timetable/timetableContainer'; +import { Column, Row, WrappingRow } from '../util'; +import LineTableHeader from './lineTableHeader'; +import styles from './lineTableColumns.css'; + +const LineTimetableRow = props => { + const { hours, minutes } = props; + const paddedMins = minutes.toString().padStart(2, '0'); + return ( + + + {hours}.{paddedMins} + + + ); +}; + +LineTimetableRow.propTypes = { + hours: PropTypes.number.isRequired, + minutes: PropTypes.number.isRequired, +}; + +const DeparturesColumn = props => { + const { departures, stop } = props; + const departureRows = departures.map(departure => ( + + )); + + return ( +
+ +
{departureRows}
+
+ ); +}; + +DeparturesColumn.propTypes = { + departures: PropTypes.array.isRequired, + stop: PropTypes.object.isRequired, +}; + +const LineTableColumns = props => { + const selectedDepartureDays = props.days; + + const mapWeekdayDepartures = props.departures.map(departuresForStop => { + const { + mondays, + tuesdays, + wednesdays, + thursdays, + fridays, + saturdays, + sundays, + } = departuresForStop.departures; + + return { + stop: departuresForStop.stop, + combinedDays: combineConsecutiveDays({ + mondays, + tuesdays, + wednesdays, + thursdays, + fridays, + saturdays, + sundays, + }), + }; + }); + + const departureColums = mapWeekdayDepartures.map(departures => { + return ( + + + + ); + }); + + return
{departureColums}
; +}; + +LineTableColumns.propTypes = { + departures: PropTypes.arrayOf(PropTypes.any).isRequired, + stopSequence: PropTypes.arrayOf(PropTypes.string).isRequired, + days: PropTypes.string.isRequired, +}; + +export default LineTableColumns; diff --git a/src/components/lineTimetable/lineTableHeader.css b/src/components/lineTimetable/lineTableHeader.css new file mode 100644 index 00000000..486b6dd5 --- /dev/null +++ b/src/components/lineTimetable/lineTableHeader.css @@ -0,0 +1,9 @@ +.stop { + flex-grow: 1; +} + +.stopName { + font-size: 1.2em; + margin: 0 0 0 2rem; + font-family: GothamRounded-Medium; +} diff --git a/src/components/lineTimetable/lineTableHeader.js b/src/components/lineTimetable/lineTableHeader.js new file mode 100644 index 00000000..2b0fb808 --- /dev/null +++ b/src/components/lineTimetable/lineTableHeader.js @@ -0,0 +1,21 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import styles from './lineTableHeader.css'; + +const LineTableHeader = props => { + const { stop } = props; + return ( +
+

{stop.nameFi}

+

{stop.nameSe}

+
+ ); +}; + +LineTableHeader.propTypes = { + stop: PropTypes.object.isRequired, +}; + +export default LineTableHeader; diff --git a/src/components/lineTimetable/lineTimetable.css b/src/components/lineTimetable/lineTimetable.css new file mode 100644 index 00000000..ad4853cf --- /dev/null +++ b/src/components/lineTimetable/lineTimetable.css @@ -0,0 +1,194 @@ +.root { + position: relative; + width: 100%; + padding: var(--border-radius); + padding-top: calc(var(--border-radius) - 15px); + border-radius: var(--border-radius); + color: var(--hsl-blue); + background: var(--light-background); + --timetable-accent-color: white; + font-size: 16px; + margin-bottom: 10px; + font-family: GothamRounded-Medium; +} + +.root.a3 { + border-radius: 30px; + padding: 15px 0px 30px 0px; +} + +.root.standalone { + font-size: 22px; +} + +.root.summer { + background: white; + --timetable-accent-color: var(--light-background); +} + +.root.greyscale { + background: white; + color: black; + --timetable-accent-color: #eaeaea; +} + +.root.printable { + width: auto; + border-radius: 0; +} + +.header { + padding: 0 0 0 0.4em; +} + +.headerTitle { + font-size: 1.75em; +} + +.componentName { + padding: 0 0 0 0.205em; + font-size: 1.875em; +} + +.title { + font-family: GothamRounded-Medium; + white-space: nowrap; + font-size: 0.9em; +} + +.subtitle { + font-family: GothamRounded-Book; + font-size: 0.9em; +} + +.footnote { + padding: 0.125em 0.625em; + font-family: GothamXNarrow-Book; + font-size: 0.813em; +} + +.validity { + font-family: GothamXNarrow-Book; + font-size: 0.75em; + line-height: 1.1; + letter-spacing: -0.025em; + text-align: right; + margin-left: auto; + position: absolute; + margin-top: -5px; + top: calc(var(--border-radius)); + right: var(--border-radius); +} + +.validity .shortId { + font-family: GothamRounded-Medium; + font-size: 1.75em; +} + +.address { + font-family: GothamRounded-Medium; + font-size: 0.6rem; + color: black; + position: absolute; + top: 1110px; + right: var(--border-radius); +} + +.validity .title { + font-family: GothamRounded-Medium; +} + +.stopZone { + padding-left: 0.5em; + margin-top: 0.875em; + display: flex; + flex-direction: row; + align-items: center; +} + +.zoneTitle { + font-size: 0.75em; + line-height: 1.1; + font-family: GothamRounded-Medium; + white-space: nowrap; + margin-right: 0.4em; +} + +.zoneSubtitle { + font-size: 0.75em; + composes: zoneTitle; + font-family: GothamRounded-Book; +} + +.zone { + position: relative; + box-sizing: border-box; + flex: none; + width: 1.5em; + height: 1.5em; + color: white; + background: var(--hsl-blue); + border-radius: 50%; +} + +.zoneLetter { + font-size: 1.25em; + position: absolute; + top: 50%; + left: 50%; + text-align: center; + line-height: 1; + font-family: GothamRounded-Medium; + text-transform: uppercase; +} + +.printBtn { + margin-left: 2rem; + background-color: var(--hsl-blue); + margin-top: 1.5rem; + height: 2rem; + width: fit-content; + padding: 0 1rem 0 1rem; + font-size: 0.9rem; + border-radius: 10px; + color: white; + border: none; +} + +.printBtn:hover { + background-color: white; + cursor: pointer; + color: var(--hsl-blue); + border: 1px solid var(--hsl-blue); +} + +.timetableDays { + margin: 0 1rem 1rem 2rem; + font-size: 1em; + font-family: GothamRounded-Book; +} + +.timetableDates { + margin: 0 1rem 1rem 4rem; + font-size: 1em; + font-family: GothamRounded-Book; +} + +.pageBreak { + display: none; +} + +@media print { + .noPrint, + .noPrint * { + display: none !important; + } + .pageBreak { + display: block; + page-break-before: always; + } + + body { + overflow-y: visible; + } +} diff --git a/src/components/lineTimetable/lineTimetable.js b/src/components/lineTimetable/lineTimetable.js new file mode 100644 index 00000000..1c319c1d --- /dev/null +++ b/src/components/lineTimetable/lineTimetable.js @@ -0,0 +1,120 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import styles from './lineTimetable.css'; +import LineTimetableHeader from './lineTimetableHeader'; +import LineTableColumns from './lineTableColumns'; +import AllStopsList from './allStopsList'; + +const SCHEDULE_SEGMENT = { + weekdays: 'mondays-fridays', + saturdays: 'saturdays', + sundays: 'sundays', +}; + +const formatDate = date => { + const monthNames = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + + const day = date.getDate(); + const monthIndex = date.getMonth(); + const year = date.getFullYear(); + + return `${day}.${monthIndex}.${year}`; +}; + +const PrintButton = lang => { + const PRINT_TEXT = { + fi: 'TULOSTA AIKATAULU', + sv: 'SKRIV UT TIDTABEL', + en: 'PRINT SCHEDULE', + }; + + return ( +
+ +
+ ); +}; + +class LineTimetable extends Component { + constructor(props) { + super(props); + this.state = { + isMultiLineTimetable: false, // Placeholder for implementation of "multiple lines" version + }; + } + + render() { + const { lineIdParsed, nameFi, nameSe } = this.props.line; + const { allStops } = this.props; + + return ( +
+ +

{this.state.isMultiLineTimetable}

+ Maanantai-Perjantai + Måndag-Fredag + + {formatDate(new Date(this.props.dateBegin))}-{formatDate(new Date(this.props.dateEnd))} + + + +
 
+ + Lauantai/Lördag + +
 
+ + Sunnuntai/Söndag + +
 
+ +
+ ); + } +} + +LineTimetable.defaultProps = { + dateBegin: null, + dateEnd: null, + departures: {}, + timedStops: {}, + allStops: [], +}; + +LineTimetable.propTypes = { + line: PropTypes.object.isRequired, + dateBegin: PropTypes.string, + dateEnd: PropTypes.string, + departures: PropTypes.object, + timedStops: PropTypes.object, + allStops: PropTypes.array, +}; + +export default LineTimetable; diff --git a/src/components/lineTimetable/lineTimetableContainer.js b/src/components/lineTimetable/lineTimetableContainer.js new file mode 100644 index 00000000..ac9d3e7f --- /dev/null +++ b/src/components/lineTimetable/lineTimetableContainer.js @@ -0,0 +1,175 @@ +/* eslint-disable no-undef */ +import PropTypes from 'prop-types'; +import { graphql } from 'react-apollo'; +import gql from 'graphql-tag'; +import mapProps from 'recompose/mapProps'; +import compose from 'recompose/compose'; +import { filter, find } from 'lodash'; + +import apolloWrapper from 'util/apolloWrapper'; + +import LineTimetable from './lineTimetable'; +import { groupDeparturesByDay } from '../timetable/timetableContainer'; + +const lineQuery = gql` + query lineQuery($lineId: String!, $dateBegin: Date!, $dateEnd: Date!) { + line: lineByLineIdAndDateBeginAndDateEnd( + lineId: $lineId + dateBegin: $dateBegin + dateEnd: $dateEnd + ) { + lineId + lineIdParsed + nameFi + nameSe + dateBegin + dateEnd + trunkRoute + routes { + nodes { + routeId + direction + dateBegin + dateEnd + mode + nameFi + line { + nodes { + trunkRoute + lineIdParsed + } + } + routeSegments { + nodes { + stopIndex + timingStopType + duration + line { + nodes { + trunkRoute + } + } + stop: stopByStopId { + stopId + lat + lon + shortId + nameFi + nameSe + platform + } + } + } + } + } + notes { + nodes { + noteType + noteText + dateEnd + } + } + } + } +`; + +const departureQuery = gql` + query getTimedStopDepartures($routeIdentifier: String!, $routeDirection: String!, $date: Date!) { + departures: getRouteDeparturesForTimedStops( + routeIdentifier: $routeIdentifier + routeDirection: $routeDirection + date: $date + ) { + nodes { + stopId + routeId + direction + departureId + dayType + hours + minutes + isNextDay + timingStopType + } + } + } +`; + +const filterTimedStopsListFromLineQuery = props => { + const routeForSelectedDirection = find(props.data.line.routes.nodes, route => { + return route.direction === props.routeDirection; + }); + const stopList = routeForSelectedDirection.routeSegments.nodes; + const filteredStopsList = filter(stopList, stop => { + return stop.stopIndex <= 1 || stop.timingStopType > 0; + }); + return { timedStops: filteredStopsList, allStops: stopList }; +}; + +const lineQueryMapper = mapProps(props => { + const { dateBegin, dateEnd, routeDirection } = props; + const { line } = props.data; + const { timedStops, allStops } = filterTimedStopsListFromLineQuery(props); + + return { + line, + dateBegin, + dateEnd, + timedStops, + allStops, + date: props.date, + routeIdentifier: props.lineId, + routeDirection, + }; +}); + +const departuresMapper = mapProps(props => { + console.log(props); + const departures = props.data.departures.nodes; + + const departuresByStop = props.timedStops.map(timedStop => { + const stopDepartures = departures.filter( + departure => departure.stopId === timedStop.stop.stopId, + ); + return { + stop: timedStop.stop, + departures: groupDeparturesByDay(stopDepartures), + }; + }); + + return { + line: props.line, + dateBegin: props.dateBegin, + dateEnd: props.dateEnd, + departures: departuresByStop, + timedStops: props.timedStops, + allStops: props.allStops, + }; +}); + +const hoc = compose( + graphql(lineQuery), + apolloWrapper(lineQueryMapper), + graphql(departureQuery), + apolloWrapper(departuresMapper), +); + +const LineTimetableContainer = hoc(LineTimetable); + +LineTimetableContainer.defaultProps = { + dateBegin: null, + dateEnd: null, + date: null, + routeDirection: '1', +}; + +LineTimetableContainer.propTypes = { + lineId: PropTypes.string.isRequired, + stopId: PropTypes.string.isRequired, + dateBegin: PropTypes.string, + dateEnd: PropTypes.string, + date: PropTypes.string, + routeDirection: PropTypes.string, +}; + +export default LineTimetableContainer; diff --git a/src/components/lineTimetable/lineTimetableHeader.css b/src/components/lineTimetable/lineTimetableHeader.css new file mode 100644 index 00000000..e63276be --- /dev/null +++ b/src/components/lineTimetable/lineTimetableHeader.css @@ -0,0 +1,29 @@ +.header { + display: flex; + page-break-before: always; + margin-bottom: 1rem; +} + +.lineId { + font-size: 4rem; + font-weight: bold; + margin: 2rem 2rem 0 2rem; + font-family: GothamRounded-Medium; +} + +.nameContainer { + display: block; + padding-top: 2rem; +} + +.lineName { + font-size: 30px; + font-weight: bold; + font-family: GothamRounded-Medium; +} + +.lineNameSecondary { + font-size: 25px; + font-weight: normal; + font-family: GothamRounded-Book; +} diff --git a/src/components/lineTimetable/lineTimetableHeader.js b/src/components/lineTimetable/lineTimetableHeader.js new file mode 100644 index 00000000..82e1f631 --- /dev/null +++ b/src/components/lineTimetable/lineTimetableHeader.js @@ -0,0 +1,36 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import styles from './lineTimetableHeader.css'; + +const LineTimetableHeader = props => { + return ( +
+
+ {props.lineIdParsed} +
+
+
+ {props.nameFi} +
+
+ {props.nameSe} +
+
+
+ ); +}; + +LineTimetableHeader.defaultProps = { + nameFi: '', + nameSe: '', +}; + +LineTimetableHeader.propTypes = { + lineIdParsed: PropTypes.string.isRequired, + nameFi: PropTypes.string, + nameSe: PropTypes.string, +}; + +export default LineTimetableHeader; diff --git a/src/components/timetable/timetableContainer.js b/src/components/timetable/timetableContainer.js index 4a09f362..fbf66e39 100644 --- a/src/components/timetable/timetableContainer.js +++ b/src/components/timetable/timetableContainer.js @@ -38,7 +38,7 @@ function groupDepartures(departures) { }; } -function groupDeparturesByDay(departures) { +export function groupDeparturesByDay(departures) { return { mondays: departures.filter(departure => departure.dayType.includes('Ma')), tuesdays: departures.filter(departure => departure.dayType.includes('Ti')), @@ -83,7 +83,7 @@ function areDepartureArraysEqual(arr1, arr2) { return true; } -function combineConsecutiveDays(daysObject) { +export function combineConsecutiveDays(daysObject) { let currentStartDay = null; let currentDepartures = null; diff --git a/test/testStops.js b/test/testStops.js index 866f1ab0..41479a69 100644 --- a/test/testStops.js +++ b/test/testStops.js @@ -9,12 +9,20 @@ const path = require('path'); const stopIds = ['1020105', '1284188', '6301068', '1040411']; +// Lines for testing the LineTimetable component +const testLines = [ + { lineId: '2015', routeDirection: '2', dateBegin: '1999-02-02', dateEnd: '2050-12-31' }, + { lineId: '1052', routeDirection: '1', dateBegin: '2021-12-13', dateEnd: '2050-12-31' }, + { lineId: '1500', routeDirection: '1', dateBegin: '2023-10-12', dateEnd: '2050-12-31' }, +]; + const TEST_RESULTS_PATH = './test/results'; const POSTER_COMPONENTS = { TIMETABLE: 'Timetable', STOP_POSTER: 'StopPoster', A3_STOP_POSTER: 'A3StopPoster', + LINE_TIMETABLE: 'LineTimetable', }; async function sleep(millis) { @@ -23,24 +31,41 @@ async function sleep(millis) { // Build the body for the poster generation requests function buildGenerationRequestBody(buildId, component, printAsA4) { - const props = stopIds.map(stopId => { - return { - date: new Date().toISOString().split('T')[0], - isSummerTime: false, - legend: true, - mapZoneSymbols: true, - mapZones: true, - minimapZoneSymbols: true, - minimapZones: true, - printTimetablesAsA4: printAsA4, - printTimetablesAsGreyscale: false, - routeFilter: '', - salesPoint: true, - selectedRuleTemplates: [], - stopId, - template: 'default', - }; - }); + let props = null; + + if (component === POSTER_COMPONENTS.LINE_TIMETABLE) { + props = testLines.map(line => { + const { lineId, routeDirection, dateBegin, dateEnd } = line; + return { + lineId, + routeDirection, + dateBegin, + dateEnd, + date: new Date().toISOString().split('T')[0], + selectedRuleTemplates: [], + template: 'default', // Server throws error if template and selectedRuleTemplate aren't included in properties, however they aren't needed for rendering though + }; + }); + } else { + props = stopIds.map(stopId => { + return { + date: new Date().toISOString().split('T')[0], + isSummerTime: false, + legend: true, + mapZoneSymbols: true, + mapZones: true, + minimapZoneSymbols: true, + minimapZones: true, + printTimetablesAsA4: printAsA4, + printTimetablesAsGreyscale: false, + routeFilter: '', + salesPoint: true, + selectedRuleTemplates: [], + stopId, + template: 'default', + }; + }); + } return { buildId, @@ -117,7 +142,7 @@ async function pollForCompletedPosters(listId) { } console.log( `Completed posters - ${completedPosters.length}/${stopIds.length * Object.keys(POSTER_COMPONENTS).length}`, + ${completedPosters.length}/${completedPosters.length + failedPosters.length}`, ); return completedPosters; }