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"