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 267209c05..90539ec34 100644
--- a/i18n/en-US.yml
+++ b/i18n/en-US.yml
@@ -36,6 +36,11 @@ components:
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.
@@ -47,6 +52,17 @@ components:
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}}
diff --git a/i18n/fr-FR.yml b/i18n/fr-FR.yml
index fa8ec1771..6a05db9eb 100644
--- a/i18n/fr-FR.yml
+++ b/i18n/fr-FR.yml
@@ -11,6 +11,11 @@ components:
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.
@@ -22,6 +27,17 @@ components:
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}}
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 b16cb6cc9..ac123df10 100644
--- a/lib/actions/ui.js
+++ b/lib/actions/ui.js
@@ -5,6 +5,7 @@ import { matchPath } from 'react-router'
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))
}
}
diff --git a/lib/components/app/app.css b/lib/components/app/app.css
index 5dfcfc047..6d71a330e 100644
--- a/lib/components/app/app.css
+++ b/lib/components/app/app.css
@@ -62,6 +62,10 @@
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;
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/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/user/back-link.js b/lib/components/user/back-link.js
index 7f2cc82e5..526fb7968 100644
--- a/lib/components/user/back-link.js
+++ b/lib/components/user/back-link.js
@@ -2,6 +2,8 @@ import React from 'react'
import { Button } from 'react-bootstrap'
import styled from 'styled-components'
+import { navigateBack } from '../../util/ui'
+
import { IconWithMargin } from './styled'
const StyledButton = styled(Button)`
@@ -9,8 +11,6 @@ const StyledButton = styled(Button)`
padding: 0;
`
-const navigateBack = () => 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 5ef294251..b724caca6 100644
--- a/lib/components/viewers/route-viewer.js
+++ b/lib/components/viewers/route-viewer.js
@@ -1,56 +1,88 @@
-import coreUtils from '@opentripplanner/core-utils'
-import React, { Component, PureComponent } from 'react'
-import PropTypes from 'prop-types'
-import { Label, Button } from 'react-bootstrap'
-import { VelocityTransitionGroup } from 'velocity-react'
+import React, { Component } from 'react'
+import { Button } from 'react-bootstrap'
import { connect } from 'react-redux'
-import styled from 'styled-components'
+import coreUtils from '@opentripplanner/core-utils'
import { FormattedMessage, injectIntl } from 'react-intl'
+import PropTypes from 'prop-types'
+import { ComponentContext } from '../../util/contexts'
+import { getModeFromRoute } from '../../util/viewer'
+import { getVehiclePositionsForRoute, findRoutes, findRoute } from '../../actions/api'
import Icon from '../util/icon'
-import {
- setMainPanelContent,
- setViewedRoute,
- setRouteViewerFilter
-} from '../../actions/ui'
-import { findRoutes, findRoute } from '../../actions/api'
import {
getAgenciesFromRoutes,
getModesForActiveAgencyFilter,
getSortedFilteredRoutes
} from '../../util/state'
-import { ComponentContext } from '../../util/contexts'
-import { getModeFromRoute } from '../../util/viewer'
+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,
modes: PropTypes.array,
- routes: 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})
+ })
+ }
}
/**
@@ -83,14 +115,62 @@ class RouteViewer extends Component {
agencies,
filter,
findRoute,
+ getVehiclePositionsForRoute,
hideBackButton,
intl,
modes,
routes: sortedRoutes,
setViewedRoute,
transitOperators,
- viewedRoute
+ viewedRoute,
+ viewedRouteObject
} = this.props
+
+ 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 (
@@ -185,6 +265,9 @@ class RouteViewer extends Component {
return (
(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
-
- ) : (
-
- )}
-
- )}
-
-
- )
- }
-}
// connect to redux store
const mapStateToProps = (state, ownProps) => {
@@ -359,13 +299,15 @@ const mapStateToProps = (state, ownProps) => {
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
diff --git a/lib/components/viewers/stop-viewer.js b/lib/components/viewers/stop-viewer.js
index 4c5661894..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 = () => window.history.back()
+ _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/viewers.css b/lib/components/viewers/viewers.css
index 6a212e591..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 */
diff --git a/lib/reducers/create-otp-reducer.js b/lib/reducers/create-otp-reducer.js
index 5014ed979..7e5ce4f26 100644
--- a/lib/reducers/create-otp-reducer.js
+++ b/lib/reducers/create-otp-reducer.js
@@ -260,7 +260,7 @@ function createOtpReducer (config) {
// validate the initial state
validateInitialState(initialState)
- // eslint-disable-next-line complexity
+ /* eslint-disable-next-line complexity */
return (state = initialState, action) => {
const searchId = action.payload && action.payload.searchId
const requestId = action.payload && action.payload.requestId
@@ -760,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: {
@@ -806,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.
@@ -837,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: {
@@ -890,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
@@ -927,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':
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"