diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index ff497a107..f40c59996 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -12,6 +12,7 @@ jobs: - uses: codespell-project/actions-codespell@master with: check_filenames: true - # The a11y test file has a false positive and the ignore list does not work + # skip git, yarn, and i18n non-english resources. + # Also, the a11y test file has a false positive and the ignore list does not work # see https://github.com/opentripplanner/otp-react-redux/pull/436/checks?check_run_id=3369380014 - skip: ./.git,yarn.lock,./a11y/a11y.test.js + skip: ./.git,yarn.lock,./a11y/a11y.test.js,./i18n/fr* diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index 635a49f38..f63891002 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -12,10 +12,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Use Node.js 12.x + - name: Use Node.js 14.x uses: actions/setup-node@v1 with: - node-version: 12.x + node-version: 14.x - name: Install npm packages using cache uses: bahmutov/npm-install@v1 - name: Copy example config diff --git a/__tests__/components/viewers/__snapshots__/stop-viewer.js.snap b/__tests__/components/viewers/__snapshots__/stop-viewer.js.snap index 81e21e621..e83ba8e43 100644 --- a/__tests__/components/viewers/__snapshots__/stop-viewer.js.snap +++ b/__tests__/components/viewers/__snapshots__/stop-viewer.js.snap @@ -235,12 +235,10 @@ exports[`components > viewers > stop viewer should render countdown times after @@ -294,12 +292,10 @@ exports[`components > viewers > stop viewer should render countdown times after @@ -494,7 +490,7 @@ exports[`components > viewers > stop viewer should render countdown times after
viewers > stop viewer should render countdown times after "marginRight": 2, } } - type="clock-o" > viewers > stop viewer should render countdown times after "marginRight": 2, } } - type="clock-o" /> @@ -944,12 +938,10 @@ exports[`components > viewers > stop viewer should render countdown times after @@ -1030,12 +1022,10 @@ exports[`components > viewers > stop viewer should render countdown times after className="" fixedWidth={true} name="refresh" - type="refresh" > @@ -1189,12 +1179,10 @@ exports[`components > viewers > stop viewer should render countdown times for st @@ -1248,12 +1236,10 @@ exports[`components > viewers > stop viewer should render countdown times for st @@ -1448,7 +1434,7 @@ exports[`components > viewers > stop viewer should render countdown times for st
viewers > stop viewer should render countdown times for st "marginRight": 2, } } - type="clock-o" > viewers > stop viewer should render countdown times for st "marginRight": 2, } } - type="clock-o" /> @@ -1709,12 +1693,10 @@ exports[`components > viewers > stop viewer should render countdown times for st @@ -1795,12 +1777,10 @@ exports[`components > viewers > stop viewer should render countdown times for st className="" fixedWidth={true} name="refresh" - type="refresh" > @@ -2053,12 +2033,10 @@ exports[`components > viewers > stop viewer should render times after midnight w @@ -2112,12 +2090,10 @@ exports[`components > viewers > stop viewer should render times after midnight w @@ -2312,7 +2288,7 @@ exports[`components > viewers > stop viewer should render times after midnight w
viewers > stop viewer should render times after midnight w "marginRight": 2, } } - type="clock-o" > viewers > stop viewer should render times after midnight w "marginRight": 2, } } - type="clock-o" /> @@ -2771,12 +2745,10 @@ exports[`components > viewers > stop viewer should render times after midnight w @@ -2857,12 +2829,10 @@ exports[`components > viewers > stop viewer should render times after midnight w className="" fixedWidth={true} name="refresh" - type="refresh" > @@ -3373,12 +3343,10 @@ exports[`components > viewers > stop viewer should render with OTP transit index @@ -3432,12 +3400,10 @@ exports[`components > viewers > stop viewer should render with OTP transit index @@ -3632,7 +3598,7 @@ exports[`components > viewers > stop viewer should render with OTP transit index
viewers > stop viewer should render with OTP transit index "marginRight": 2, } } - type="clock-o" > viewers > stop viewer should render with OTP transit index "marginRight": 2, } } - type="clock-o" /> @@ -4347,12 +4311,10 @@ exports[`components > viewers > stop viewer should render with OTP transit index @@ -4549,7 +4511,6 @@ exports[`components > viewers > stop viewer should render with OTP transit index "marginRight": 2, } } - type="clock-o" > viewers > stop viewer should render with OTP transit index "marginRight": 2, } } - type="clock-o" /> @@ -4606,12 +4566,10 @@ exports[`components > viewers > stop viewer should render with OTP transit index @@ -4808,7 +4766,6 @@ exports[`components > viewers > stop viewer should render with OTP transit index "marginRight": 2, } } - type="clock-o" > viewers > stop viewer should render with OTP transit index "marginRight": 2, } } - type="clock-o" /> @@ -4865,12 +4821,10 @@ exports[`components > viewers > stop viewer should render with OTP transit index @@ -5121,7 +5075,6 @@ exports[`components > viewers > stop viewer should render with OTP transit index "marginRight": 2, } } - type="clock-o" > viewers > stop viewer should render with OTP transit index "marginRight": 2, } } - type="clock-o" /> @@ -5178,12 +5130,10 @@ exports[`components > viewers > stop viewer should render with OTP transit index @@ -5264,12 +5214,10 @@ exports[`components > viewers > stop viewer should render with OTP transit index className="" fixedWidth={true} name="refresh" - type="refresh" > @@ -5775,12 +5723,10 @@ exports[`components > viewers > stop viewer should render with TriMet transit in @@ -5834,12 +5780,10 @@ exports[`components > viewers > stop viewer should render with TriMet transit in @@ -6034,7 +5978,7 @@ exports[`components > viewers > stop viewer should render with TriMet transit in
viewers > stop viewer should render with TriMet transit in "marginRight": 2, } } - type="clock-o" > viewers > stop viewer should render with TriMet transit in "marginRight": 2, } } - type="clock-o" /> @@ -6746,12 +6688,10 @@ exports[`components > viewers > stop viewer should render with TriMet transit in @@ -6832,12 +6772,10 @@ exports[`components > viewers > stop viewer should render with TriMet transit in className="" fixedWidth={true} name="refresh" - type="refresh" > @@ -6925,12 +6863,10 @@ exports[`components > viewers > stop viewer should render with initial stop id a 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-config.yml b/example-config.yml index 889ba5ab8..2ecd0b388 100644 --- a/example-config.yml +++ b/example-config.yml @@ -15,7 +15,6 @@ api: # lon: -122.71607145667079 # name: Oregon Zoo, Portland, OR - ### Define the strategies for how to handle auto-planning of a new trip when ### different query parameters are changes in the form. The default config is ### shown below, but if autoPlan is set to false, auto-plan will never occur. @@ -30,7 +29,6 @@ api: # defaultQueryParams: # maxWalkDistance: 3219 # 2 miles in meters - ### The persistence setting is used to enable the storage of places (home, work), ### recent searches/places, user overrides, and favorite stops. ### Pick the strategy that best suits your needs. @@ -60,13 +58,37 @@ persistence: # apiBaseUrl: https://otp-middleware.example.com # apiKey: your-middleware-api-key +### Adding additional menu items to the main menu items. Use the separator flag +### to include a separator line if you have groups of menu items +### If a Top level menu item contains submenu items (children) then use the 'children' flag. +### Icon URL is preferable over iconType. If none are given then a 'bus' iconType is used. +#App menu +#extraMenuItems: +# - id: link-list +# label: List of Links +# iconType: 'train' +# iconUrl: '' +# children: +# - id: bus-website +# # Label will be overridden by localization file and can be omitted if a localized version is present +# # only one of iconType or iconUrl is needed +# iconType: 'bus' +# iconUrl: '' +# href: '' +# separator: 'true' +# - id: car-website +# label: Car Website +# iconType: 'car' +# iconUrl: '' +# href: '' + map: initLat: 45.52 initLon: -122.682 baseLayers: - name: Streets url: //cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}@2x.png - subdomains: 'abcd' + subdomains: "abcd" attribution: 'Map tiles: © OpenStreetMap, © CARTO' maxZoom: 20 hasRetinaSupport: true @@ -123,6 +145,9 @@ geocoder: # This base URL is required as the libraries will default to using now-defunct # mapzen urls baseUrl: https://geocoder.example.com/pelias/v1 + # This allows the location field dropdown headers to be colored based on Pelias layer + #resultsColors: + # stops: "#007fae" # example config for an ArcGIS geocoder # (https://developers.arcgis.com/rest/geocode/api-reference/overview-world-geocoding-service.htm) @@ -294,6 +319,9 @@ itinerary: # common: # accessModes: # bikeshare: Blue Bike +# . config: +# menuItems: +# demo-item: Demo Item ### Localization section to provide language/locale settings #localization: @@ -301,7 +329,7 @@ itinerary: # # In some components such as DefaultItinerary, we display a cost element # # that falls back to $0.00 (or its equivalent in the configured ambient currency # # and in the user-selected locale) if no fare or currency info is available. -# currency: 'USD' +# currency: 'USD' # defaultLocale: 'en-US' ### If using OTP Middleware, you can define the optional phone number options below. @@ -317,7 +345,6 @@ itinerary: # Format the date time format for display. dateTime: longDateFormat: DD-MM-YYYY - # stopViewer: # # The max. number of departures to show for each trip pattern # # in the stop viewer Next Arrivals mode 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 f8a220fad..018a84f21 100644 --- a/i18n/en-US.yml +++ b/i18n/en-US.yml @@ -22,15 +22,13 @@ _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 for the departure-arrival string - # (this will accommodate right-to-left languages by swapping the order in this string). - departureArrivalTimes: "{startTime}—{endTime}" # 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}" @@ -38,10 +36,126 @@ 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. + 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}" + AppMenu: + closeMenu: "Close Menu" + openMenu: "Open Menu" + menuItemIconAlt: "Icon for {label} menu item" + callHistory: "Call History" + fieldTrip: "Field Trip" + mailables: "Mailables" + ItinerarySummary: + fareCost: "{useMaxFare, select, + true {{minTotalFare} - {maxTotalFare}} + other {{minTotalFare}} + }" + NarrativeItinerariesHeader: + numIssues: "{issueNum, number} issues" + resultText: "{pending, select, + true {Finding your options...} + other { + {itineraryNum, number} {itineraryNum, plural, + one {itinerary found} + other {itineraries found} + } + } + }" + selectArrivalTime: Arrival time + selectBest: Best option + selectCost: Cost + selectDepartureTime: Departure time + selectDuration: Duration + selectWalkTime: Walk time + titleText: "{pending, select, + true {Finding your options...} + other { + {itineraryNum, number} {itineraryNum, plural, + one {itinerary} + other {itineraries}} + {issueNum, plural, + =0 {found} + one {(and {issueNum, number} issue) found} + other {(and {issueNum, number} issues) found} + } + } + }" + viewAll: View all options + PlanFirstLastButtons: + # Note to translator: these values are width-constrained. + first: First + last: Last + next: Next + previous: Previous + RealtimeAnnotation: + ignoreServiceDelays: Apply service delays + delaysNotShownInResults: "Your trip results are currently being affected by service delays. + These delays do not factor into travel times shown below." + delaysShownInResults: "Your trip results have been adjusted based on real-time + information. Under normal conditions, this trip would take {normalDuration} + using the following routes: {routes}." + ignoreServiceDelays: Ignore service delays + serviceUpdate: Service update + SaveTripButton: + cantSaveText: Cannot save + cantSaveTooltip: Only itineraries that include transit and no rentals or ride hailing can be monitored. + saveTripText: Save trip + signInText: Sign in to save trip + signInTooltip: Please sign in to save trip. + SimpleRealtimeAnnotation: + usingRealtimeInfo: This trip uses real-time traffic and delay information + TabbedItineraries: + optionNumber: "Option {optionNum, number}" + fareCost: "{hasMaxFare, select, + true {{minTotalFare}+} + other {{minTotalFare}} + }" + TripTools: + # Note to translator: copyLink, linkCopied, print, reportIssue, + # and startOver are width-constrained. + copyLink: Copy link + # Text that replaces the copyLink button text after user clicks it. + linkCopied: Copied + print: Print + reportIssue: Report Issue + reportEmailSubject: Reporting an Issue with OpenTripPlanner + reportEmailTemplate: " *** INSTRUCTIONS TO USER *** + This feature allows you to email a report to site administrators for review. + Please add any additional feedback for this trip under the 'Additional Comments' + section below and send using your regular email program." # Common messages that appear in multiple components and modules # are grouped below by topic. common: + # Standard navigation + navigation: + back: Back + startOver: Start Over # OTP access modes accessModes: bike: Bike @@ -49,7 +163,11 @@ common: drive: Drive micromobility: E-Scooter micromobilityRent: Rental E-Scooter - walk: Walk + walk: Walk + + itineraryDescriptions: + calories: "{calories, number} Cal" + transfers: "{transfers, plural, =0 {} one {{transfers} transfer} other {{transfers} transfers}}" # OTP transit modes # Note that identifiers are OTP modes converted to lowercase. @@ -63,3 +181,12 @@ common: cable_car: Cable Car gondola: Gondola funicular: Funicular + + time: + # Use ordered placeholders for the departure-arrival string + # (this will accommodate right-to-left languages by swapping the order in this string). + departureArrivalTimes: "{startTime, time, short}—{endTime, time, short}" + tripDurationFormat: "{hours, plural, + =0 {{minutes, number} min} + other {{hours, number} hr {minutes, number} min}}" + \ No newline at end of file diff --git a/i18n/fr-FR.yml b/i18n/fr-FR.yml index cdbf33b27..13b5ec614 100644 --- a/i18n/fr-FR.yml +++ b/i18n/fr-FR.yml @@ -2,21 +2,144 @@ _id: fr-FR _name: Unofficial French Translations! components: + BatchRoutingPanel: + shortTitle: Planifier un trajet DefaultItinerary: clickDetails: Cliquez pour afficher les détails - departureArrivalTimes: "{startTime}—{endTime}" multiModeSummary: "{accessMode} + {transitMode}" + # If trip is less than one hour only display the minutes. tripDurationFormatZeroHours: "{minutes, number} mn" - tripDurationFormat: "{hours, number} h, {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}" + AppMenu: + closeMenu: "Fermer le menu" + openMenu: "Ouvrir le menu" + menuItemIconAlt: "Icône pour le menu {label}" + callHistory: "Historique des appels" + fieldTrip: "Groupes scolaires" + mailables: "Envoi de brochures" + ItinerarySummary: + fareCost: "{useMaxFare, select, + true {{minTotalFare} - {maxTotalFare}} + other {{minTotalFare}} + }" + NarrativeItinerariesHeader: + numIssues: "{issueNum, number} problèmes" + resultText: "{pending, select, + true {Recherche de vos options en cours...} + other { + {itineraryNum, number} {itineraryNum, plural, + one {trajet trouvé} + other {trajets trouvés} + } + } + }" + selectArrivalTime: Heure d'arrivée + selectBest: Meilleure option + selectCost: Prix + selectDepartureTime: Heure de départ + selectDuration: Durée + selectWalkTime: Temps de marche + titleText: "{pending, select, + true {Recherche de vos options en cours...} + other { + {itineraryNum, number} {itineraryNum, plural, + one {trajet} + other {trajets}} + {issueNum, plural, + =0 {trouvé} + one {(et {issueNum, number} problème) trouvé} + other {(and {issueNum, number} problèmes) trouvés} + } + } + }" + viewAll: Voir toutes les options + PlanFirstLastButtons: + # Note to translator: these values are width-constrained. + first: Premier + last: Dernier + next: Suivant + previous: Précédent + RealtimeAnnotation: + ignoreServiceDelays: Appliquer les retards + delaysNotShownInResults: "Vos trajets recherchés sont perturbés par des retards. + Ces retards ne sont pas pris en compte dans les temps de trajet ci-dessous." + delaysShownInResults: "Vos trajets recherchés ont été mis à jour avec les conditions en temps réel. + En temps normal, ce trajet prendrait {normalDuration} en empruntant les lignes: {routes}." + ignoreServiceDelays: Ignorer les retards + serviceUpdate: Information sur le service + SaveTripButton: + cantSaveText: Impossible d'enregistrer + cantSaveTooltip: Seuls les trajets en transports en commun sans location de véhicules et sans course en voiture peuvent être suivis. + saveTripText: Enregistrer + signInText: Connectez-vous pour enregistrer + signInTooltip: Veuillez vous connecter pour enregistrer ce trajet. + SimpleRealtimeAnnotation: + usingRealtimeInfo: Ce trajet utilise les informations en temps réel sur le trafic et les retards + TabbedItineraries: + optionNumber: "Option {optionNum, number}" + fareCost: "{hasMaxFare, select, + true {À partir de {minTotalFare}} + other {{minTotalFare}} + }" + TripTools: + # Note to translator: copyLink, linkCopied, print, reportIssue, + # and startOver are width-constrained. + copyLink: Copier le lien + # Text that replaces the copyLink button text after user clicks it. + linkCopied: Copié + print: Imprimer + reportIssue: Un problème ? # "Signaler un problème" does not fit. + reportEmailSubject: Signaler un problème avec OpenTripPlanner + reportEmailTemplate: " *** A L'ATTENTION DE L'UTILISATEUR *** + Vous pouvez communiquer votre problème en détail aux administrateurs de ce site, par courriel. + Veuillez ajouter toute remarque sur cet itinéraire dans la section 'Additional Comments' + ci-dessous, puis envoyez depuis votre logiciel de messagerie usuel." common: + # Standard navigation + navigation: + back: Retour + startOver: Recommencer accessModes: bike: Vélo bikeshare: Vélo en libre-service drive: Voiture micromobility: Trottinette électrique micromobilityRent: Trottinette électrique en libre-service - walk: Marche + walk: À pied + + itineraryDescriptions: + calories: "{calories, number} kcal" # SI unit + transfers: "{transfers, plural, =0 {} one {{transfers} correspondance} other {{transfers} correspondances}}" + otpTransitModes: tram: Tram @@ -27,3 +150,9 @@ common: cable_car: Tram tiré par câble gondola: Téléphérique funicular: Funiculaire + + time: + departureArrivalTimes: "{startTime, time, short}—{endTime, time, short}" + tripDurationFormat: "{hours, plural, + =0 {{minutes, number} mn} + other {{hours, number} h {minutes, number} mn}}" 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/admin/call-history-window.js b/lib/components/admin/call-history-window.js index 1f72e8a5a..4bbf897ce 100644 --- a/lib/components/admin/call-history-window.js +++ b/lib/components/admin/call-history-window.js @@ -2,7 +2,7 @@ import React from 'react' import { connect } from 'react-redux' import * as callTakerActions from '../../actions/call-taker' -import Icon from '../narrative/icon' +import Icon from '../util/icon' import CallRecord from './call-record' import DraggableWindow from './draggable-window' diff --git a/lib/components/admin/call-record.js b/lib/components/admin/call-record.js index e4d821616..3f37491cd 100644 --- a/lib/components/admin/call-record.js +++ b/lib/components/admin/call-record.js @@ -2,8 +2,8 @@ import humanizeDuration from 'humanize-duration' import moment from 'moment' import React, { Component } from 'react' -import Icon from '../narrative/icon' import {searchToQuery} from '../../util/call-taker' +import Icon from '../util/icon' import CallTimeCounter from './call-time-counter' import QueryRecord from './query-record' diff --git a/lib/components/admin/call-taker-controls.js b/lib/components/admin/call-taker-controls.js index 23ef687ad..3fbd38458 100644 --- a/lib/components/admin/call-taker-controls.js +++ b/lib/components/admin/call-taker-controls.js @@ -5,8 +5,8 @@ import * as apiActions from '../../actions/api' import * as callTakerActions from '../../actions/call-taker' import * as fieldTripActions from '../../actions/field-trip' import * as uiActions from '../../actions/ui' -import Icon from '../narrative/icon' import { isModuleEnabled, Modules } from '../../util/config' +import Icon from '../util/icon' import { CallHistoryButton, diff --git a/lib/components/admin/draggable-window.js b/lib/components/admin/draggable-window.js index 2d6aba12f..75532fd2f 100644 --- a/lib/components/admin/draggable-window.js +++ b/lib/components/admin/draggable-window.js @@ -1,7 +1,7 @@ import React, { Component } from 'react' import Draggable from 'react-draggable' -import Icon from '../narrative/icon' +import Icon from '../util/icon' const noop = () => {} diff --git a/lib/components/admin/field-trip-details.js b/lib/components/admin/field-trip-details.js index 2707fb64d..48c125435 100644 --- a/lib/components/admin/field-trip-details.js +++ b/lib/components/admin/field-trip-details.js @@ -6,7 +6,6 @@ import { connect } from 'react-redux' import styled from 'styled-components' import * as fieldTripActions from '../../actions/field-trip' -import Icon from '../narrative/icon' import { getActiveFieldTripRequest, getGroupSize, @@ -14,6 +13,7 @@ import { PAYMENT_FIELDS, TICKET_TYPES } from '../../util/call-taker' +import Icon from '../util/icon' import DraggableWindow from './draggable-window' import EditableSection from './editable-section' diff --git a/lib/components/admin/field-trip-itinerary-group-size.js b/lib/components/admin/field-trip-itinerary-group-size.js index 27e3015f7..40d7bfd18 100644 --- a/lib/components/admin/field-trip-itinerary-group-size.js +++ b/lib/components/admin/field-trip-itinerary-group-size.js @@ -1,7 +1,7 @@ import React from 'react' import { Badge } from 'react-bootstrap' -import Icon from '../narrative/icon' +import Icon from '../util/icon' export default function FieldTripGroupSize ({ itinerary }) { return itinerary.fieldTripGroupSize > 0 && ( diff --git a/lib/components/admin/field-trip-list.js b/lib/components/admin/field-trip-list.js index 2cabcd1cb..399351eaa 100644 --- a/lib/components/admin/field-trip-list.js +++ b/lib/components/admin/field-trip-list.js @@ -5,10 +5,10 @@ import { Badge, Button } from 'react-bootstrap' import { connect } from 'react-redux' import * as fieldTripActions from '../../actions/field-trip' -import Icon from '../narrative/icon' import Loading from '../narrative/loading' import {getVisibleRequests, TABS} from '../../util/call-taker' import {FETCH_STATUS} from '../../util/constants' +import Icon from '../util/icon' import FieldTripStatusIcon from './field-trip-status-icon' import {FieldTripRecordButton, WindowHeader} from './styled' diff --git a/lib/components/admin/field-trip-notes.js b/lib/components/admin/field-trip-notes.js index a190f5f5d..c0f2be4a4 100644 --- a/lib/components/admin/field-trip-notes.js +++ b/lib/components/admin/field-trip-notes.js @@ -2,7 +2,8 @@ import React, { Component } from 'react' import { Badge, Button as BsButton } from 'react-bootstrap' import styled from 'styled-components' -import Icon from '../narrative/icon' +import Icon from '../util/icon' + import { Button, Full, diff --git a/lib/components/admin/field-trip-status-icon.js b/lib/components/admin/field-trip-status-icon.js index e7ed9b0eb..901a73939 100644 --- a/lib/components/admin/field-trip-status-icon.js +++ b/lib/components/admin/field-trip-status-icon.js @@ -1,6 +1,6 @@ import React from 'react' -import Icon from '../narrative/icon' +import Icon from '../util/icon' const FieldTripStatusIcon = ({ ok }) => ( ok diff --git a/lib/components/admin/mailables-window.js b/lib/components/admin/mailables-window.js index 55f558c54..80f8112e6 100644 --- a/lib/components/admin/mailables-window.js +++ b/lib/components/admin/mailables-window.js @@ -3,8 +3,8 @@ import {Badge, Button} from 'react-bootstrap' import {connect} from 'react-redux' import * as callTakerActions from '../../actions/call-taker' -import Icon from '../narrative/icon' import {getModuleConfig, isModuleEnabled, Modules} from '../../util/config' +import Icon from '../util/icon' import {createLetter, LETTER_FIELDS} from '../../util/mailables' import { diff --git a/lib/components/admin/styled.js b/lib/components/admin/styled.js index 6c9ecdb5b..7ee01dbe4 100644 --- a/lib/components/admin/styled.js +++ b/lib/components/admin/styled.js @@ -1,7 +1,7 @@ import { Button as BsButton } from 'react-bootstrap' import styled, {css} from 'styled-components' -import Icon from '../narrative/icon' +import Icon from '../util/icon' import DefaultCounter from './call-time-counter' diff --git a/lib/components/app/app-menu.js b/lib/components/app/app-menu.js index ccbf42fa1..dd716b264 100644 --- a/lib/components/app/app-menu.js +++ b/lib/components/app/app-menu.js @@ -1,25 +1,35 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import qs from 'qs' +import React, { Component, Fragment } from 'react' import { connect } from 'react-redux' -import { DropdownButton, MenuItem } from 'react-bootstrap' +import { FormattedMessage, injectIntl, useIntl } from 'react-intl' +import { MenuItem } from 'react-bootstrap' import { withRouter } from 'react-router' +import PropTypes from 'prop-types' +import qs from 'qs' +import SlidingPane from 'react-sliding-pane' +import VelocityTransitionGroup from 'velocity-react/velocity-transition-group' -import Icon from '../narrative/icon' +import { isModuleEnabled, Modules } from '../../util/config' +import { MainPanelContent, setMainPanelContent } from '../../actions/ui' import * as callTakerActions from '../../actions/call-taker' import * as fieldTripActions from '../../actions/field-trip' -import { MainPanelContent, setMainPanelContent } from '../../actions/ui' -import { isModuleEnabled, Modules } from '../../util/config' - -// TODO: make menu items configurable via props/config +import Icon from '../util/icon' +/** + * Sidebar which appears to show user list of options and links + */ class AppMenu extends Component { static propTypes = { setMainPanelContent: PropTypes.func } + state = { + expandedSubmenus: {}, + isPaneOpen: false + } + _showRouteViewer = () => { this.props.setMainPanelContent(MainPanelContent.ROUTE_VIEWER) + this._togglePane() } _startOver = () => { @@ -35,54 +45,169 @@ class AppMenu extends Component { const params = qs.parse(search, { ignoreQueryPrefix: true }) const { sessionId } = params if (sessionId) { - startOverUrl += `?${qs.stringify({sessionId})}` + startOverUrl += `?${qs.stringify({ sessionId })}` } } window.location.href = startOverUrl } + _togglePane = () => { + const { isPaneOpen } = this.state + this.setState({ isPaneOpen: !isPaneOpen }) + } + + _toggleSubmenu = (id) => { + const { expandedSubmenus } = this.state + const currentlyOpen = expandedSubmenus[id] || false + this.setState({ expandedSubmenus: { [id]: !currentlyOpen } }) + } + + _addExtraMenuItems = (menuItems) => { + return ( + menuItems && + menuItems.map((menuItem) => { + const { + children, + href, + iconType, + iconUrl, + id, + label: configLabel, + subMenuDivider + } = menuItem + const { expandedSubmenus } = this.state + const { intl } = this.props + const isSubmenuExpanded = expandedSubmenus[id] + + const localizationId = `config.menuItems.${id}` + const localizedLabel = intl.formatMessage({id: localizationId}) + // Override the config label if a localized label exists + const label = localizedLabel === localizationId ? configLabel : localizedLabel + + if (children) { + return ( + + this._toggleSubmenu(id)} + > + + + + + + + {isSubmenuExpanded && ( +
+ {this._addExtraMenuItems(children)} +
+ )} +
+
+ ) + } + + return ( + + + + ) + }) + ) + } + render () { const { callTakerEnabled, + extraMenuItems, fieldTripEnabled, - languageConfig, + intl, mailablesEnabled, resetAndToggleCallHistory, resetAndToggleFieldTrips, toggleMailables } = this.props + const { isPaneOpen } = this.state return ( -
- )}> - - {languageConfig.routeViewer || 'Route Viewer'} - - {callTakerEnabled && - - Call History - + <> +
- Field Trip + className='app-menu-icon' + onClick={this._togglePane} + onKeyDown={this._togglePane} + role='button' + tabIndex={0} + > + + + +
+ +
    + {/* This item is duplicated by the view-switcher, but only shown on mobile + when the view switcher isn't shown (using css) */} + + + - } - {mailablesEnabled && - - Mailables + + + - } - - Start Over - - -
+ {callTakerEnabled && ( + + + + + )} + {fieldTripEnabled && ( + + + + + )} + {mailablesEnabled && ( + + + + + )} + {this._addExtraMenuItems(extraMenuItems)} + + + ) } } @@ -90,11 +215,11 @@ class AppMenu extends Component { // connect to the redux store const mapStateToProps = (state, ownProps) => { - const {language} = state.otp.config + const { extraMenuItems } = state.otp.config return { callTakerEnabled: isModuleEnabled(state, Modules.CALL_TAKER), + extraMenuItems, fieldTripEnabled: isModuleEnabled(state, Modules.FIELD_TRIP), - languageConfig: language, mailablesEnabled: isModuleEnabled(state, Modules.MAILABLES) } } @@ -106,4 +231,35 @@ const mapDispatchToProps = { toggleMailables: callTakerActions.toggleMailables } -export default withRouter(connect(mapStateToProps, mapDispatchToProps)(AppMenu)) +export default withRouter( + connect( + mapStateToProps, + mapDispatchToProps + )(injectIntl(AppMenu)) +) + +/** + * Renders a label and icon either from url or font awesome type + */ +const IconAndLabel = ({ iconType, iconUrl, label }) => { + const intl = useIntl() + + return ( + + {iconUrl ? ( + {intl.formatMessage( + ) : ( + + )} + {label} + + ) +} 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/default-main-panel.js b/lib/components/app/default-main-panel.js index 8784fc8d5..d40b0af34 100644 --- a/lib/components/app/default-main-panel.js +++ b/lib/components/app/default-main-panel.js @@ -26,7 +26,6 @@ class DefaultMainPanel extends Component {
{ 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/form/batch-settings.js b/lib/components/form/batch-settings.js index 6e22168cc..f4150a576 100644 --- a/lib/components/form/batch-settings.js +++ b/lib/components/form/batch-settings.js @@ -5,7 +5,7 @@ import styled from 'styled-components' import * as apiActions from '../../actions/api' import * as formActions from '../../actions/form' -import Icon from '../narrative/icon' +import Icon from '../util/icon' import { hasValidLocation, getActiveSearch, getShowUserSettings } from '../../util/state' import BatchPreferences from './batch-preferences' diff --git a/lib/components/form/connect-location-field.js b/lib/components/form/connect-location-field.js index ca2c562d1..02b0e2caa 100644 --- a/lib/components/form/connect-location-field.js +++ b/lib/components/form/connect-location-field.js @@ -26,6 +26,7 @@ export default function connectLocationField (StyledLocationField, options = {}) const stateToProps = { currentPosition, geocoderConfig: config.geocoder, + layerColorMap: config.geocoder?.resultsColors || {}, nearbyStops, sessionSearches, showUserSettings: getShowUserSettings(state), diff --git a/lib/components/form/default-search-form.js b/lib/components/form/default-search-form.js index 960fbfe6f..cee59d088 100644 --- a/lib/components/form/default-search-form.js +++ b/lib/components/form/default-search-form.js @@ -29,7 +29,7 @@ export default class DefaultSearchForm extends Component { return (
-
+
{ 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-results-screen.js b/lib/components/mobile/batch-results-screen.js index 273c53bbb..d9be6ce0b 100644 --- a/lib/components/mobile/batch-results-screen.js +++ b/lib/components/mobile/batch-results-screen.js @@ -6,8 +6,8 @@ import styled, { css } from 'styled-components' import * as uiActions from '../../actions/ui' import Map from '../map/map' -import Icon from '../narrative/icon' import NarrativeItineraries from '../narrative/narrative-itineraries' +import Icon from '../util/icon' import { getActiveItineraries, getActiveSearch, 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 cdd845530..844c50a7d 100644 --- a/lib/components/mobile/navigation-bar.js +++ b/lib/components/mobile/navigation-bar.js @@ -6,9 +6,9 @@ import { connect } from 'react-redux' import { setMobileScreen } from '../../actions/ui' import AppMenu from '../app/app-menu' import NavLoginButtonAuth0 from '../../components/user/nav-login-button-auth0' -import Icon from '../narrative/icon' import { accountLinks, getAuth0Config } from '../../util/auth' import { ComponentContext } from '../../util/contexts' +import Icon from '../util/icon' class MobileNavigationBar extends Component { static propTypes = { @@ -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 ( - +
- ) - } else { - return ( - - ) - } -} - -function FormattedTime ({endTime, startTime, timeFormat}) { - return ( - - ) -} - const ITINERARY_ATTRIBUTES = [ { alias: 'best', @@ -98,26 +86,15 @@ const ITINERARY_ATTRIBUTES = [ render: (itinerary, options) => { if (options.isSelected) { if (options.selection === 'ARRIVALTIME') { - return ( - - ) + return } else { - return ( - - ) + return } } return ( - ) } @@ -144,18 +121,12 @@ const ITINERARY_ATTRIBUTES = [ const {LegIcon} = options return ( // FIXME: For CAR mode, walk time considers driving time. - - {' '} -
+ <> + + -
-
+ + ) } } @@ -200,8 +171,7 @@ class DefaultItinerary extends NarrativeItinerary { LegIcon, setActiveLeg, showRealtimeAnnotation, - timeFormat, - use24HourFormat + timeFormat } = this.props const timeOptions = { format: timeFormat, @@ -214,11 +184,6 @@ class DefaultItinerary extends NarrativeItinerary { onMouseEnter={this._onMouseEnter} onMouseLeave={this._onMouseLeave} role='presentation' - // FIXME: Move style to css - style={{ - backgroundColor: expanded ? 'white' : undefined, - borderLeft: active && !expanded ? '4px teal solid' : undefined - }} > {(active && expanded) && @@ -281,8 +245,7 @@ const mapStateToProps = (state, ownProps) => { // The configured (ambient) currency is needed for rendering the cost // of itineraries whether they include a fare or not, in which case // we show $0.00 or its equivalent in the configured currency and selected locale. - currency: state.otp.config.localization?.currency || 'USD', - use24HourFormat: state.user.loggedInUser?.use24HourFormat ?? false + currency: state.otp.config.localization?.currency || 'USD' } } diff --git a/lib/components/narrative/default/itinerary.css b/lib/components/narrative/default/itinerary.css index b4203ba0f..63bdb7bec 100644 --- a/lib/components/narrative/default/itinerary.css +++ b/lib/components/narrative/default/itinerary.css @@ -5,7 +5,7 @@ } /* If child component is focused, highlight itinerary option */ -.otp .option.default-itin:focus-within { +.otp .option.default-itin:focus-within:not(.expanded) { background-color: var(--hover-color); } @@ -19,6 +19,11 @@ border-top: 1px solid grey; } +/* Show side border if active and not expanded */ +.otp .option.default-itin.active:not(.expanded) { + border-left: 4px teal solid; +} + /* FIXME: don't highlight if not active */ .otp .option.default-itin:hover:not(.active) { background-color: var(--hover-color); diff --git a/lib/components/narrative/default/transit-leg.js b/lib/components/narrative/default/transit-leg.js index 2e2f3023d..c70107c5e 100644 --- a/lib/components/narrative/default/transit-leg.js +++ b/lib/components/narrative/default/transit-leg.js @@ -2,7 +2,7 @@ import coreUtils from '@opentripplanner/core-utils' import PropTypes from 'prop-types' import React, { Component } from 'react' -import Icon from '../icon' +import Icon from '../../util/icon' import ViewTripButton from '../../viewers/view-trip-button' import ViewStopButton from '../../viewers/view-stop-button' diff --git a/lib/components/narrative/icon.js b/lib/components/narrative/icon.js deleted file mode 100644 index cca4799b3..000000000 --- a/lib/components/narrative/icon.js +++ /dev/null @@ -1,16 +0,0 @@ -import React, { Component } from 'react' -import FontAwesome from 'react-fontawesome' - -export default class Icon extends Component { - static propTypes = { - // type: PropTypes.string.required - } - render () { - return ( - ) - } -} diff --git a/lib/components/narrative/itinerary-carousel.js b/lib/components/narrative/itinerary-carousel.js index f6b125edb..02fcee418 100644 --- a/lib/components/narrative/itinerary-carousel.js +++ b/lib/components/narrative/itinerary-carousel.js @@ -8,8 +8,8 @@ import SwipeableViews from 'react-swipeable-views' import { setActiveItinerary, setActiveLeg, setActiveStep } from '../../actions/narrative' import { ComponentContext } from '../../util/contexts' import { getActiveItineraries, getActiveSearch } from '../../util/state' +import Icon from '../util/icon' -import Icon from './icon' import Loading from './loading' class ItineraryCarousel extends Component { diff --git a/lib/components/narrative/line-itin/itin-summary.js b/lib/components/narrative/line-itin/itin-summary.js index b457e95ac..96f130dab 100644 --- a/lib/components/narrative/line-itin/itin-summary.js +++ b/lib/components/narrative/line-itin/itin-summary.js @@ -2,8 +2,12 @@ import coreUtils from '@opentripplanner/core-utils' import PropTypes from 'prop-types' import React, { Component } from 'react' import styled from 'styled-components' +import { connect } from 'react-redux' +import { FormattedNumber, FormattedMessage } from 'react-intl' import { ComponentContext } from '../../../util/contexts' +import FormattedDuration from '../../util/formatted-duration' +import FormattedTimeRange from '../../util/formatted-time-range' // TODO: make this a prop const defaultRouteColor = '#008' @@ -71,7 +75,7 @@ const ShortName = styled.div` width: 30px; ` -export default class ItinerarySummary extends Component { +export class ItinerarySummary extends Component { static propTypes = { itinerary: PropTypes.object } @@ -83,11 +87,10 @@ export default class ItinerarySummary extends Component { } render () { - const { itinerary, timeOptions } = this.props + const { currency, itinerary } = this.props const { LegIcon } = this.context const { - centsToString, maxTNCFare, minTNCFare, transitFare @@ -97,34 +100,59 @@ export default class ItinerarySummary extends Component { const maxTotalFare = maxTNCFare * 100 + transitFare const { caloriesBurned } = coreUtils.itinerary.calculatePhysicalActivity(itinerary) - return (
{/* Travel time in hrs/mins */} -
{coreUtils.time.formatDuration(itinerary.duration)}
+
+ +
{/* Duration as time range */} - {coreUtils.time.formatTime(itinerary.startTime, timeOptions)} - {coreUtils.time.formatTime(itinerary.endTime, timeOptions)} + {/* Fare / Calories */} {minTotalFare > 0 && - {centsToString(minTotalFare)} - {minTotalFare !== maxTotalFare && - {centsToString(maxTotalFare)}} + + ), + minTotalFare: ( + + ), + useMaxFare: minTotalFare !== maxTotalFare ? 'true' : 'false' + }} + /> } - {Math.round(caloriesBurned)} Cals + {/* Number of transfers, if applicable */} - {itinerary.transfers > 0 && ( - - {itinerary.transfers} transfer{itinerary.transfers > 1 ? 's' : ''} - - )} + + +
@@ -179,3 +207,10 @@ function getRouteNameForBadge (leg) { function getRouteColorForBadge (leg) { return leg.routeColor ? '#' + leg.routeColor : defaultRouteColor } + +const mapStateToProps = (state, ownProps) => { + return { + currency: state.otp.config.localization?.currency || 'USD' + } +} +export default connect(mapStateToProps)(ItinerarySummary) diff --git a/lib/components/narrative/loading.js b/lib/components/narrative/loading.js index adc668564..efc749562 100644 --- a/lib/components/narrative/loading.js +++ b/lib/components/narrative/loading.js @@ -1,6 +1,6 @@ import React, { Component } from 'react' -import Icon from './icon' +import Icon from '../util/icon' export default class Loading extends Component { render () { diff --git a/lib/components/narrative/mode-icon.js b/lib/components/narrative/mode-icon.js index eacb1436e..fd88b3054 100644 --- a/lib/components/narrative/mode-icon.js +++ b/lib/components/narrative/mode-icon.js @@ -1,7 +1,7 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' -import Icon from './icon' +import Icon from '../util/icon' export default class ModeIcon extends Component { static propTypes = { diff --git a/lib/components/narrative/narrative-itineraries-errors.js b/lib/components/narrative/narrative-itineraries-errors.js index 37ca1a098..fa94c77af 100644 --- a/lib/components/narrative/narrative-itineraries-errors.js +++ b/lib/components/narrative/narrative-itineraries-errors.js @@ -1,7 +1,7 @@ import { getCompanyIcon } from '@opentripplanner/icons/lib/companies' import styled from 'styled-components' -import Icon from '../narrative/icon' +import Icon from '../util/icon' import { getErrorMessage } from '../../util/state' const IssueContainer = styled.div` diff --git a/lib/components/narrative/narrative-itineraries-header.js b/lib/components/narrative/narrative-itineraries-header.js index 54300bc81..e462de55d 100644 --- a/lib/components/narrative/narrative-itineraries-header.js +++ b/lib/components/narrative/narrative-itineraries-header.js @@ -1,6 +1,7 @@ import styled from 'styled-components' +import { FormattedMessage, useIntl } from 'react-intl' -import Icon from '../narrative/icon' +import Icon from '../util/icon' import PlanFirstLastButtons from './plan-first-last-buttons' import SaveTripButton from './save-trip-button' @@ -26,22 +27,7 @@ export default function NarrativeItinerariesHeader ({ showingErrors, sort }) { - let resultText, titleText - if (pending) { - resultText = 'Finding your options...' - titleText = 'Finding your options...' - } else { - const itineraryPlural = itineraries.length === 1 - ? 'itinerary' - : 'itineraries' - const issuePlural = errors.length === 1 - ? 'issue' - : 'issues' - resultText = `${itineraries.length} ${itineraryPlural} found.` - titleText = errors.length > 0 - ? `${itineraries.length} ${itineraryPlural} (and ${errors.length} ${issuePlural}) found` - : resultText - } + const intl = useIntl() return (
- View all options + {itineraryIsExpanded && ( // marginLeft: auto is a way of making something "float right" @@ -72,15 +58,33 @@ export default function NarrativeItinerariesHeader ({ : <>
- {resultText} + {errors.length > 0 && ( - {errors.length} issues + + + )}
@@ -97,12 +101,12 @@ export default function NarrativeItinerariesHeader ({ onChange={onSortChange} value={sort.type} > - - - - - - + + + + + +
diff --git a/lib/components/narrative/narrative.css b/lib/components/narrative/narrative.css index fd0d6534d..db5f6e664 100644 --- a/lib/components/narrative/narrative.css +++ b/lib/components/narrative/narrative.css @@ -198,6 +198,10 @@ color: #685c5c; } +.otp .tabbed-itineraries .tab-button .details > span { + display: block; +} + .otp .tabbed-itineraries .tab-button:hover .title { border-bottom: 3px solid #ddd; } diff --git a/lib/components/narrative/plan-first-last-buttons.js b/lib/components/narrative/plan-first-last-buttons.js index fae5e4bf9..9209696e1 100644 --- a/lib/components/narrative/plan-first-last-buttons.js +++ b/lib/components/narrative/plan-first-last-buttons.js @@ -1,5 +1,6 @@ import React from 'react' import {Button} from 'react-bootstrap' +import { FormattedMessage } from 'react-intl' import {connect} from 'react-redux' import * as planActions from '../../actions/plan' @@ -15,16 +16,16 @@ function PlanFirstLastButtons (props) { return ( ) diff --git a/lib/components/narrative/realtime-annotation.js b/lib/components/narrative/realtime-annotation.js index 325fe8167..a8662cc33 100644 --- a/lib/components/narrative/realtime-annotation.js +++ b/lib/components/narrative/realtime-annotation.js @@ -1,7 +1,10 @@ -import coreUtils from '@opentripplanner/core-utils' import PropTypes from 'prop-types' import React, { Component } from 'react' import { Button, OverlayTrigger, Popover } from 'react-bootstrap' +import { FormattedList, FormattedMessage } from 'react-intl' + +import FormattedDuration from '../util/formatted-duration' +import Icon from '../util/icon' export default class RealtimeAnnotation extends Component { static propTypes = { @@ -25,28 +28,30 @@ export default class RealtimeAnnotation extends Component { const innerContent =

- Service update + +

-

+

{useRealtime - ? - Your trip results have been adjusted based on real-time - information. Under normal conditions, this trip would take{' '} - {coreUtils.time.formatDuration(realtimeEffects.normalDuration)} - using the following routes:{' '} - {filteredRoutes - .map((route, idx) => ( - - {route} - {filteredRoutes.length - 1 > idx && ', '} - - )) - }. - - : - Your trip results are currently being affected by service delays. - These delays do not factor into travel times shown below. - + ? ( + + + + ), + routes: ( + {route})} + /> + ) + }} + /> + ) + : }

@@ -55,7 +60,10 @@ export default class RealtimeAnnotation extends Component { className='toggle-realtime' onClick={toggleRealtime} > - {useRealtime ? `Ignore` : `Apply`} service delays + {useRealtime + ? + : + }
diff --git a/lib/components/narrative/save-trip-button.js b/lib/components/narrative/save-trip-button.js index d5cabe2e7..5fe5f84a5 100644 --- a/lib/components/narrative/save-trip-button.js +++ b/lib/components/narrative/save-trip-button.js @@ -1,9 +1,11 @@ import React from 'react' import { OverlayTrigger, Tooltip } from 'react-bootstrap' +import { FormattedMessage, useIntl } from 'react-intl' import { connect } from 'react-redux' import { LinkContainerWithQuery } from '../form/connected-links' import { CREATE_TRIP_PATH } from '../../util/constants' +import Icon from '../util/icon' import { itineraryCanBeMonitored } from '../../util/itinerary' import { getActiveItinerary } from '../../util/state' @@ -16,6 +18,7 @@ const SaveTripButton = ({ loggedInUser, persistence }) => { + const intl = useIntl() // We are dealing with the following states: // 1. Persistence disabled => just return null // 2. User is not logged in => render something like: "Please sign in to save trip". @@ -24,23 +27,23 @@ const SaveTripButton = ({ let buttonDisabled let buttonText let tooltipText - let icon + let iconType if (!persistence || !persistence.enabled) { return null } else if (!loggedInUser) { buttonDisabled = true - buttonText = 'Sign in to save trip' - icon = 'fa fa-lock' - tooltipText = 'Please sign in to save trip.' + buttonText = + iconType = 'lock' + tooltipText = intl.formatMessage({id: 'components.SaveTripButton.signInTooltip'}) } else if (!itineraryCanBeMonitored(itinerary)) { buttonDisabled = true - buttonText = 'Cannot save' - icon = 'fa fa-ban' - tooltipText = 'Only itineraries that include transit and no rentals or ride hailing can be monitored.' + buttonText = + iconType = 'ban' + tooltipText = intl.formatMessage({id: 'components.SaveTripButton.cantSaveTooltip'}) } else { - buttonText = 'Save trip' - icon = 'fa fa-plus-circle' + buttonText = + iconType = 'plus-circle' } const button = ( ) // Show tooltip with help text if button is disabled. if (buttonDisabled) { return ( {tooltipText}} + overlay={( + + {/* Must get text using intl.formatMessage here because the rendering + of OverlayTrigger seems to occur outside of the IntlProvider context. */} + {tooltipText} + + )} placement='top' > -
+ {/* An active element around the disabled button is necessary + for the OverlayTrigger to render. */} +
{button}
diff --git a/lib/components/narrative/simple-realtime-annotation.js b/lib/components/narrative/simple-realtime-annotation.js index 07e1a4e7b..254714278 100644 --- a/lib/components/narrative/simple-realtime-annotation.js +++ b/lib/components/narrative/simple-realtime-annotation.js @@ -1,9 +1,13 @@ -import React, { Component } from 'react' +import React from 'react' +import { FormattedMessage } from 'react-intl' -export default class SimpleRealtimeAnnotation extends Component { - render () { - return
- This trip uses real-time traffic and delay information -
- } -} +import Icon from '../util/icon' + +const SimpleRealtimeAnnotation = () => ( +
+ + +
+) + +export default SimpleRealtimeAnnotation diff --git a/lib/components/narrative/tabbed-itineraries.js b/lib/components/narrative/tabbed-itineraries.js index 28950c273..1352e542e 100644 --- a/lib/components/narrative/tabbed-itineraries.js +++ b/lib/components/narrative/tabbed-itineraries.js @@ -2,14 +2,25 @@ import coreUtils from '@opentripplanner/core-utils' import PropTypes from 'prop-types' import React, { Component } from 'react' import { Button } from 'react-bootstrap' +import { FormattedMessage, FormattedNumber } from 'react-intl' import { connect } from 'react-redux' +import styled from 'styled-components' import * as narrativeActions from '../../actions/narrative' import { ComponentContext } from '../../util/contexts' +import { getTimeFormat } from '../../util/i18n' import { getActiveSearch, getRealtimeEffects } from '../../util/state' +import FormattedDuration from '../util/formatted-duration' +import FormattedTimeRange from '../util/formatted-time-range' -const { calculateFares, calculatePhysicalActivity, getTimeZoneOffset } = coreUtils.itinerary -const { formatDuration, formatTime, getTimeFormat } = coreUtils.time +const { calculateFares, calculatePhysicalActivity } = coreUtils.itinerary + +const Bullet = styled.span` + ::before { + content: "•"; + margin: 0 0.25em; + } +` class TabbedItineraries extends Component { static propTypes = { @@ -33,6 +44,7 @@ class TabbedItineraries extends Component { render () { const { activeItinerary, + currency, itineraries, realtimeEffects, setActiveItinerary, @@ -57,11 +69,12 @@ class TabbedItineraries extends Component { {itineraries.map((itinerary, index) => { return ( ) })} @@ -105,22 +118,16 @@ class TabButton extends Component { } render () { - const {index, isActive, itinerary, timeFormat} = this.props - const timeOptions = { - format: timeFormat, - offset: getTimeZoneOffset(itinerary) - } + const {currency, index, isActive, itinerary} = this.props const classNames = ['tab-button', 'clear-button-formatting'] const { caloriesBurned } = calculatePhysicalActivity(itinerary) const { - centsToString, maxTNCFare, minTNCFare, transitFare } = calculateFares(itinerary) // TODO: support non-USD const minTotalFare = minTNCFare * 100 + transitFare - const plus = maxTNCFare && maxTNCFare > minTNCFare ? '+' : '' if (isActive) classNames.push('selected') return ( ) } @@ -163,18 +196,21 @@ class TabButton extends Component { const mapStateToProps = (state, ownProps) => { const activeSearch = getActiveSearch(state) + const currency = state.otp.config.localization?.currency || 'USD' const pending = activeSearch ? Boolean(activeSearch.pending) : false const realtimeEffects = getRealtimeEffects(state) const useRealtime = state.otp.useRealtime + return { - // swap out realtime itineraries with non-realtime depending on boolean activeItinerary: activeSearch && activeSearch.activeItinerary, activeLeg: activeSearch && activeSearch.activeLeg, activeStep: activeSearch && activeSearch.activeStep, companies: state.otp.currentQuery.companies, + currency, pending, + // swap out realtime itineraries with non-realtime depending on boolean realtimeEffects, - timeFormat: getTimeFormat(state.otp.config), + timeFormat: getTimeFormat(state), tnc: state.otp.tnc, useRealtime } diff --git a/lib/components/narrative/trip-tools.js b/lib/components/narrative/trip-tools.js index aa5f908a9..848efa674 100644 --- a/lib/components/narrative/trip-tools.js +++ b/lib/components/narrative/trip-tools.js @@ -1,9 +1,12 @@ +import bowser from 'bowser' +import copyToClipboard from 'copy-to-clipboard' import React, {Component} from 'react' import { connect } from 'react-redux' import { Button } from 'react-bootstrap' // import { DropdownButton, MenuItem } from 'react-bootstrap' -import copyToClipboard from 'copy-to-clipboard' -import bowser from 'bowser' +import { FormattedMessage, injectIntl } from 'react-intl' + +import Icon from '../util/icon' class TripTools extends Component { static defaultProps = { @@ -32,7 +35,14 @@ class TripTools extends Component { if (reactRouterConfig && reactRouterConfig.basename) { startOverUrl += reactRouterConfig.basename } - buttonComponents.push() + buttonComponents.push( + // FIXME: The Spanish string does not fit in button width. + } + url={startOverUrl} + /> + ) break } }) @@ -89,6 +99,7 @@ class CopyUrlButton extends Component { if (parts.length === 2) { url = `${parts[0]}#/start/x/x/x/${routerId}${parts[1]}` } else { + // Console logs are not internationalized. console.warn('URL not formatted as expected, copied URL will not contain session routerId.', routerId) } } @@ -105,8 +116,18 @@ class CopyUrlButton extends Component { onClick={this._onClick} > {this.state.showCopied - ? Copied - : Copy Link + ? ( + + + + + ) + : ( + + + + + ) }
@@ -130,7 +151,8 @@ class PrintButton extends Component { className='tool-button' onClick={this._onClick} > - Print + +
) @@ -139,20 +161,14 @@ class PrintButton extends Component { // Report Issue Button Component -class ReportIssueButton extends Component { - static defaultProps = { - subject: 'Reporting an Issue with OpenTripPlanner' - } - +class ReportIssueButtonBase extends Component { _onClick = () => { - const { mailto, subject } = this.props - + const { intl, mailto, subject: configuredSubject } = this.props + const subject = configuredSubject || intl.formatMessage({id: 'components.TripTools.reportEmailSubject'}) const bodyLines = [ - ' *** INSTRUCTIONS TO USER ***', - 'This feature allows you to email a report to site administrators for review.', - `Please add any additional feedback for this trip under the 'Additional Comments'`, - 'section below and send using your regular email program.', + intl.formatMessage({id: 'components.TripTools.reportEmailTemplate'}), '', + // Search data section is for support and is not translated. 'SEARCH DATA:', 'Address: ' + window.location.href, 'Browser: ' + bowser.name + ' ' + bowser.version, @@ -171,12 +187,18 @@ class ReportIssueButton extends Component { className='tool-button' onClick={this._onClick} > - Report Issue + + {/* FIXME: Depending on translation, Spanish and French strings may not fit in button width. */} + ) } } +// The ReportIssueButton component above, with an intl prop +// for retrieving messages shown outside of React rendering. +const ReportIssueButton = injectIntl(ReportIssueButtonBase) + // Link to URL Button class LinkButton extends Component { @@ -192,7 +214,7 @@ class LinkButton extends Component { className='tool-button' onClick={this._onClick} > - {icon && } + {icon && } {text}
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/user/monitored-trip/trip-notifications-pane.js b/lib/components/user/monitored-trip/trip-notifications-pane.js index 302e928d4..e174f7478 100644 --- a/lib/components/user/monitored-trip/trip-notifications-pane.js +++ b/lib/components/user/monitored-trip/trip-notifications-pane.js @@ -3,7 +3,7 @@ import React, { Component } from 'react' import { Alert, FormControl, Glyphicon } from 'react-bootstrap' import styled from 'styled-components' -import Icon from '../../narrative/icon' +import Icon from '../../util/icon' const notificationChannelLabels = { email: 'email', diff --git a/lib/components/user/monitored-trip/trip-summary.js b/lib/components/user/monitored-trip/trip-summary.js index 816d58352..a4ec3c8be 100644 --- a/lib/components/user/monitored-trip/trip-summary.js +++ b/lib/components/user/monitored-trip/trip-summary.js @@ -12,7 +12,7 @@ const TripSummary = ({ monitoredTrip }) => { // TODO: use the modern itinerary summary built for trip comparison. return (
Itinerary{' '} diff --git a/lib/components/user/places/favorite-place-row.js b/lib/components/user/places/favorite-place-row.js index dea65aebe..0b1f7e533 100644 --- a/lib/components/user/places/favorite-place-row.js +++ b/lib/components/user/places/favorite-place-row.js @@ -4,7 +4,7 @@ import { Button } from 'react-bootstrap' import styled, { css } from 'styled-components' import { LinkContainerWithQuery } from '../../form/connected-links' -import Icon from '../../narrative/icon' +import Icon from '../../util/icon' const FIELD_HEIGHT_PX = '60px' diff --git a/lib/components/user/places/place-editor.js b/lib/components/user/places/place-editor.js index 562fd3907..7a78fbff3 100644 --- a/lib/components/user/places/place-editor.js +++ b/lib/components/user/places/place-editor.js @@ -10,7 +10,7 @@ import { } from 'react-bootstrap' import styled from 'styled-components' -import Icon from '../../narrative/icon' +import Icon from '../../util/icon' import { getErrorStates } from '../../../util/ui' import { CUSTOM_PLACE_TYPES, isHomeOrWork } from '../../../util/user' diff --git a/lib/components/user/styled.js b/lib/components/user/styled.js index 35019af1c..fea421734 100644 --- a/lib/components/user/styled.js +++ b/lib/components/user/styled.js @@ -1,7 +1,7 @@ import { Panel } from 'react-bootstrap' import styled from 'styled-components' -import Icon from '../narrative/icon' +import Icon from '../util/icon' export const PageHeading = styled.h2` margin: 10px 0px 45px 0px; diff --git a/lib/components/util/formatted-duration.js b/lib/components/util/formatted-duration.js new file mode 100644 index 000000000..3ca1a80c4 --- /dev/null +++ b/lib/components/util/formatted-duration.js @@ -0,0 +1,17 @@ +import moment from 'moment-timezone' +import { FormattedMessage } from 'react-intl' + +/** + * Formats the given duration according to the selected locale. + */ +export default function FormattedDuration ({duration}) { + const dur = moment.duration(duration, 'seconds') + const hours = dur.hours() + const minutes = dur.minutes() + return ( + + ) +} diff --git a/lib/components/util/formatted-time-range.js b/lib/components/util/formatted-time-range.js new file mode 100644 index 000000000..f1e9a2c8b --- /dev/null +++ b/lib/components/util/formatted-time-range.js @@ -0,0 +1,18 @@ +import moment from 'moment-timezone' +import { FormattedMessage } from 'react-intl' + +/** + * Renders a time range e.g. 3:45pm-4:15pm according to the + * react-intl default time format for the ambient locale. + */ +export default function FormattedTimeRange ({ endTime, startTime }) { + return ( + + ) +} diff --git a/lib/components/util/icon.js b/lib/components/util/icon.js new file mode 100644 index 000000000..0662b2c94 --- /dev/null +++ b/lib/components/util/icon.js @@ -0,0 +1,41 @@ +import PropTypes from 'prop-types' +import React from 'react' +import FontAwesome from 'react-fontawesome' +import styled from 'styled-components' + +/** + * A Font Awesome icon followed by a with a pseudo-element equivalent to a single space. + */ +const FontAwesomeWithSpace = styled(FontAwesome)` + &::after { + content: ""; + margin: 0 0.125em; + } +` + +/** + * Wrapper for the FontAwesome component that, if specified in the withSpace prop, + * supports CSS spacing after the specified icon type, to replace the {' '} workaround, + * and that should work for both left-to-right and right-to-left layouts. + * Other props from FontAwesome are passed to that component. + */ +const Icon = ({ fixedWidth = true, type, withSpace, ...props }) => { + const FontComponent = withSpace + ? FontAwesomeWithSpace + : FontAwesome + return ( + + ) +} + +Icon.propTypes = { + fixedWidth: PropTypes.bool, + type: PropTypes.string.isRequired, + withSpace: PropTypes.bool +} + +export default Icon 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/live-stop-times.js b/lib/components/viewers/live-stop-times.js index 76f0cb050..403f627ed 100644 --- a/lib/components/viewers/live-stop-times.js +++ b/lib/components/viewers/live-stop-times.js @@ -3,7 +3,7 @@ import 'moment-timezone' import coreUtils from '@opentripplanner/core-utils' import React, { Component } from 'react' -import Icon from '../narrative/icon' +import Icon from '../util/icon' import { getRouteIdForPattern, getStopTimesByPattern, diff --git a/lib/components/viewers/pattern-row.js b/lib/components/viewers/pattern-row.js index 0286154d6..5f5d7ecc1 100644 --- a/lib/components/viewers/pattern-row.js +++ b/lib/components/viewers/pattern-row.js @@ -1,7 +1,7 @@ import React, { Component } from 'react' import { VelocityTransitionGroup } from 'velocity-react' -import Icon from '../narrative/icon' +import Icon from '../util/icon' import { stopTimeComparator } from '../../util/viewer' import RealtimeStatusLabel from './realtime-status-label' diff --git a/lib/components/viewers/route-details.js b/lib/components/viewers/route-details.js new file mode 100644 index 000000000..35216bd21 --- /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 95ad0cbd6..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 '../narrative/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-time-cell.js b/lib/components/viewers/stop-time-cell.js index ac1dae23d..be732823d 100644 --- a/lib/components/viewers/stop-time-cell.js +++ b/lib/components/viewers/stop-time-cell.js @@ -4,7 +4,7 @@ import coreUtils from '@opentripplanner/core-utils' import PropTypes from 'prop-types' import React from 'react' -import Icon from '../narrative/icon' +import Icon from '../util/icon' import { getSecondsUntilDeparture } from '../../util/viewer' const { formatDuration, formatSecondsAfterMidnight } = coreUtils.time diff --git a/lib/components/viewers/stop-viewer.js b/lib/components/viewers/stop-viewer.js index fa540f208..008d13c43 100644 --- a/lib/components/viewers/stop-viewer.js +++ b/lib/components/viewers/stop-viewer.js @@ -11,8 +11,9 @@ import styled from 'styled-components' import * as apiActions from '../../actions/api' import * as mapActions from '../../actions/map' import * as uiActions from '../../actions/ui' -import Icon from '../narrative/icon' +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/trip-viewer.js b/lib/components/viewers/trip-viewer.js index 3835b99da..7a6543aeb 100644 --- a/lib/components/viewers/trip-viewer.js +++ b/lib/components/viewers/trip-viewer.js @@ -6,7 +6,7 @@ import React, { Component } from 'react' import { Button, Label } from 'react-bootstrap' import { connect } from 'react-redux' -import Icon from '../narrative/icon' +import Icon from '../util/icon' import { setViewedTrip } from '../../actions/ui' import { findTrip } from '../../actions/api' import { setLocation } from '../../actions/map' 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/index.css b/lib/index.css index d87b78e7a..86d599b91 100644 --- a/lib/index.css +++ b/lib/index.css @@ -16,14 +16,155 @@ @import url(lib/bike-rental.css); +@import url(react-sliding-pane/dist/react-sliding-pane.css); + /* Hide IE/Edge clear button in text input fields. */ -input[type=text]::-ms-clear { - display: none; +input[type="text"]::-ms-clear { + display: none; +} + +/* New app menu */ +.app-menu-icon { + width: 21px; + height: 15px; + display: flex; + flex-direction: column; + justify-content: space-between; + cursor: pointer; + position: absolute; + z-index: 10; + transition: all 1s ease; + top: 16px; +} + +@media only screen and (max-width: 768px) { + .app-menu-icon { + margin-left: 15px; + } +} + +.menu-top-line, +.menu-middle-line, +.menu-bottom-line, +.menu-left-x, +.menu-right-x { + border-bottom: 3px solid #ffffff; + transition: all 0.5s ease; +} + +.menu-left-x { + transform: rotate(45deg); + top: 7px; + position: relative; +} + +.menu-right-x { + transform: rotate(-45deg); + bottom: 5px; + position: relative; +} + +.slide-pane { + transition: all 0.2s ease-in-out; +} + +.slide-pane_from_left { + margin: 52px auto 0 0; +} + +.slide-pane__header, +.slide-pane__close { + display: none; +} + +.slide-pane__content { + padding: 6px 0; +} + +.app-menu { + margin: 0; + padding: 0; +} + +.app-menu li { + list-style-type: none; + margin: 1rem 2rem; + cursor: pointer; +} + +.app-menu li img { + width: 25px; + margin-right: 2rem; +} + +.app-menu li a { + color: inherit; + font-size: 20px; + text-decoration: none; + outline: none; +} + +/* increase icon size */ +.app-menu li a .fa, +.expand-submenu-button span .fa { + font-size: 2rem; +} + +.app-menu span:not(.expand-menu-chevron) { + margin-right: 2rem; +} + +.menu-item:hover *, +.app-menu li a span *:hover { + color: #4c97f5; +} + +.expand-menu-chevron { + margin-right: -2rem; +} + +.dropdown-header { + font-size: inherit; + line-height: normal; + color: inherit; + white-space: nowrap; +} + +.expand-submenu-button a { + display: flex; + width: 100%; + justify-content: space-between; + background: transparent; + border: none; + outline: none; +} + +.sub-menu-container { + border-top: 1px solid #cccccc; +} + +.sub-menu-container li a { + margin-left: 2rem; +} + +.sub-menu-container li a span { + color: inherit; +} + +.app-menu-divider::after { + content: ""; + display: block; + height: 1px; + background: #ccc; + margin: 1rem; + width: 200%; /* ensures the line extends to the edge of the pane */ } /* Buttons */ -button.header, button.step, .clear-button-formatting { +button.header, +button.step, +.clear-button-formatting { background: transparent; color: inherit; border: 0; @@ -46,7 +187,8 @@ button.header, button.step, .clear-button-formatting { background: none; } -button.header, button.step { +button.header, +button.step { width: 100%; } @@ -55,3 +197,9 @@ button.header, button.step { overflow: hidden; text-overflow: ellipsis; } + +.map-container .leaflet-top { + /* we need to override this z-index so that the +/- map controls appear below + the geocoder search results */ + z-index: 999; +} 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/i18n.js b/lib/util/i18n.js index b0e91f165..c6f69d57d 100644 --- a/lib/util/i18n.js +++ b/lib/util/i18n.js @@ -64,3 +64,12 @@ export function getDefaultLocale (config) { const { localization = {} } = config return localization.defaultLocale || 'en-US' } + +/** + * Obtains the time format (12 or 24 hr) based on the redux user state. + * FIXME: Remove after OTP-UI components can determine the time format on their own. + */ +export function getTimeFormat (state) { + const use24HourFormat = state.user.loggedInUser?.use24HourFormat ?? false + return use24HourFormat ? 'H:mm' : 'h:mm a' +} 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..ab0a99a92 100644 --- a/package.json +++ b/package.json @@ -37,11 +37,11 @@ "@opentripplanner/core-utils": "^3.2.3", "@opentripplanner/endpoints-overlay": "^1.2.0", "@opentripplanner/from-to-location-picker": "^1.2.1", - "@opentripplanner/geocoder": "^1.1.0", + "@opentripplanner/geocoder": "^1.1.1", "@opentripplanner/humanize-distance": "^1.1.0", "@opentripplanner/icons": "^1.1.0", "@opentripplanner/itinerary-body": "^2.3.1", - "@opentripplanner/location-field": "^1.3.0", + "@opentripplanner/location-field": "^1.6.1", "@opentripplanner/location-icon": "^1.3.0", "@opentripplanner/park-and-ride-overlay": "^1.2.0", "@opentripplanner/printable-itinerary": "^1.2.0", @@ -99,6 +99,7 @@ "react-router-bootstrap": "^0.25.0", "react-router-dom": "^4.4.0-beta.8", "react-select": "^3.1.0", + "react-sliding-pane": "^7.0.0", "react-swipeable-views": "^0.13.3", "redux": "^4.0.4", "redux-actions": "^1.2.1", @@ -107,6 +108,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..8190308bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1071,6 +1071,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.14.0": + version "7.15.4" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.15.4.tgz#fd17d16bfdf878e6dd02d19753a39fa8a8d9c84a" + integrity sha512-99catp6bHCaxr4sJ/DbTGgHS4+Rs2RVd2g7iOap6SLGPDknRK9ztKNsE/Fg6QhSeh1FGE5f6gHGQmvvn3I3xhw== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.10.4", "@babel/template@^7.12.7", "@babel/template@^7.4.0": version "7.12.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.7.tgz#c817233696018e39fbb6c491d2fb684e05ed43bc" @@ -1125,6 +1132,11 @@ resolved "https://registry.yarnpkg.com/@conveyal/lonlat/-/lonlat-1.4.0.tgz#18a5c1349078a779e710d24af11bc02b24127ba0" integrity sha512-ag1FcRuwRGAZgeZ4e3sUq+gblf1Pgma2c9SaIVXluIrgsZ9Lrq7xhCbV0ErN8chyg/OCOoG8m/l3mgzbycQCnQ== +"@conveyal/lonlat@^1.4.1": + version "1.4.1" + resolved "https://registry.yarnpkg.com/@conveyal/lonlat/-/lonlat-1.4.1.tgz#9970f33a2dc810ac08e89c844901405f18da478b" + integrity sha512-Z4W1NmvDsnkylhPMDWCk7kLaAjRtA9BVixASFxDPVkQwAiceOxaLEo4UVbZys4jNFzdtbcZ1UVPr6SQHBmEsrA== + "@csstools/convert-colors@^1.4.0": version "1.4.0" resolved "https://registry.yarnpkg.com/@csstools/convert-colors/-/convert-colors-1.4.0.tgz#ad495dc41b12e75d588c6db8b9834f08fa131eb7" @@ -1166,7 +1178,7 @@ resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.8.0.tgz#bbbff68978fefdbe68ccb533bc8cbe1d1afb5413" integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow== -"@emotion/is-prop-valid@^0.8.6", "@emotion/is-prop-valid@^0.8.8": +"@emotion/is-prop-valid@^0.8.6", "@emotion/is-prop-valid@^0.8.7", "@emotion/is-prop-valid@^0.8.8": version "0.8.8" resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a" integrity sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA== @@ -1575,6 +1587,23 @@ prop-types "^15.7.2" qs "^6.9.1" +"@opentripplanner/core-utils@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@opentripplanner/core-utils/-/core-utils-4.1.0.tgz#fd1f879de0366e9b1ac1d4af9450df4296338711" + integrity sha512-SGsazdapV7zUxaLg8AM3TgLGqlLHc34qhAoI/6Z3fjFFlbOb5+vFefltFSnKvOh0IYx/33gRJZ35FTcmCSZqkQ== + dependencies: + "@mapbox/polyline" "^1.1.0" + "@opentripplanner/geocoder" "^1.0.2" + "@turf/along" "^6.0.1" + bowser "^2.7.0" + date-fns "^2.23.0" + date-fns-tz "^1.1.4" + lodash.clonedeep "^4.5.0" + lodash.isequal "^4.5.0" + moment "^2.24.0" + prop-types "^15.7.2" + qs "^6.9.1" + "@opentripplanner/endpoints-overlay@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@opentripplanner/endpoints-overlay/-/endpoints-overlay-1.2.0.tgz#d8bcf347d82b1566352c4c2bc52f4f389bf72f6b" @@ -1604,6 +1633,16 @@ isomorphic-mapzen-search "^1.5.1" lodash.memoize "^4.1.2" +"@opentripplanner/geocoder@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@opentripplanner/geocoder/-/geocoder-1.1.1.tgz#a449ad868a01d9e35291e52f68946929469c065a" + integrity sha512-XejHs5KvYcZOnv/oh8QrF4JU43Fne803HjWx0o0a7OfxqvWj9Xh7ioKktKz7HMXmtI9nSMWWXpeqLW6tTp9K9w== + dependencies: + "@conveyal/geocoder-arcgis-geojson" "^0.0.2" + "@conveyal/lonlat" "^1.4.1" + isomorphic-mapzen-search "^1.5.1" + lodash.memoize "^4.1.2" + "@opentripplanner/humanize-distance@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@opentripplanner/humanize-distance/-/humanize-distance-1.1.0.tgz#aa5ecdd70f33cfbdd214c76da4ba799a8ab473a5" @@ -1632,17 +1671,17 @@ react-resize-detector "^4.2.1" velocity-react "^1.4.3" -"@opentripplanner/location-field@^1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@opentripplanner/location-field/-/location-field-1.3.0.tgz#18c31edfe03d8483e0663cbfa6ced5349b809aa7" - integrity sha512-j9TvSOfB9EAiRHIy6lR43qnPqQQqlnqO1WjibBhThUkgkxL/NOy688hAJuUyna+PXE/m3SJKgNlVdkyDzyJ9HQ== +"@opentripplanner/location-field@^1.6.1": + version "1.6.1" + resolved "https://registry.yarnpkg.com/@opentripplanner/location-field/-/location-field-1.6.1.tgz#2a439d4c87f1ee2e914fb628ab919eda32498b58" + integrity sha512-5RPn1j8o7ACWhJ9v/X9G/NZoORqEq91xplE8y0LGz6dLRhnRjY2brhqIz4DtzKyWM3siMJS51dsxOUWhkJDMDA== dependencies: - "@opentripplanner/core-utils" "^3.0.4" - "@opentripplanner/geocoder" "^1.0.2" + "@opentripplanner/core-utils" "^4.1.0" + "@opentripplanner/geocoder" "^1.1.0" "@opentripplanner/humanize-distance" "^1.1.0" - "@opentripplanner/location-icon" "^1.0.1" + "@opentripplanner/location-icon" "^1.3.0" + "@styled-icons/fa-solid" "^10.34.0" prop-types "^15.7.2" - styled-icons "^9.1.0" throttle-debounce "^2.1.0" "@opentripplanner/location-icon@^1.0.1", "@opentripplanner/location-icon@^1.3.0": @@ -1968,6 +2007,14 @@ "@styled-icons/styled-icon" "^9.4.1" tslib "^1.9.3" +"@styled-icons/fa-solid@^10.34.0": + version "10.34.0" + resolved "https://registry.yarnpkg.com/@styled-icons/fa-solid/-/fa-solid-10.34.0.tgz#315a6f6f25d38202d3387928191731e4c7a3bb18" + integrity sha512-PnJMUPcbuPA7gswxl9FKd725qaqP5VSbmX7rk+ZZ7ivdA6Dbi1VoKYXqAc62OEisGDhzn5g56KbBvE14W1n7vw== + dependencies: + "@babel/runtime" "^7.14.0" + "@styled-icons/styled-icon" "^10.6.3" + "@styled-icons/fa-solid@^9.4.1": version "9.4.1" resolved "https://registry.yarnpkg.com/@styled-icons/fa-solid/-/fa-solid-9.4.1.tgz#d30de755f8fee6e932032e97fa592cb1eaa21d8e" @@ -2056,6 +2103,14 @@ "@styled-icons/styled-icon" "^9.4.1" tslib "^1.9.3" +"@styled-icons/styled-icon@^10.6.3": + version "10.6.3" + resolved "https://registry.yarnpkg.com/@styled-icons/styled-icon/-/styled-icon-10.6.3.tgz#eae0e5e18fd601ac47e821bb9c2e099810e86403" + integrity sha512-/A95L3peioLoWFiy+/eKRhoQ9r/oRrN/qzbSX4hXU1nGP2rUXcX3LWUhoBNAOp9Rw38ucc/4ralY427UUNtcGQ== + dependencies: + "@babel/runtime" "^7.10.5" + "@emotion/is-prop-valid" "^0.8.7" + "@styled-icons/styled-icon@^9.4.1": version "9.4.1" resolved "https://registry.yarnpkg.com/@styled-icons/styled-icon/-/styled-icon-9.4.1.tgz#9dc236c85afd89edc2bc7265ec7858cbc35d4234" @@ -6028,6 +6083,16 @@ data-urls@^1.0.0: whatwg-mimetype "^2.2.0" whatwg-url "^7.0.0" +date-fns-tz@^1.1.4: + version "1.1.6" + resolved "https://registry.yarnpkg.com/date-fns-tz/-/date-fns-tz-1.1.6.tgz#93cbf354e2aeb2cd312ffa32e462c1943cf20a8e" + integrity sha512-nyy+URfFI3KUY7udEJozcoftju+KduaqkVfwyTIE0traBiVye09QnyWKLZK7drRr5h9B7sPJITmQnS3U6YOdQg== + +date-fns@^2.23.0: + version "2.24.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.24.0.tgz#7d86dc0d93c87b76b63d213b4413337cfd1c105d" + integrity sha512-6ujwvwgPID6zbI0o7UbURi2vlLDR9uP26+tW6Lg+Ji3w7dd0i3DOcjcClLjLPranT60SSEFBwdSyYwn/ZkPIuw== + date-now@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b" @@ -7308,6 +7373,11 @@ execa@^5.0.0, execa@^5.1.1: signal-exit "^3.0.3" strip-final-newline "^2.0.0" +exenv@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d" + integrity sha1-KueOhdmJQVhnCwPUe+wfA72Ru50= + exit@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" @@ -14601,14 +14671,14 @@ react-bootstrap@^0.32.1: warning "^3.0.0" react-dom@^16.9.0: - version "16.9.0" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.9.0.tgz#5e65527a5e26f22ae3701131bcccaee9fb0d3962" - integrity sha512-YFT2rxO9hM70ewk9jq0y6sQk8cL02xm4+IzYBz75CQGlClQQ1Bxq0nhHF6OtSbit+AIahujJgb/CPRibFkMNJQ== + version "16.14.0" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89" + integrity sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" prop-types "^15.6.2" - scheduler "^0.15.0" + scheduler "^0.19.1" react-draggable@^4.4.3: version "4.4.3" @@ -14676,7 +14746,7 @@ react-leaflet@^2.6.1: hoist-non-react-statics "^3.3.1" warning "^4.0.3" -react-lifecycles-compat@^3.0.4: +react-lifecycles-compat@^3.0.0, react-lifecycles-compat@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== @@ -14688,6 +14758,16 @@ react-loading-skeleton@^2.1.1: dependencies: "@emotion/core" "^10.0.22" +react-modal@^3.12.1: + version "3.14.3" + resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.14.3.tgz#7eb7c5ec85523e5843e2d4737cc17fc3f6aeb1c0" + integrity sha512-+C2KODVKyu20zHXPJxfOOcf571L1u/EpFlH+oS/3YDn8rgVE51QZuxuuIwabJ8ZFnOEHaD+r6XNjqwtxZnXO0g== + dependencies: + exenv "^1.2.0" + prop-types "^15.7.2" + react-lifecycles-compat "^3.0.0" + warning "^4.0.3" + react-overlays@^0.8.0: version "0.8.3" resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-0.8.3.tgz#fad65eea5b24301cca192a169f5dddb0b20d3ac5" @@ -14801,6 +14881,14 @@ react-select@^3.1.0: react-input-autosize "^2.2.2" react-transition-group "^4.3.0" +react-sliding-pane@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/react-sliding-pane/-/react-sliding-pane-7.0.0.tgz#ed96400fce273e7a36bd26cfb8ffed4e1480cc29" + integrity sha512-8WLNxGZGAUYM7q29VMW5e8zGE01IJDFQzTBrMsCVtl5Bjipn1D9VeBvsVhSFygnYLv89ClLSpacJKt6Zqn6E+g== + dependencies: + prop-types "^15.7.2" + react-modal "^3.12.1" + react-swipeable-views-core@^0.13.1: version "0.13.1" resolved "https://registry.yarnpkg.com/react-swipeable-views-core/-/react-swipeable-views-core-0.13.1.tgz#8829a922462a8bdd701709cd1b385393d38f1527" @@ -15858,6 +15946,14 @@ scheduler@^0.18.0: loose-envify "^1.1.0" object-assign "^4.1.1" +scheduler@^0.19.1: + version "0.19.1" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196" + integrity sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scope-analyzer@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/scope-analyzer/-/scope-analyzer-2.1.1.tgz#5156c27de084d74bf75af9e9506aaf95c6e73dd6" @@ -17195,6 +17291,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"