diff --git a/__tests__/reducers/__snapshots__/create-otp-reducer.js.snap b/__tests__/reducers/__snapshots__/create-otp-reducer.js.snap index 9cad075ba..9df6d1faf 100644 --- a/__tests__/reducers/__snapshots__/create-otp-reducer.js.snap +++ b/__tests__/reducers/__snapshots__/create-otp-reducer.js.snap @@ -93,6 +93,7 @@ Object { "rideEstimates": Object {}, }, "transitIndex": Object { + "routes": Object {}, "stops": Object {}, "trips": Object {}, }, @@ -102,6 +103,13 @@ Object { "localizedMessages": Object {}, "mobileScreen": 1, "printView": false, + "routeViewer": Object { + "filter": Object { + "agency": null, + "mode": null, + "search": "", + }, + }, }, "useRealtime": true, "user": Object { diff --git a/example.css b/example.css index 9c3d962d7..afd7900f5 100644 --- a/example.css +++ b/example.css @@ -38,7 +38,7 @@ .sidebar { height: 100%; - padding: 10px; + padding: 0; box-shadow: 3px 0px 12px #00000052; z-index: 1000; } diff --git a/i18n/en-US.yml b/i18n/en-US.yml index 7e5711a5e..90539ec34 100644 --- a/i18n/en-US.yml +++ b/i18n/en-US.yml @@ -22,15 +22,47 @@ _name: English # - In contrast, some strings are common to multiple components, # so it makes sense to group them by theme (e.g. accessModes) under the 'common' category. - # Component-specific messages (e.g. button captions) # are defined for each component under the 'components' category. components: + BatchRoutingPanel: + shortTitle: Plan Trip DefaultItinerary: clickDetails: Click to view details # Use ordered placeholders when multiple modes are involved # (this will accommodate right-to-left languages by swapping the order/separator in this string). multiModeSummary: "{accessMode} to {transitMode}" + # If trip is less than one hour only display the minutes. + tripDurationFormatZeroHours: "{minutes, number} min" + # TODO: Distinguish between one hour (singular) and 2 hours or more? + tripDurationFormat: "{hours, number} hr {minutes, number} min" + RouteDetails: + operatedBy: "Operated by {agencyName}" + moreDetails: "More Details" + stopsTo: "Towards" + selectADirection: "Select a direction..." + RouteViewer: + allAgencies: All Agencies + allModes: All Modes # Note to translator: This text is width-constrained. + findARoute: Find A Route + noFilteredRoutesFound: No routes match your filter! + noRouteUrl: No route URL provided. + title: Route Viewer + shortTitle: View Routes + agencyFilter: Agency Filter + modeFilter: Mode Filter + details: " " # If the string is left blank, React-Intl renders the id + RouteRow: + operatorLogoAltText: '{operatorName} logo' + TransitVehicleOverlay: + # keys designed to match API output + incoming_at: "approaching {stop}" + stopped_at: "doors open at {stop}" + in_transit_to: "next stop {stop}" + + vehicleName: "Vehicle {vehicleNumber}: " + realtimeVehicleInfo: "{vehicleNameOrBlank}{relativeTime}" + travelingAt: "traveling at {milesPerHour}" ItinerarySummary: fareCost: "{useMaxFare, select, true {{minTotalFare} - {maxTotalFare}} @@ -114,6 +146,9 @@ components: # Common messages that appear in multiple components and modules # are grouped below by topic. common: + # Standard navigation + navigation: + back: Back # OTP access modes accessModes: bike: Bike diff --git a/i18n/fr-FR.yml b/i18n/fr-FR.yml index ad0874e2e..6a05db9eb 100644 --- a/i18n/fr-FR.yml +++ b/i18n/fr-FR.yml @@ -2,9 +2,42 @@ _id: fr-FR _name: Unofficial French Translations! components: + BatchRoutingPanel: + shortTitle: Planifier un trajet DefaultItinerary: clickDetails: Cliquez pour afficher les détails multiModeSummary: "{accessMode} + {transitMode}" + # If trip is less than one hour only display the minutes. + tripDurationFormatZeroHours: "{minutes, number} mn" + # TODO: Distinguish between one hour (singular) and 2 hours or more? + tripDurationFormat: "{hours, number} h {minutes, number} mn" + RouteDetails: + operatedBy: "Exploité par {agencyName}" + moreDetails: "Plus d'infos" + stopsTo: "Direction" + selectADirection: "Choisissez une direction..." + RouteViewer: + allAgencies: Tous exploitants + allModes: Tous modes # Note to translator: This text is width-constrained. + findARoute: Chercher une ligne + noFilteredRoutesFound: Aucune ligne ne correspond à vos critères + noRouteUrl: Aucun lien fourni pour cette ligne. + title: Index des lignes + shortTitle: Index des lignes + agencyFilter: Filtre pour les exploitants + modeFilter: Filtre pour les modes + details: " " # If the string is left blank, React-Intl renders the id + RouteRow: + operatorLogoAltText: "Logo de {operatorName}" + TransitVehicleOverlay: + # keys designed to match API output + incoming_at: "Approchant {stop}" + stopped_at: "À quai à {stop}" + in_transit_to: "Prochain arrêt : {stop}" + + vehicleName: "Véhicule {vehicleNumber}: " + realtimeVehicleInfo: "{vehicleNameOrBlank}{relativeTime}" + travelingAt: "Vitesse : {milesPerHour}" ItinerarySummary: fareCost: "{useMaxFare, select, true {{minTotalFare} - {maxTotalFare}} @@ -85,6 +118,9 @@ components: startOver: Recommencer common: + # Standard navigation + navigation: + back: Retour accessModes: bike: Vélo bikeshare: Vélo en libre-service diff --git a/lib/actions/api.js b/lib/actions/api.js index 2bd38d20c..31d6624cc 100644 --- a/lib/actions/api.js +++ b/lib/actions/api.js @@ -507,19 +507,25 @@ export function findRoute (params) { export function findPatternsForRoute (params) { return createQueryAction( - `index/routes/${params.routeId}/patterns`, + `index/routes/${params.routeId}/patterns?includeGeometry=true`, findPatternsForRouteResponse, findPatternsForRouteError, { + noThrottle: true, postprocess: (payload, dispatch) => { // load geometry for each pattern payload.forEach(ptn => { - dispatch(findGeometryForPattern({ - patternId: ptn.id, - routeId: params.routeId - })) + // Some OTP instances don't support includeGeometry. + // We need to manually fetch geometry in these cases. + if (!ptn.geometry) { + dispatch(findGeometryForPattern({ + patternId: ptn.id, + routeId: params.routeId + })) + } }) }, + rewritePayload: (payload) => { // convert pattern array to ID-mapped object const patterns = {} @@ -556,6 +562,29 @@ export function findGeometryForPattern (params) { ) } +// Stops for pattern query + +export const findStopsForPatternResponse = createAction('FIND_STOPS_FOR_PATTERN_RESPONSE') +export const findStopsForPatternError = createAction('FIND_STOPS_FOR_PATTERN_ERROR') + +export function findStopsForPattern (params) { + return createQueryAction( + `index/patterns/${params.patternId}/stops`, + findStopsForPatternResponse, + findStopsForPatternError, + { + noThrottle: true, + rewritePayload: (payload) => { + return { + patternId: params.patternId, + routeId: params.routeId, + stops: payload + } + } + } + ) +} + // TNC ETA estimate lookup query export const transportationNetworkCompanyEtaResponse = createAction('TNC_ETA_RESPONSE') @@ -682,6 +711,27 @@ export function findStopsWithinBBox (params) { export const clearStops = createAction('CLEAR_STOPS_OVERLAY') +// Realtime Vehicle positions query + +const receivedVehiclePositions = createAction('REALTIME_VEHICLE_POSITIONS_RESPONSE') +const receivedVehiclePositionsError = createAction('REALTIME_VEHICLE_POSITIONS_ERROR') + +export function getVehiclePositionsForRoute (routeId) { + return createQueryAction( + `index/routes/${routeId}/vehicles`, + receivedVehiclePositions, + receivedVehiclePositionsError, + { + rewritePayload: (payload) => { + return { + routeId: routeId, + vehicles: payload + } + } + } + ) +} + const throttledUrls = {} function now () { @@ -720,6 +770,7 @@ window.setInterval(() => { */ function createQueryAction (endpoint, responseAction, errorAction, options = {}) { + /* eslint-disable-next-line complexity */ return async function (dispatch, getState) { const state = getState() const { config } = state.otp diff --git a/lib/actions/ui.js b/lib/actions/ui.js index dee3c40c2..ac123df10 100644 --- a/lib/actions/ui.js +++ b/lib/actions/ui.js @@ -3,8 +3,9 @@ import coreUtils from '@opentripplanner/core-utils' import { createAction } from 'redux-actions' import { matchPath } from 'react-router' -import { getUiUrlParams } from '../util/state' +import { getUiUrlParams, getModesForActiveAgencyFilter } from '../util/state' import { getDefaultLocale, loadLocaleData } from '../util/i18n' +import { getPathFromParts } from '../util/ui' import { findRoute, setUrlSearch } from './api' import { setMapCenter, setMapZoom, setRouterId } from './config' @@ -47,22 +48,31 @@ export function routeTo (url, replaceSearch, routingMethod = push) { * route or stop). */ export function matchContentToUrl (location) { + // eslint-disable-next-line complexity return function (dispatch, getState) { // This is a bit of a hack to make up for the fact that react-router does // not always provide the match params as expected. // https://github.com/ReactTraining/react-router/issues/5870#issuecomment-394194338 const root = location.pathname.split('/')[1] const match = matchPath(location.pathname, { - exact: true, + exact: false, path: `/${root}/:id`, strict: false }) - const id = match && match.params && match.params.id + const id = match?.params?.id switch (root) { case 'route': if (id) { dispatch(findRoute({ routeId: id })) - dispatch(setViewedRoute({ routeId: id })) + // Check for pattern "submatch" + const subMatch = matchPath(location.pathname, { + exact: true, + path: `/${root}/:id/pattern/:patternId`, + strict: false + }) + const patternId = subMatch?.params?.patternId + // patternId may be undefined, which is OK as the route will still be routed + dispatch(setViewedRoute({ patternId, routeId: id })) } else { dispatch(setViewedRoute(null)) dispatch(setMainPanelContent(MainPanelContent.ROUTE_VIEWER)) @@ -196,23 +206,29 @@ export const clearPanel = createAction('CLEAR_MAIN_PANEL') export function setViewedStop (payload) { return function (dispatch, getState) { dispatch(viewStop(payload)) - const path = payload && payload.stopId - ? `/stop/${payload.stopId}` - : '/stop' + // payload.stopId may be undefined, which is ok as will be ignored by getPathFromParts + const path = getPathFromParts('stop', payload?.stopId) dispatch(routeTo(path)) } } const viewStop = createAction('SET_VIEWED_STOP') +export const setHoveredStop = createAction('SET_HOVERED_STOP') + export const setViewedTrip = createAction('SET_VIEWED_TRIP') export function setViewedRoute (payload) { return function (dispatch, getState) { dispatch(viewRoute(payload)) - const path = payload && payload.routeId - ? `/route/${payload.routeId}` - : '/route' + + const path = getPathFromParts( + 'route', + payload?.routeId, + // If a pattern is supplied, include pattern in path + payload?.patternId && 'pattern', + payload?.patternId + ) dispatch(routeTo(path)) } } @@ -337,3 +353,26 @@ export function setLocale (locale) { dispatch(updateLocale({ locale: effectiveLocale, messages })) } } + +const updateRouteViewerFilter = createAction('UPDATE_ROUTE_VIEWER_FILTER') +/** + * Updates the route viewer filter + * @param {*} filter Object which includes either agency, mode, and/or search + */ +export function setRouteViewerFilter (filter) { + return async function (dispatch, getState) { + dispatch(updateRouteViewerFilter(filter)) + + // If we're changing agency, and have a mode selected, + // ensure that the mode filter doesn't select non-existent modes! + const activeModeFilter = getState().otp.ui.routeViewer.filter.mode + if ( + filter.agency && + activeModeFilter && + !getModesForActiveAgencyFilter(getState()).includes(activeModeFilter.toUpperCase()) + ) { + // If invalid mode is selected, reset mode + dispatch(updateRouteViewerFilter({ mode: null })) + } + } +} diff --git a/lib/components/app/app-menu.js b/lib/components/app/app-menu.js index 159d22446..8cb86a2f4 100644 --- a/lib/components/app/app-menu.js +++ b/lib/components/app/app-menu.js @@ -45,7 +45,6 @@ class AppMenu extends Component { const { callTakerEnabled, fieldTripEnabled, - languageConfig, mailablesEnabled, resetAndToggleCallHistory, resetAndToggleFieldTrips, @@ -60,8 +59,10 @@ class AppMenu extends Component { id='app-menu' noCaret title={()}> - - {languageConfig.routeViewer || 'Route Viewer'} + {/* This item is duplicated by the view-switcher, but only shown on mobile + when the view switcher isn't shown (using css) */} + + Route Viewer {callTakerEnabled && @@ -90,11 +91,9 @@ class AppMenu extends Component { // connect to the redux store const mapStateToProps = (state, ownProps) => { - const {language} = state.otp.config return { callTakerEnabled: isModuleEnabled(state, Modules.CALL_TAKER), fieldTripEnabled: isModuleEnabled(state, Modules.FIELD_TRIP), - languageConfig: language, mailablesEnabled: isModuleEnabled(state, Modules.MAILABLES) } } diff --git a/lib/components/app/app.css b/lib/components/app/app.css index 1547bc4f6..6d71a330e 100644 --- a/lib/components/app/app.css +++ b/lib/components/app/app.css @@ -31,6 +31,16 @@ color: #ddd; } +/* Don't show route viewer link in the app menu on desktop as it is in the navbar */ +.app-menu-route-viewer-link { + display: none; +} +@media (max-width: 800px) { + .app-menu-route-viewer-link { + display: block; + } +} + /* PrintLayout styles */ .otp.print-layout { @@ -51,3 +61,30 @@ margin-bottom: 30px; box-sizing: border-box; } + +/* Batch routing panel requires padding removed from sidebar */ +.batch-routing-panel { + padding: 10px; +} +/* View Switcher Styling */ +.view-switcher { + align-items: center; + display: flex; + justify-content: center; +} +.view-switcher button.btn-link { + color: rgba(255, 255, 255, 0.85); + border-radius: 15px; +} +.view-switcher button.btn-link.active { + background: rgba(255, 255, 255, 0.15); +} +.view-switcher button.btn-link:hover, +.view-switcher button.btn-link:focus { + text-decoration: none; + border-radius: 15px; +} + +.view-switcher button.btn-link:hover { + color: #fff; +} diff --git a/lib/components/app/desktop-nav.js b/lib/components/app/desktop-nav.js index 1914690b0..e867b815a 100644 --- a/lib/components/app/desktop-nav.js +++ b/lib/components/app/desktop-nav.js @@ -7,6 +7,7 @@ import { accountLinks, getAuth0Config } from '../../util/auth' import { DEFAULT_APP_TITLE } from '../../util/constants' import AppMenu from './app-menu' +import ViewSwitcher from './view-switcher' /** * The desktop navigation bar, featuring a `branding` logo or a `title` text @@ -42,7 +43,8 @@ const DesktopNav = ({ otpConfig }) => { return ( - + {/* Required to allow the hamburger button to be clicked */} + {/* TODO: Reconcile CSS class and inline style. */}
@@ -53,6 +55,7 @@ const DesktopNav = ({ otpConfig }) => { + {showLogin && ( diff --git a/lib/components/app/view-switcher.js b/lib/components/app/view-switcher.js new file mode 100644 index 000000000..dfb7dd5b4 --- /dev/null +++ b/lib/components/app/view-switcher.js @@ -0,0 +1,86 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { Button } from 'react-bootstrap' +import { withRouter } from 'react-router' +import { connect } from 'react-redux' +import { FormattedMessage } from 'react-intl' + +import { MainPanelContent, setMainPanelContent } from '../../actions/ui' + +/** + * This component is a switcher between + * the main views of the application. + */ +class ViewSwitcher extends Component { + static propTypes = { + activePanel: PropTypes.number, + setMainPanelContent: PropTypes.func, + sticky: PropTypes.bool + } + + _showRouteViewer = () => { + this.props.setMainPanelContent(MainPanelContent.ROUTE_VIEWER) + } + _showTripPlanner = () => { + this.props.setMainPanelContent(null) + } + + render () { + const { activePanel, sticky } = this.props + + return ( +
+ + +
+ ) + } +} + +// connect to the redux store + +const mapStateToProps = (state, ownProps) => { + const {mainPanelContent} = state.otp.ui + + // Reverse the ID to string mapping + let activePanel = Object.entries(MainPanelContent).find( + (keyValuePair) => keyValuePair[1] === mainPanelContent + ) + // activePanel is array of form [string, ID] + // The trip planner has id null + activePanel = (activePanel && activePanel[1]) || null + + return { + activePanel + } +} + +const mapDispatchToProps = { + setMainPanelContent +} + +export default withRouter(connect(mapStateToProps, mapDispatchToProps)(ViewSwitcher)) diff --git a/lib/components/map/connected-route-viewer-overlay.js b/lib/components/map/connected-route-viewer-overlay.js index 1aa83fce2..e9fab5969 100644 --- a/lib/components/map/connected-route-viewer-overlay.js +++ b/lib/components/map/connected-route-viewer-overlay.js @@ -5,10 +5,20 @@ import { connect } from 'react-redux' const mapStateToProps = (state, ownProps) => { const viewedRoute = state.otp.ui.viewedRoute - return { - routeData: viewedRoute && state.otp.transitIndex.routes + + const routeData = + viewedRoute && state.otp.transitIndex.routes ? state.otp.transitIndex.routes[viewedRoute.routeId] : null + let filteredPatterns = routeData?.patterns + + // If a pattern is selected, hide all other patterns + if (viewedRoute?.patternId && routeData?.patterns) { + filteredPatterns = {[viewedRoute.patternId]: routeData.patterns[viewedRoute.patternId]} + } + + return { + routeData: { ...routeData, patterns: filteredPatterns } } } diff --git a/lib/components/map/connected-stop-marker.js b/lib/components/map/connected-stop-marker.js index e46ebcaea..c57a1dede 100644 --- a/lib/components/map/connected-stop-marker.js +++ b/lib/components/map/connected-stop-marker.js @@ -7,8 +7,18 @@ import { setViewedStop } from '../../actions/ui' // connect to the redux store const mapStateToProps = (state, ownProps) => { + const { highlightedStop, viewedRoute } = state.otp.ui + const routeData = viewedRoute && state.otp.transitIndex.routes?.[viewedRoute.routeId] + const hoverColor = routeData?.routeColor || '#333' + return { languageConfig: state.otp.config.language, + leafletPath: { + color: '#000', + fillColor: highlightedStop === ownProps.entity.id ? hoverColor : '#FFF', + fillOpacity: 1, + weight: 1 + }, stop: ownProps.entity } } diff --git a/lib/components/map/connected-stops-overlay.js b/lib/components/map/connected-stops-overlay.js index d52f845f1..643f08c7c 100644 --- a/lib/components/map/connected-stops-overlay.js +++ b/lib/components/map/connected-stops-overlay.js @@ -1,17 +1,29 @@ import StopsOverlay from '@opentripplanner/stops-overlay' -import StopMarker from './connected-stop-marker' import { connect } from 'react-redux' import { findStopsWithinBBox } from '../../actions/api' +import StopMarker from './connected-stop-marker' + // connect to the redux store const mapStateToProps = (state, ownProps) => { + const { viewedRoute } = state.otp.ui + + let { stops } = state.otp.overlay.transit + let minZoom = 15 + + // If a pattern is being shown, show only the pattern's stops and show them large + if (viewedRoute?.patternId && state.otp.transitIndex.routes) { + stops = state.otp.transitIndex.routes[viewedRoute.routeId]?.patterns?.[viewedRoute.patternId].stops + minZoom = 2 + } + return { - stops: state.otp.overlay.transit.stops, + stops: stops || [], symbols: [ { - minZoom: 15, + minZoom, symbol: StopMarker } ] diff --git a/lib/components/map/connected-transit-vehicle-overlay.js b/lib/components/map/connected-transit-vehicle-overlay.js new file mode 100644 index 000000000..6879576e6 --- /dev/null +++ b/lib/components/map/connected-transit-vehicle-overlay.js @@ -0,0 +1,123 @@ +/** + * This overlay is similar to gtfs-rt-vehicle-overlay in that it shows + * realtime positions of vehicles on a route using the otp-ui/transit-vehicle-overlay. + * + * However, this overlay differs in a few ways: + * 1) This overlay retrieves vehicle locations from OTP + * 2) This overlay renders vehicles as blobs rather than a custom shape + * 3) This overlay does not handle updating positions + * 4) This overlay does not render route paths + * 5) This overlay has a custom popup on vehicle hover + */ +import { Circle, CircledVehicle } from '@opentripplanner/transit-vehicle-overlay/lib/components/markers/ModeCircles' +import { connect } from 'react-redux' +import { FormattedMessage, FormattedNumber, injectIntl } from 'react-intl' +import TransitVehicleOverlay from '@opentripplanner/transit-vehicle-overlay' +import { Tooltip } from 'react-leaflet' + +const vehicleSymbols = [ + { + minZoom: 0, + symbol: Circle + }, + { + minZoom: 10, + symbol: CircledVehicle + } +] + +function VehicleTooltip (props) { + const { direction, intl, permanent, vehicle } = props + + let vehicleLabel = vehicle?.label + // If a vehicle's label is less than 5 characters long, we can assume it is a vehicle + // number. If this is the case, prepend "vehicle" to it. + // Otherwise, the label itself is enough + if (vehicleLabel !== null && vehicleLabel?.length <= 5) { + vehicleLabel = intl.formatMessage( + { id: 'components.TransitVehicleOverlay.vehicleName' }, + { vehicleNumber: vehicleLabel } + ) + } else { + vehicleLabel = '' + } + + const stopStatus = vehicle?.stopStatus || 'in_transit_to' + + // FIXME: This may not be timezone adjusted as reported seconds may be in the wrong timezone. + // All needed info to fix this is available via route.agency.timezone + // However, the needed coreUtils methods are not updated to support this + return ( + + + {/* + FIXME: move back to core-utils for time handling + */} + {m}, + relativeTime: intl.formatRelativeTime(Math.floor(vehicle?.seconds - Date.now() / 1000)), + vehicleNameOrBlank: vehicleLabel + }} + /> + + {stopStatus !== 'STOPPED_AT' && vehicle?.speed > 0 && ( +
+ + ) + }} + /> +
+ )} + {vehicle?.nextStopName && ( +
+ +
+ )} +
+ ) +} +// connect to the redux store + +const mapStateToProps = (state, ownProps) => { + const viewedRoute = state.otp.ui.viewedRoute + const route = state.otp.transitIndex?.routes?.[viewedRoute?.routeId] + + let vehicleList = [] + + // Add missing fields to vehicle list + if (viewedRoute?.routeId) { + vehicleList = route?.vehicles?.map(vehicle => { + vehicle.routeType = route?.mode + vehicle.routeColor = route?.color + vehicle.textColor = route?.routeTextColor + return vehicle + }) + + // Remove all vehicles not on pattern being currently viewed + if (viewedRoute.patternId && vehicleList) { + vehicleList = vehicleList + .filter( + (vehicle) => vehicle.patternId === viewedRoute.patternId + ) + } + } + return { symbols: vehicleSymbols, TooltipSlot: injectIntl(VehicleTooltip), vehicleList } +} + +const mapDispatchToProps = {} + +export default connect(mapStateToProps, mapDispatchToProps)(TransitVehicleOverlay) diff --git a/lib/components/map/default-map.js b/lib/components/map/default-map.js index 2e28626cb..e99af0bec 100644 --- a/lib/components/map/default-map.js +++ b/lib/components/map/default-map.js @@ -20,6 +20,7 @@ import BoundsUpdatingOverlay from './bounds-updating-overlay' import EndpointsOverlay from './connected-endpoints-overlay' import ParkAndRideOverlay from './connected-park-and-ride-overlay' import RouteViewerOverlay from './connected-route-viewer-overlay' +import TransitVehicleOverlay from './connected-transit-vehicle-overlay' import StopViewerOverlay from './connected-stop-viewer-overlay' import StopsOverlay from './connected-stops-overlay' import TransitiveOverlay from './connected-transitive-overlay' @@ -159,6 +160,7 @@ class DefaultMap extends Component { + diff --git a/lib/components/mobile/batch-search-screen.js b/lib/components/mobile/batch-search-screen.js index e6582fa96..c110feb14 100644 --- a/lib/components/mobile/batch-search-screen.js +++ b/lib/components/mobile/batch-search-screen.js @@ -6,12 +6,11 @@ import BatchSettings from '../form/batch-settings' import DefaultMap from '../map/default-map' import LocationField from '../form/connected-location-field' import SwitchButton from '../form/switch-button' +import { MobileScreens, setMobileScreen } from '../../actions/ui' import MobileContainer from './container' import MobileNavigationBar from './navigation-bar' -import { MobileScreens, setMobileScreen } from '../../actions/ui' - const { SET_DATETIME, SET_FROM_LOCATION, diff --git a/lib/components/mobile/navigation-bar.js b/lib/components/mobile/navigation-bar.js index 3c4f57e65..844c50a7d 100644 --- a/lib/components/mobile/navigation-bar.js +++ b/lib/components/mobile/navigation-bar.js @@ -28,9 +28,9 @@ class MobileNavigationBar extends Component { } render () { - const { defaultMobileTitle } = this.context const { auth0Config, + defaultMobileTitle, headerAction, headerText, showBackButton diff --git a/lib/components/mobile/route-viewer.js b/lib/components/mobile/route-viewer.js index 97b25fe5b..9131cbad6 100644 --- a/lib/components/mobile/route-viewer.js +++ b/lib/components/mobile/route-viewer.js @@ -1,14 +1,14 @@ import React, { Component } from 'react' -import PropTypes from 'prop-types' import { connect } from 'react-redux' +import PropTypes from 'prop-types' -import RouteViewer from '../viewers/route-viewer' +import { ComponentContext } from '../../util/contexts' import DefaultMap from '../map/default-map' import { setViewedRoute, setMainPanelContent } from '../../actions/ui' -import { ComponentContext } from '../../util/contexts' +import RouteViewer from '../viewers/route-viewer' -import MobileNavigationBar from './navigation-bar' import MobileContainer from './container' +import MobileNavigationBar from './navigation-bar' class MobileRouteViewer extends Component { static propTypes = { diff --git a/lib/components/mobile/welcome-screen.js b/lib/components/mobile/welcome-screen.js index 31ced700b..66416ad2d 100644 --- a/lib/components/mobile/welcome-screen.js +++ b/lib/components/mobile/welcome-screen.js @@ -2,14 +2,14 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' -import MobileContainer from './container' import LocationField from '../form/connected-location-field' import DefaultMap from '../map/default-map' -import MobileNavigationBar from './navigation-bar' - import { MobileScreens, setMobileScreen } from '../../actions/ui' import { setLocationToCurrent } from '../../actions/map' +import MobileNavigationBar from './navigation-bar' +import MobileContainer from './container' + class MobileWelcomeScreen extends Component { static propTypes = { setLocationToCurrent: PropTypes.func, @@ -36,7 +36,7 @@ class MobileWelcomeScreen extends Component { render () { return ( - +
window.history.back() - /** * Back link that navigates to the previous location in browser history. */ diff --git a/lib/components/viewers/RouteRow.js b/lib/components/viewers/RouteRow.js new file mode 100644 index 000000000..c461ab638 --- /dev/null +++ b/lib/components/viewers/RouteRow.js @@ -0,0 +1,160 @@ +import { Label, Button } from 'react-bootstrap' +import React, { PureComponent } from 'react' +import styled from 'styled-components' +import { VelocityTransitionGroup } from 'velocity-react' + +import { ComponentContext } from '../../util/contexts' +import { getColorAndNameFromRoute, getModeFromRoute } from '../../util/viewer' + +import RouteDetails from './route-details' + +export class RouteRow extends PureComponent { + static contextType = ComponentContext; + + constructor (props) { + super(props) + // Create a ref used to scroll to + this.activeRef = React.createRef() + } + + componentDidMount = () => { + const { getVehiclePositionsForRoute, isActive, route } = this.props + if (isActive && route?.id) { + // Update data to populate map + getVehiclePositionsForRoute(route.id) + // This is fired when coming back from the route details view + this.activeRef.current.scrollIntoView() + } + }; + + componentDidUpdate () { + /* + If the initial route row list is being rendered and there is an active + route, scroll to it. The initialRender prop prohibits the row being scrolled to + if the user has clicked on a route + */ + if (this.props.isActive && this.props.initialRender) { + this.activeRef.current.scrollIntoView() + } + } + + _onClick = () => { + const { findRoute, getVehiclePositionsForRoute, isActive, route, setViewedRoute } = this.props + if (isActive) { + // Deselect current route if active. + setViewedRoute({ patternId: null, routeId: null }) + } else { + // Otherwise, set active and fetch route patterns. + findRoute({ routeId: route.id }) + getVehiclePositionsForRoute(route.id) + setViewedRoute({ routeId: route.id }) + } + }; + + render () { + const { intl, isActive, operator, route } = this.props + const { ModeIcon } = this.context + + return ( + + + + {operator && operator.logo && ( + + )} + + + + + + + + {isActive && } + + + ) + } +} + +export const StyledRouteRow = styled.div` + background-color: white; + border-bottom: 1px solid gray; +` + +export const RouteRowButton = styled(Button)` + align-items: center; + display: flex; + padding: 6px; + width: 100%; + transition: all ease-in-out 0.1s; +` + +export const RouteRowElement = styled.span`` + +export const OperatorImg = styled.img` + height: 25px; + margin-right: 8px; +` + +export const ModeIconElement = styled.span` + display: inline-block; + vertical-align: bottom; + height: 22px; +` + +const RouteNameElement = styled(Label)` + background-color: ${(props) => + props.backgroundColor === '#ffffff' || props.backgroundColor === 'white' + ? 'rgba(0,0,0,0)' + : props.backgroundColor}; + color: ${(props) => props.color}; + flex: 0 1 auto; + font-size: medium; + font-weight: 400; + margin-left: ${(props) => + props.backgroundColor === '#ffffff' || props.backgroundColor === 'white' + ? 0 + : '8px'}; + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; +` + +export const RouteName = ({operator, route}) => { + const { backgroundColor, color, longName } = getColorAndNameFromRoute( + operator, + route + ) + return ( + + {route.shortName} {longName} + + ) +} diff --git a/lib/components/viewers/route-details.js b/lib/components/viewers/route-details.js new file mode 100644 index 000000000..5aea3d7ef --- /dev/null +++ b/lib/components/viewers/route-details.js @@ -0,0 +1,214 @@ +import React, { Component } from 'react' +import { connect } from 'react-redux' +import { FormattedMessage, injectIntl } from 'react-intl' +import PropTypes from 'prop-types' + +import { extractHeadsignFromPattern, getColorAndNameFromRoute } from '../../util/viewer' +import Icon from '../util/icon' +import { getVehiclePositionsForRoute, findStopsForPattern } from '../../actions/api' +import { setHoveredStop, setViewedStop, setViewedRoute } from '../../actions/ui' + +import { + Container, + RouteNameContainer, + LogoLinkContainer, + PatternContainer, + StopContainer, + Stop +} from './styled' + +class RouteDetails extends Component { + static propTypes = { + className: PropTypes.string, + findStopsForPattern: findStopsForPattern.type, + operator: PropTypes.shape({ + defaultRouteColor: PropTypes.string, + defaultRouteTextColor: PropTypes.string, + longNameSplitter: PropTypes.string + }), + // There are more items in pattern and route, but none mandatory + pattern: PropTypes.shape({ id: PropTypes.string }), + route: PropTypes.shape({ id: PropTypes.string }), + setHoveredStop: setHoveredStop.type, + setViewedRoute: setViewedRoute.type + }; + + componentDidMount = () => { + const { getVehiclePositionsForRoute, pattern, route } = this.props + if (!route.vehicles) { + getVehiclePositionsForRoute(route.id) + } + if (!pattern?.stops) { this.getStops() } + }; + + componentDidUpdate = (prevProps) => { + if (prevProps.pattern?.id !== this.props.pattern?.id) { + this.getStops() + } + }; + + /** + * Requests stop list for current pattern + */ + getStops = () => { + const { findStopsForPattern, pattern, route } = this.props + if (pattern && route) { + findStopsForPattern({ patternId: pattern.id, routeId: route.id }) + } + }; + + /** + * If a headsign link is clicked, set that pattern in redux state so that the + * view can adjust + */ + _headSignButtonClicked = (e) => { + const { target } = e + const { value: id } = target + const { route, setViewedRoute } = this.props + setViewedRoute({ patternId: id, routeId: route.id }) + }; + + /** + * If a stop link is clicked, redirect to stop viewer + */ + _stopLinkClicked = (stopId) => { + const { setViewedStop } = this.props + setViewedStop({ stopId }) + }; + + render () { + const { intl, operator, pattern, route, setHoveredStop, viewedRoute } = this.props + const { agency, patterns, url } = route + + const { + backgroundColor: routeColor + } = getColorAndNameFromRoute(operator, route) + + const headsigns = + patterns && + Object.entries(patterns) + .map((pattern) => { + return { + geometryLength: pattern[1].geometry?.length, + headsign: extractHeadsignFromPattern(pattern[1]), + id: pattern[0] + } + }) + // Remove duplicate headsigns. Using a reducer means that the first pattern + // with a specific headsign is the accepted one. TODO: is this good behavior? + .reduce((prev, cur) => { + const amended = prev + const alreadyExistingIndex = prev.findIndex( + (h) => h.headsign === cur.headsign + ) + // If the item we're replacing has less geometry, replace it! + if (alreadyExistingIndex >= 0) { + // Only replace if new pattern has greater geometry + if ( + amended[alreadyExistingIndex].geometryLength < cur.geometryLength + ) { + amended[alreadyExistingIndex] = cur + } + } else { + amended.push(cur) + } + return amended + }, []) + .sort((a, b) => { + // sort by number of vehicles on that pattern + const aVehicleCount = route.vehicles?.filter( + (vehicle) => vehicle.patternId === a.id + ).length + const bVehicleCount = route.vehicles?.filter( + (vehicle) => vehicle.patternId === b.id + ).length + + // if both have the same count, sort by pattern geometry length + if (aVehicleCount === bVehicleCount) { + return b.geometryLength - a.geometryLength + } + return bVehicleCount - aVehicleCount + }) + + // if no pattern is set, we are in the routeRow + return ( + + + + {agency && } + {url && ( + + + + + )} + + + +

+ +

+ {headsigns && + } +
+ {pattern && ( + setHoveredStop(null)} + > + {pattern?.stops?.map((stop) => ( + this._stopLinkClicked(stop.id)} + onFocus={() => setHoveredStop(stop.id)} + onMouseOver={() => setHoveredStop(stop.id)} + routeColor={routeColor.includes('ffffff') ? '#333' : routeColor} + > + {stop.name} + + ))} + + )} +
+ ) + } +} + +// connect to redux store +const mapStateToProps = (state, ownProps) => { + return { + viewedRoute: state.otp.ui.viewedRoute + } +} + +const mapDispatchToProps = { + findStopsForPattern, + getVehiclePositionsForRoute, + setHoveredStop, + setViewedRoute, + setViewedStop +} + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(RouteDetails)) diff --git a/lib/components/viewers/route-viewer.js b/lib/components/viewers/route-viewer.js index 362016c35..b724caca6 100644 --- a/lib/components/viewers/route-viewer.js +++ b/lib/components/viewers/route-viewer.js @@ -1,61 +1,178 @@ +import React, { Component } from 'react' +import { Button } from 'react-bootstrap' +import { connect } from 'react-redux' import coreUtils from '@opentripplanner/core-utils' -import React, { Component, PureComponent } from 'react' +import { FormattedMessage, injectIntl } from 'react-intl' import PropTypes from 'prop-types' -import { Label, Button } from 'react-bootstrap' -import { VelocityTransitionGroup } from 'velocity-react' -import { connect } from 'react-redux' -import styled from 'styled-components' -import Icon from '../util/icon' -import { setMainPanelContent, setViewedRoute } from '../../actions/ui' -import { findRoutes, findRoute } from '../../actions/api' import { ComponentContext } from '../../util/contexts' import { getModeFromRoute } from '../../util/viewer' +import { getVehiclePositionsForRoute, findRoutes, findRoute } from '../../actions/api' +import Icon from '../util/icon' +import { + getAgenciesFromRoutes, + getModesForActiveAgencyFilter, + getSortedFilteredRoutes +} from '../../util/state' +import { + setMainPanelContent, + setViewedRoute, + setRouteViewerFilter +} from '../../actions/ui' -/** - * Determine the appropriate contrast color for text (white or black) based on - * the input hex string (e.g., '#ff00ff') value. - * - * From https://stackoverflow.com/a/11868398/915811 - * - * TODO: Move to @opentripplanner/core-utils once otp-rr uses otp-ui library. - */ -function getContrastYIQ (hexcolor) { - hexcolor = hexcolor.replace('#', '') - const r = parseInt(hexcolor.substr(0, 2), 16) - const g = parseInt(hexcolor.substr(2, 2), 16) - const b = parseInt(hexcolor.substr(4, 2), 16) - const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000 - return (yiq >= 128) ? '000000' : 'ffffff' -} +import RouteDetails from './route-details' +import { RouteRow, RouteName } from './RouteRow' class RouteViewer extends Component { static propTypes = { + agencies: PropTypes.array, + filter: PropTypes.shape({ + agency: PropTypes.string, + mode: PropTypes.string, + search: PropTypes.string + }), + findRoute: findRoute.type, + getVehiclePositionsForRoute: getVehiclePositionsForRoute.type, hideBackButton: PropTypes.bool, - routes: PropTypes.object + modes: PropTypes.array, + routes: PropTypes.array, + setViewedRoute: setViewedRoute.type, + transitOperators: PropTypes.array, + viewedRoute: PropTypes.shape({ + patternId: PropTypes.string, + routeId: PropTypes.string + }), + // Routes have many more properties, but none are guaranteed + viewedRouteObject: PropTypes.shape({ id: PropTypes.string }) + }; + + state = { + /** Used to track if all routes have been rendered */ + initialRender: true } - _backClicked = () => this.props.setMainPanelContent(null) + static contextType = ComponentContext + + /** + * If we're viewing a pattern's stops, route to + * main route viewer, otherwise go back to main view + */ + _backClicked = () => + this.props.viewedRoute === null + ? this.props.setMainPanelContent(null) + : this.props.setViewedRoute({...this.props.viewedRoute, patternId: null}); componentDidMount () { - this.props.findRoutes() + const { findRoutes } = this.props + findRoutes() + } + + /** Used to scroll to actively viewed route on load */ + componentDidUpdate () { + const { routes } = this.props + const { initialRender } = this.state + + // Wait until more than the one route is present. + // This ensures that there is something to scroll past! + if (initialRender && routes.length > 1) { + // Using requestAnimationFrame() ensures that the scroll only happens once + // paint is complete + window.requestAnimationFrame(() => { + // Setting initialRender to false ensures that routeRow will not initiate + // any more scrolling + this.setState({initialRender: false}) + }) + } + } + + /** + * Handle filter dropdown change. Id of the filter is equivalent to the key in the + * route object + */ + onFilterChange = (event) => { + const { eventPhase, target } = event + // If the dropdown changes without user interaction, don't update! + // see https://developer.mozilla.org/en-US/docs/Web/API/Event/eventPhase + if (eventPhase !== Event.BUBBLING_PHASE) { + return + } + const { id, value } = target + // id will be either 'agency' or 'mode' based on the dropdown used + this.props.setRouteViewerFilter({ [id]: value }) + } + + /** + * Update search state when user updates search field + */ + onSearchChange = (event) => { + const { target } = event + const { value } = target + this.props.setRouteViewerFilter({ search: value }) } render () { const { + agencies, + filter, findRoute, + getVehiclePositionsForRoute, hideBackButton, - languageConfig, - routes, + intl, + modes, + routes: sortedRoutes, setViewedRoute, transitOperators, - viewedRoute + viewedRoute, + viewedRouteObject } = this.props - const sortedRoutes = routes - ? Object.values(routes).sort( - coreUtils.route.makeRouteComparator(transitOperators) + + const { initialRender } = this.state + const { ModeIcon } = this.context + + // If patternId is present, we're looking at a specific pattern's stops + if (viewedRoute?.patternId && viewedRouteObject) { + const { patternId } = viewedRoute + const route = viewedRouteObject + // Find operator based on agency_id (extracted from OTP route ID). + const operator = + coreUtils.route.getTransitOperatorFromOtpRoute( + route, + transitOperators + ) || {} + + return ( +
+ {/* Header Block */} +
+ {/* Always show back button, as we don't write a route anymore */} +
+ +
+ +
+ {route && ModeIcon && ( + + )} + +
+
+ +
) - : [] + } + const { search } = filter + return (
{/* Header Block */} @@ -63,199 +180,140 @@ class RouteViewer extends Component { {/* Back button */} {!hideBackButton && (
- +
)} {/* Header Text */}
- {languageConfig.routeViewer || 'Route Viewer'} +
-
- {languageConfig.routeViewerDetails} +
+
-
+
+ + + + + + + + + +
- {sortedRoutes - .map(route => { - // Find operator based on agency_id (extracted from OTP route ID). - const operator = coreUtils.route.getTransitOperatorFromOtpRoute( + {sortedRoutes.map((route) => { + // Find operator based on agency_id (extracted from OTP route ID). + const operator = + coreUtils.route.getTransitOperatorFromOtpRoute( route, transitOperators ) || {} - return ( - - ) - }) - } + return ( + + ) + })} + {/* check modes length to differentiate between loading and over-filtered */} + {modes.length > 0 && sortedRoutes.length === 0 && ( + + + + )}
) } } -const StyledRouteRow = styled.div` - background-color: ${props => props.isActive ? '#f6f8fa' : 'white'}; - border-bottom: 1px solid gray; -` - -const RouteRowButton = styled(Button)` - align-items: center; - display: flex; - padding: 6px; - width: 100%; -` - -const RouteRowElement = styled.span` -` - -const OperatorImg = styled.img` - height: 25px; - margin-right: 8px; -` - -const ModeIconElement = styled.span` - display: inline-block; - vertical-align: bottom; - height: 22px; -` - -const RouteNameElement = styled(Label)` - background-color: ${props => ( - props.backgroundColor === '#ffffff' || props.backgroundColor === 'white' - ? 'rgba(0,0,0,0)' - : props.backgroundColor - )}; - color: ${props => props.color}; - flex: 0 1 auto; - font-size: medium; - font-weight: 400; - margin-left: ${props => ( - props.backgroundColor === '#ffffff' || props.backgroundColor === 'white' - ? 0 - : '8px' - )}; - margin-top: 2px; - overflow: hidden; - text-overflow: ellipsis; -` - -const RouteDetails = styled.div` - padding: 8px; -` - -class RouteRow extends PureComponent { - static contextType = ComponentContext - - _onClick = () => { - const { findRoute, isActive, route, setViewedRoute } = this.props - if (isActive) { - // Deselect current route if active. - setViewedRoute({ routeId: null }) - } else { - // Otherwise, set active and fetch route patterns. - findRoute({ routeId: route.id }) - setViewedRoute({ routeId: route.id }) - } - } - - getCleanRouteLongName ({ longNameSplitter, route }) { - let longName = '' - if (route.longName) { - // Attempt to split route name if splitter is defined for operator (to - // remove short name value from start of long name value). - const nameParts = route.longName.split(longNameSplitter) - longName = (longNameSplitter && nameParts.length > 1) - ? nameParts[1] - : route.longName - // If long name and short name are identical, set long name to be an empty - // string. - if (longName === route.shortName) longName = '' - } - return longName - } - - render () { - const { isActive, operator, route } = this.props - const { ModeIcon } = this.context - - const {defaultRouteColor, defaultRouteTextColor, longNameSplitter} = operator || {} - const backgroundColor = `#${defaultRouteColor || route.color || 'ffffff'}` - // NOTE: text color is not a part of short response route object, so there - // is no way to determine from OTP what the text color should be if the - // background color is, say, black. Instead, determine the appropriate - // contrast color and use that if no text color is available. - const contrastColor = getContrastYIQ(backgroundColor) - const color = `#${defaultRouteTextColor || route.textColor || contrastColor}` - // Default long name is empty string (long name is an optional GTFS value). - const longName = this.getCleanRouteLongName({ longNameSplitter, route }) - return ( - - - - {operator && operator.logo && - - } - - - - - - {route.shortName} {longName} - - - - {isActive && ( - - {route.url - ? Route Details - : 'No route URL provided.' - } - - )} - - - ) - } -} // connect to redux store const mapStateToProps = (state, ownProps) => { return { - languageConfig: state.otp.config.language, - routes: state.otp.transitIndex.routes, + agencies: getAgenciesFromRoutes(state), + filter: state.otp.ui.routeViewer.filter, + modes: getModesForActiveAgencyFilter(state), + routes: getSortedFilteredRoutes(state), transitOperators: state.otp.config.transitOperators, - viewedRoute: state.otp.ui.viewedRoute + viewedRoute: state.otp.ui.viewedRoute, + viewedRouteObject: state.otp.transitIndex.routes?.[state.otp.ui.viewedRoute?.routeId] } } const mapDispatchToProps = { findRoute, findRoutes, + getVehiclePositionsForRoute, setMainPanelContent, + setRouteViewerFilter, setViewedRoute } -export default connect(mapStateToProps, mapDispatchToProps)(RouteViewer) +export default connect( + mapStateToProps, + mapDispatchToProps +)(injectIntl(RouteViewer)) diff --git a/lib/components/viewers/stop-viewer.js b/lib/components/viewers/stop-viewer.js index 8f538cef4..008d13c43 100644 --- a/lib/components/viewers/stop-viewer.js +++ b/lib/components/viewers/stop-viewer.js @@ -13,6 +13,7 @@ import * as mapActions from '../../actions/map' import * as uiActions from '../../actions/ui' import Icon from '../util/icon' import { getShowUserSettings, getStopViewerConfig } from '../../util/state' +import { navigateBack } from '../../util/ui' import LiveStopTimes from './live-stop-times' import StopScheduleTable from './stop-schedule-table' @@ -52,7 +53,7 @@ class StopViewer extends Component { viewedStop: PropTypes.object } - _backClicked = () => this.props.setMainPanelContent(null) + _backClicked = () => navigateBack() _setLocationFromStop = (locationType) => { const { setLocation, stopData } = this.props diff --git a/lib/components/viewers/styled.js b/lib/components/viewers/styled.js new file mode 100644 index 000000000..543f6eb9d --- /dev/null +++ b/lib/components/viewers/styled.js @@ -0,0 +1,92 @@ +import styled from 'styled-components' + +/** Route Details */ +export const Container = styled.div` + overflow-y: hidden; + height: 100%; + background-color: ${props => props.full ? '#ddd' : 'inherit'} +` + +export const RouteNameContainer = styled.div` + padding: 8px; + background-color: inherit; +` +export const LogoLinkContainer = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +` +export const PatternContainer = styled.div` + background-color: inherit; + color: inherit; + display: flex; + justify-content: flex-start; + align-items: baseline; + gap: 16px; + padding: 0 8px 8px; + margin: 0; + + overflow-x: scroll; + + h4 { + margin-bottom: 0px; + white-space: nowrap; + } +} +` + +export const StopContainer = styled.div` + color: #333; + background-color: #fff; + overflow-y: scroll; + height: 100%; + /* 100px bottom padding is needed to ensure all stops + are shown when browsers don't calculate 100% sensibly */ + padding: 15px 0 100px; +` +export const Stop = styled.a` + cursor: pointer; + color: #333; + display: block; + white-space: nowrap; + margin-left: 45px; + /* negative margin accounts for the height of the stop blob */ + margin-top: -25px; + + &:hover { + color: #23527c; + } + + /* this is the station blob */ + &::before { + content: ''; + display: block; + height: 20px; + width: 20px; + border: 5px solid ${props => props.routeColor}; + background: #fff; + position: relative; + top: 20px; + left: -35px; + border-radius: 20px; + } + + /* this is the line between the blobs */ + &::after { + content: ''; + display: block; + height: 20px; + width: 10px; + background: ${props => props.routeColor}; + position: relative; + left: -30px; + /* this is 2px into the blob (to make it look attached) + 30px so that each + stop's bar connects the previous bar with the current one */ + top: -37px; + } + + /* hide the first line between blobs */ + &:first-of-type::after { + background: transparent; + } +` diff --git a/lib/components/viewers/viewer-container.js b/lib/components/viewers/viewer-container.js index 97a292f00..78f3feaa4 100644 --- a/lib/components/viewers/viewer-container.js +++ b/lib/components/viewers/viewer-container.js @@ -2,10 +2,11 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' +import { MainPanelContent } from '../../actions/ui' + import StopViewer from './stop-viewer' import TripViewer from './trip-viewer' import RouteViewer from './route-viewer' -import { MainPanelContent } from '../../actions/ui' class ViewerContainer extends Component { static propTypes = { diff --git a/lib/components/viewers/viewers.css b/lib/components/viewers/viewers.css index fe92df858..09207f9b0 100644 --- a/lib/components/viewers/viewers.css +++ b/lib/components/viewers/viewers.css @@ -1,11 +1,15 @@ /* shared stop/trip viewer styles */ -.otp .route-viewer-header, .otp .stop-viewer-header, .otp .trip-viewer-header { +.otp .route-viewer-header, +.otp .stop-viewer-header, +.otp .trip-viewer-header { background-color: #ddd; padding: 12px; } -.otp .route-viewer, .otp .stop-viewer, .otp .trip-viewer, +.otp .route-viewer, +.otp .stop-viewer, +.otp .trip-viewer, .otp .stop-viewer-body { display: flex; flex-direction: column; @@ -14,8 +18,12 @@ } @keyframes yellowfade { - from { background: yellow; } - to { background: transparent; } + from { + background: yellow; + } + to { + background: transparent; + } } /* Used to briefly highlight an element and then fade to transparent. */ @@ -26,33 +34,40 @@ animation-name: yellowfade; } -/* Route Details Link a11y compatibility */ -a.routeDetails { - color: #2370b3; -} - /* Remove arrows on date input */ .otp .stop-viewer-body input[type="date"]::-webkit-inner-spin-button { -webkit-appearance: none; } -.otp .route-viewer-body, .otp .stop-viewer-body, .otp .trip-viewer-body { +.otp .route-viewer-body, +.otp .stop-viewer-body, +.otp .trip-viewer-body { overflow-x: hidden; overflow-y: auto; } -.otp .stop-viewer-body, .otp .trip-viewer-body { +.otp .stop-viewer-body, +.otp .trip-viewer-body { padding: 12px; } -.otp .stop-viewer .back-button-container, .otp .trip-viewer .back-button-container, .otp .route-viewer .back-button-container { +.otp .stop-viewer .back-button-container, +.otp .trip-viewer .back-button-container, +.otp .route-viewer .back-button-container { float: left; margin-right: 10px; } -.otp .stop-viewer .header-text, .otp .trip-viewer .header-text, .otp .route-viewer .header-text { +.otp .stop-viewer .header-text, +.otp .trip-viewer .header-text, +.otp .route-viewer .header-text { font-weight: 700; font-size: 24px; } +.otp .route-viewer .header-text.route-expanded { + display: flex; + align-items: center; + gap: 10px; +} /* stop viewer styles */ @@ -273,3 +288,59 @@ a.routeDetails { float: right; width: 50px; } + +/* Route Viewer Updates */ +.search-and-filter { + margin-top: 10px; + display: flex; + flex-wrap: wrap; + gap: 10px; +} +.search-and-filter select { + margin-left: 10px; + text-overflow: ellipsis; + min-width: 105px; + + border: none; + background: #eee; + border-radius: 5px; + padding: 5px; +} +.search-and-filter select option { + /* This allows the dropdowns to shrink and stretch */ + max-width: 0; +} + +.search-and-filter .routeFilter { + display: grid; + align-items: center; + grid-template-columns: 0fr 2fr 1fr; + width: 100%; +} +.search-and-filter .routeSearch { + display: flex; + align-items: center; + justify-content: center; +} +.search-and-filter .routeSearch input { + border: none; + padding: 0.125em 0.5em; + border-radius: 5px; + margin-left: 10px; +} +.routeSearch input::-webkit-search-cancel-button { + /* show clear button on webkit browsers */ + -webkit-appearance: searchfield-cancel-button; +} + +.routeSearch, +.routeSearch input { + width: 100%; +} + +.route-viewer-body .noRoutesFoundMessage { + display: flex; + align-items: center; + justify-content: center; + padding-top: 10px; +} diff --git a/lib/reducers/create-otp-reducer.js b/lib/reducers/create-otp-reducer.js index 488f99ce5..7e5ce4f26 100644 --- a/lib/reducers/create-otp-reducer.js +++ b/lib/reducers/create-otp-reducer.js @@ -222,6 +222,7 @@ export function getInitialState (userDefinedConfig) { rideEstimates: {} }, transitIndex: { + routes: {}, stops: {}, trips: {} }, @@ -230,7 +231,14 @@ export function getInitialState (userDefinedConfig) { locale: null, localizedMessages: {}, mobileScreen: MobileScreens.WELCOME_SCREEN, - printView: window.location.href.indexOf('/print/') !== -1 + printView: window.location.href.indexOf('/print/') !== -1, + routeViewer: { + filter: { + agency: null, + mode: null, + search: '' + } + } }, user: { autoRefreshStopTimes, @@ -252,6 +260,7 @@ function createOtpReducer (config) { // validate the initial state validateInitialState(initialState) + /* eslint-disable-next-line complexity */ return (state = initialState, action) => { const searchId = action.payload && action.payload.searchId const requestId = action.payload && action.payload.requestId @@ -751,6 +760,16 @@ function createOtpReducer (config) { } } }) + case 'REALTIME_VEHICLE_POSITIONS_RESPONSE': + return update(state, { + transitIndex: { + routes: { + [action.payload.routeId]: { + vehicles: { $set: action.payload.vehicles } + } + } + } + }) case 'CLEAR_STOPS_OVERLAY': return update(state, { overlay: { @@ -797,6 +816,9 @@ function createOtpReducer (config) { case 'CLEAR_VIEWED_TRIP': return update(state, { ui: { viewedTrip: { $set: null } } }) + case 'SET_HOVERED_STOP': + return update(state, { ui: { highlightedStop: { $set: action.payload } } }) + case 'SET_VIEWED_ROUTE': if (action.payload) { // If setting to a route (not null), also set main panel. @@ -828,6 +850,20 @@ function createOtpReducer (config) { } } }) + case 'FIND_STOPS_FOR_PATTERN_RESPONSE': + return update(state, { + transitIndex: { + routes: { + [action.payload.routeId]: { + patterns: { + [action.payload.patternId]: { + stops: { $set: action.payload.stops } + } + } + } + } + } + }) case 'FIND_STOP_TIMES_FOR_TRIP_RESPONSE': return update(state, { transitIndex: { @@ -881,13 +917,9 @@ function createOtpReducer (config) { transitIndex: { routes: { $set: action.payload } } }) } - // Otherwise, merge in only the routes not already defined - const currentRouteIds = Object.keys(state.transitIndex.routes) - const newRoutes = Object.keys(action.payload) - .filter(key => !currentRouteIds.includes(key)) - .reduce((res, key) => Object.assign(res, { [key]: action.payload[key] }), {}) + // otherwise, merge new data into what's already defined return update(state, { - transitIndex: { routes: { $merge: newRoutes } } + transitIndex: { routes: { $merge: action.payload } } }) case 'FIND_ROUTE_RESPONSE': // If routes is undefined, initialize it w/ this route only @@ -897,9 +929,17 @@ function createOtpReducer (config) { }) } // Otherwise, overwrite only this route + if (!state.transitIndex.routes[action.payload.id]) { + return update(state, { + transitIndex: { + // If it is a new route, set rather than merge with an empty object + routes: { [action.payload.id]: { $set: action.payload } } + } + }) + } return update(state, { transitIndex: { - routes: { [action.payload.id]: { $set: action.payload } } + routes: { [action.payload.id]: { $merge: action.payload } } } }) case 'FIND_PATTERNS_FOR_ROUTE_RESPONSE': @@ -910,10 +950,18 @@ function createOtpReducer (config) { transitIndex: { routes: { $set: { [routeId]: { patterns } } } } }) } - // Otherwise, overwrite only this route + // If patterns for route is undefined set it + if (!state.transitIndex.routes[routeId].patterns) { + return update(state, { + transitIndex: { + routes: { [routeId]: { patterns: { $set: patterns } } } + } + }) + } + // If the route patterns already exist, only merge in new data return update(state, { transitIndex: { - routes: { [routeId]: { patterns: { $set: patterns } } } + routes: { [routeId]: { $merge: patterns } } } }) case 'FIND_GEOMETRY_FOR_PATTERN_RESPONSE': @@ -1021,6 +1069,15 @@ function createOtpReducer (config) { locale: { $set: action.payload.locale }, localizedMessages: { $set: action.payload.messages } }}) + + case 'UPDATE_ROUTE_VIEWER_FILTER': + return update(state, { + ui: { + routeViewer: { + filter: { $merge: action.payload } + } + } + }) default: return state } diff --git a/lib/util/state.js b/lib/util/state.js index 32b9128f8..e264b3f7b 100644 --- a/lib/util/state.js +++ b/lib/util/state.js @@ -455,6 +455,70 @@ function getItineraryToRender (state) { return itins[visibleItineraryIndex] || activeItinerary } +const routeSelector = state => Object.values(state.otp.transitIndex.routes) +const routeViewerFilterSelector = state => state.otp.ui.routeViewer.filter + +/** + * Returns all routes that match the route viewer filters + */ +export const getFilteredRoutes = createSelector( + routeSelector, + routeViewerFilterSelector, + (routes, filter) => + routes.filter( + (route) => + // If the filter isn't defined, don't check. + (!filter.agency || filter.agency === route.agencyName) && + (!filter.mode || filter.mode === route.mode) && + // If user search is active, filter by either the long or short name + (!filter.search || + ((route.longName && route.longName.toLowerCase().includes(filter.search.toLowerCase())) || + (route.shortName && route.shortName.toLowerCase().includes(filter.search.toLowerCase())))) + ) +) + +/** + * Sorts routes filtered by the selector which filters routes + */ +export const getSortedFilteredRoutes = createSelector( + getFilteredRoutes, + state => state.otp.config.transitOperators, + (routes, transitOperators) => routes.sort( + coreUtils.route.makeRouteComparator(transitOperators) + ) +) + +/** + * Get the modes available for the current agency filter. First filters only by agency, + * then extracts each mode + */ +export const getModesForActiveAgencyFilter = createSelector( + routeSelector, + routeViewerFilterSelector, + (routes, filter) => Array.from( + new Set( + routes + .filter(route => (route.mode && (!filter.agency || filter.agency === route.agencyName))) + .map((route) => route.mode) + .filter((mode) => mode !== undefined) + ) + ) + .sort() + +) + +/** + * Returns list of agencies present within all routes + */ +export const getAgenciesFromRoutes = createSelector( + routeSelector, + (routes) => Array.from( + new Set(routes.map((route) => route.agencyName || route.agency.name)) + ) + .filter((agency) => agency !== undefined) + .sort() +) + /** * Converts an OTP itinerary to the transitive.js format, * using a selector to prevent unnecessary re-renderings of the transitive overlay. diff --git a/lib/util/ui.js b/lib/util/ui.js index f947128f7..9be25250d 100644 --- a/lib/util/ui.js +++ b/lib/util/ui.js @@ -44,3 +44,16 @@ export function getErrorStates (props) { * Browser navigate back. */ export const navigateBack = () => window.history.back() + +/** + * Assembles a path from a variable list of parts + * @param {...any} parts List of string components to assemble into path + * @returns A path made of the components passed in + */ +export function getPathFromParts (...parts) { + let path = '' + parts.forEach(p => { + if (p) path += `/${p}` + }) + return path +} diff --git a/lib/util/viewer.js b/lib/util/viewer.js index 1f52ab41d..6685cd309 100644 --- a/lib/util/viewer.js +++ b/lib/util/viewer.js @@ -1,3 +1,5 @@ +import tinycolor from 'tinycolor2' + import { isBlank } from './ui' /** @@ -47,6 +49,38 @@ export function routeIsValid (route, routeId) { return true } +/** + * Run heuristic on pattern description to extract headsign from pattern description + * @param {*} pattern pattern to extract headsign out of + * @returns headsign of pattern + */ +export function extractHeadsignFromPattern (pattern) { + let headsign = pattern.headsign + // In case stop time headsign is blank, extract headsign from the pattern 'desc' attribute + // (format: '49 to ()[ from ( ()[ from ( 1) + ? nameParts[1] + : route.longName + // If long name and short name are identical, set long name to be an empty + // string. + if (longName === route.shortName) longName = '' + } + return longName +} +/** + * Using an operator and route, apply heuristics to determine color and contrast color + * as well as a full route name + */ +export function getColorAndNameFromRoute (operator, route) { + const {defaultRouteColor, defaultRouteTextColor, longNameSplitter} = operator || {} + const backgroundColor = `#${defaultRouteColor || route.color || 'ffffff'}` + // NOTE: text color is not a part of short response route object, so there + // is no way to determine from OTP what the text color should be if the + // background color is, say, black. Instead, determine the appropriate + // contrast color and use that if no text color is available. + const contrastColor = getContrastYIQ(backgroundColor) + const color = `#${defaultRouteTextColor || route.textColor || contrastColor}` + // Default long name is empty string (long name is an optional GTFS value). + const longName = getCleanRouteLongName({ longNameSplitter, route }) + + // Choose a color that the text color will look good against + + return { + backgroundColor, + color, + longName + } +} diff --git a/package.json b/package.json index 0e8451e3e..d57efe378 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "reselect": "^4.0.0", "seamless-immutable": "^7.1.3", "styled-components": "^5.0.0", + "tinycolor2": "^1.4.2", "transitive-js": "^0.13.7", "velocity-react": "^1.3.3", "yup": "^0.29.3" diff --git a/yarn.lock b/yarn.lock index ba47abdfe..011bfc52c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17195,6 +17195,11 @@ tiny-warning@^1.0.0, tiny-warning@^1.0.2: resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== +tinycolor2@^1.4.2: + version "1.4.2" + resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.2.tgz#3f6a4d1071ad07676d7fa472e1fac40a719d8803" + integrity sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA== + tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"