diff --git a/example.js b/example.js index e3bb59688..33852b548 100644 --- a/example.js +++ b/example.js @@ -13,6 +13,7 @@ import createLogger from 'redux-logger' // import OTP-RR components import { + BatchResultsScreen, BatchRoutingPanel, BatchSearchScreen, CallTakerControls, @@ -21,6 +22,7 @@ import { DefaultItinerary, DefaultMainPanel, FieldTripWindows, + MobileResultsScreen, MobileSearchScreen, ResponsiveWebapp, createCallTakerReducer, @@ -76,6 +78,7 @@ const TermsOfStorage = () => ( // - MainControls (optional) // - MainPanel (required) // - MapWindows (optional) +// - MobileResultsScreen (required) // - MobileSearchScreen (required) // - ModeIcon (required) // - TermsOfService (required if otpConfig.persistence.strategy === 'otp_middleware') @@ -96,6 +99,9 @@ const components = { : null, + MobileResultsScreen: isBatchRoutingEnabled + ? BatchResultsScreen + : MobileResultsScreen, MobileSearchScreen: isBatchRoutingEnabled ? BatchSearchScreen : MobileSearchScreen, diff --git a/lib/actions/ui.js b/lib/actions/ui.js index 27cca104a..955515798 100644 --- a/lib/actions/ui.js +++ b/lib/actions/ui.js @@ -3,9 +3,13 @@ import coreUtils from '@opentripplanner/core-utils' import { createAction } from 'redux-actions' import { matchPath } from 'react-router' -import { findRoute } from './api' +import { findRoute, setUrlSearch } from './api' import { setMapCenter, setMapZoom, setRouterId } from './config' -import { clearActiveSearch, parseUrlQueryString, setActiveSearch } from './form' +import { + clearActiveSearch, + parseUrlQueryString, + setActiveSearch +} from './form' import { clearLocation } from './map' import { setActiveItinerary } from './narrative' import { getUiUrlParams } from '../util/state' @@ -230,3 +234,81 @@ export const MobileScreens = { SET_DATETIME: 7, RESULTS_SUMMARY: 8 } + +/** + * Enum to describe the layout of the itinerary view + * (currently only used in batch results). + */ +export const ItineraryView = { + DEFAULT: 'list', + /** One itinerary is shown. (In mobile view, the map is hidden.) */ + FULL: 'full', + /** One itinerary is shown, itinerary and map are focused on a leg. (The mobile view is split.) */ + LEG: 'leg', + /** One itinerary leg is hidden. (In mobile view, the map is expanded.) */ + LEG_HIDDEN: 'leg-hidden', + /** The list of itineraries is shown. (The mobile view is split.) */ + LIST: 'list', + /** The list of itineraries is hidden. (In mobile view, the map is expanded.) */ + LIST_HIDDEN: 'list-hidden' +} + +const setPreviousItineraryView = createAction('SET_PREVIOUS_ITINERARY_VIEW') + +/** + * Sets the itinerary view state (see values above) in the URL params + * (currently only used in batch results). + */ +export function setItineraryView (value) { + return function (dispatch, getState) { + const urlParams = coreUtils.query.getUrlParams() + const prevItineraryView = urlParams.ui_itineraryView || ItineraryView.DEFAULT + + // If the itinerary value is changed, + // set the desired ui query param, or remove it if same as default, + // and store the current view as previousItineraryView. + if (value !== urlParams.ui_itineraryView) { + if (value !== ItineraryView.DEFAULT) { + urlParams.ui_itineraryView = value + } else if (urlParams.ui_itineraryView) { + delete urlParams.ui_itineraryView + } + + dispatch(setUrlSearch(urlParams)) + dispatch(setPreviousItineraryView(prevItineraryView)) + } + } +} + +/** + * Switch the mobile batch results view between full map view and the split state + * (itinerary list or itinerary leg view) that was in place prior. + */ +export function toggleBatchResultsMap () { + return function (dispatch, getState) { + const urlParams = coreUtils.query.getUrlParams() + const itineraryView = urlParams.ui_itineraryView || ItineraryView.DEFAULT + + if (itineraryView === ItineraryView.LEG) { + dispatch(setItineraryView(ItineraryView.LEG_HIDDEN)) + } else if (itineraryView === ItineraryView.LIST) { + dispatch(setItineraryView(ItineraryView.LIST_HIDDEN)) + } else { + const { previousItineraryView } = getState().otp.ui + dispatch(setItineraryView(previousItineraryView)) + } + } +} + +/** + * Takes the user back to the mobile search screen in mobile views. + */ +export function showMobileSearchScreen () { + return function (dispatch, getState) { + // Reset itinerary view state to show the list of results *before* clearing the search. + // (Otherwise, if the map is expanded, the search is not cleared.) + dispatch(setItineraryView(ItineraryView.LIST)) + dispatch(clearActiveSearch()) + dispatch(setMobileScreen(MobileScreens.SEARCH_FORM)) + } +} diff --git a/lib/components/map/bounds-updating-overlay.js b/lib/components/map/bounds-updating-overlay.js index 230ae68c8..e4ce308e6 100644 --- a/lib/components/map/bounds-updating-overlay.js +++ b/lib/components/map/bounds-updating-overlay.js @@ -22,6 +22,9 @@ function extendBoundsByPlaces (bounds, places = []) { }) } +/** Padding around itinerary bounds and map bounds. */ +const BOUNDS_PADDING = [30, 30] + /** * This MapLayer component will automatically update the leaflet bounds * depending on what data is in the redux store. This component does not @@ -42,6 +45,29 @@ class BoundsUpdatingOverlay extends MapLayer { componentWillUnmount () {} + _fitItineraryViewToMap (newProps, bounds, map) { + // If itineraryView has changed (currently: only in mobile batch results), + // force a resize of the map before re-fitting the active itinerary or active leg, + // and do that after a delay to ensure that canvas heights have stabilized in the DOM. + setTimeout(() => { + map.invalidateSize(true) + + const { activeLeg, itinerary } = newProps + if (itinerary) { + if (activeLeg !== null) { + // Fit to active leg if set. + map.fitBounds( + getLeafletLegBounds(itinerary.legs[activeLeg]), + { ITINERARY_MAP_PADDING: BOUNDS_PADDING } + ) + } else { + // Fit to whole itinerary otherwise. + map.fitBounds(bounds, { ITINERARY_MAP_PADDING: BOUNDS_PADDING }) + } + } + }, 250) + } + /* eslint-disable-next-line complexity */ updateBounds (oldProps, newProps) { // TODO: maybe setting bounds ought to be handled in map props... @@ -55,8 +81,6 @@ class BoundsUpdatingOverlay extends MapLayer { const { map } = newProps.leaflet if (!map) return - const padding = [30, 30] - // Fit map to to entire itinerary if active itinerary bounds changed const newFrom = newProps.query && newProps.query.from const newItinBounds = newProps.itinerary && getLeafletItineraryBounds(newProps.itinerary) @@ -69,11 +93,17 @@ class BoundsUpdatingOverlay extends MapLayer { const oldIntermediate = oldProps.query && oldProps.query.intermediatePlaces const newIntermediate = newProps.query && newProps.query.intermediatePlaces const intermediateChanged = !isEqual(oldIntermediate, newIntermediate) - if ( + + // Also refit map if itineraryView prop has changed. + const itineraryViewChanged = oldProps.itineraryView !== newProps.itineraryView + + if (itineraryViewChanged) { + this._fitItineraryViewToMap(newProps, newItinBounds, map) + } else if ( (!oldItinBounds && newItinBounds) || (oldItinBounds && newItinBounds && !oldItinBounds.equals(newItinBounds)) ) { - map.fitBounds(newItinBounds, { padding }) + map.fitBounds(newItinBounds, { padding: BOUNDS_PADDING }) // Pan to to itinerary leg if made active (clicked); newly active leg must be non-null } else if ( newProps.itinerary && @@ -82,7 +112,7 @@ class BoundsUpdatingOverlay extends MapLayer { ) { map.fitBounds( getLeafletLegBounds(newProps.itinerary.legs[newProps.activeLeg]), - { padding } + { padding: BOUNDS_PADDING } ) // If no itinerary update but from/to locations are present, fit to those @@ -108,9 +138,8 @@ class BoundsUpdatingOverlay extends MapLayer { map.fitBounds([ [left, bottom], [right, top] - ], { padding }) + ], { padding: BOUNDS_PADDING }) } - // If only from or to is set, pan to that } else if (newFrom && fromChanged) { map.panTo([newFrom.lat, newFrom.lon]) @@ -141,10 +170,13 @@ class BoundsUpdatingOverlay extends MapLayer { const mapStateToProps = (state, ownProps) => { const activeSearch = getActiveSearch(state.otp) + const urlParams = coreUtils.query.getUrlParams() + return { activeLeg: activeSearch && activeSearch.activeLeg, activeStep: activeSearch && activeSearch.activeStep, itinerary: getActiveItinerary(state.otp), + itineraryView: urlParams.ui_itineraryView, popupLocation: state.otp.ui.mapPopupLocation, query: state.otp.currentQuery } diff --git a/lib/components/map/default-map.js b/lib/components/map/default-map.js index 3ce357508..9a3157701 100644 --- a/lib/components/map/default-map.js +++ b/lib/components/map/default-map.js @@ -1,4 +1,5 @@ import BaseMap from '@opentripplanner/base-map' +import coreUtils from '@opentripplanner/core-utils' import React, { Component } from 'react' import { connect } from 'react-redux' import styled from 'styled-components' @@ -119,6 +120,7 @@ class DefaultMap extends Component { bikeRentalStations, carRentalQuery, carRentalStations, + itineraryView, mapConfig, mapPopupLocation, vehicleRentalQuery, @@ -146,8 +148,8 @@ class DefaultMap extends Component { center={center} maxZoom={mapConfig.maxZoom} onClick={this.onMapClick} - popup={popup} onPopupClosed={this.onPopupClosed} + popup={popup} zoom={mapConfig.initZoom || 13} > {/* The default overlays */} @@ -155,7 +157,14 @@ class DefaultMap extends Component { - + {/* + HACK: Use the key prop to force a remount and full resizing of transitive + if the map container size changes, + per https://linguinecode.com/post/4-methods-to-re-render-react-component + Without it, transitive resolution will not match the map, + and transitive will appear blurry after e.g. the narrative is expanded. + */} + @@ -206,9 +215,12 @@ const mapStateToProps = (state, ownProps) => { const overlays = state.otp.config.map && state.otp.config.map.overlays ? state.otp.config.map.overlays : [] + const urlParams = coreUtils.query.getUrlParams() + return { bikeRentalStations: state.otp.overlay.bikeRental.stations, carRentalStations: state.otp.overlay.carRental.stations, + itineraryView: urlParams.ui_itineraryView, mapConfig: state.otp.config.map, mapPopupLocation: state.otp.ui.mapPopupLocation, overlays, diff --git a/lib/components/mobile/batch-results-screen.js b/lib/components/mobile/batch-results-screen.js new file mode 100644 index 000000000..51370a897 --- /dev/null +++ b/lib/components/mobile/batch-results-screen.js @@ -0,0 +1,146 @@ +import coreUtils from '@opentripplanner/core-utils' +import React from 'react' +import { Button } from 'react-bootstrap' +import { connect } from 'react-redux' +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 { + getActiveItineraries, + getActiveSearch, + getResponsesWithErrors +} from '../../util/state' + +import MobileContainer from './container' +import ResultsError from './results-error' +import ResultsHeader from './results-header' + +const StyledMobileContainer = styled(MobileContainer)` + .options > .header { + margin: 10px; + } + + &.otp.mobile .mobile-narrative-container { + bottom: 0; + left: 0; + overflow-y: auto; + padding: 0; + position: fixed; + right: 0; + } +` + +const ExpandMapButton = styled(Button)` + bottom: 10px; + left: 10px; + position: absolute; + z-index: 999999; +` + +const NARRATIVE_SPLIT_TOP_PERCENT = 45 + +// Styles for the results map also include prop-independent styles copied from mobile.css. +const ResultsMap = styled.div` + bottom: ${props => props.expanded ? '0' : `${100 - NARRATIVE_SPLIT_TOP_PERCENT}%`}; + display: ${props => props.visible ? 'inherit' : 'none'}; + left: 0; + position: fixed; + right: 0; + top: 100px; +` + +const narrativeCss = css` + top: ${props => props.visible ? (props.expanded ? '100px' : `${NARRATIVE_SPLIT_TOP_PERCENT}%`) : '100%'}; + transition: top 300ms; +` + +const StyledResultsError = styled(ResultsError)` + display: ${props => props.visible ? 'inherit' : 'none'}; + ${narrativeCss} +` + +const NarrativeContainer = styled.div` + ${narrativeCss} +` + +const { ItineraryView } = uiActions + +/** + * This component renders the mobile view of itinerary results from batch routing, + * and features a split view between the map and itinerary results or narratives. + */ +const BatchMobileResultsScreen = ({ + errors, + itineraries, + itineraryView, + toggleBatchResultsMap +}) => { + const hasErrorsAndNoResult = itineraries.length === 0 && errors.length > 0 + const mapExpanded = itineraryView === ItineraryView.LEG_HIDDEN || itineraryView === ItineraryView.LIST_HIDDEN + const itineraryExpanded = itineraryView === ItineraryView.FULL + + return ( + + + + + + {' '} + {mapExpanded ? 'Show results' : 'Expand map'} + + + {hasErrorsAndNoResult + ? + : ( + + + + ) + } + + ) +} + +// connect to the redux store + +const mapStateToProps = (state, ownProps) => { + const activeSearch = getActiveSearch(state.otp) + const urlParams = coreUtils.query.getUrlParams() + return { + activeLeg: activeSearch ? activeSearch.activeLeg : null, + errors: getResponsesWithErrors(state.otp), + itineraries: getActiveItineraries(state.otp), + itineraryView: urlParams.ui_itineraryView || ItineraryView.DEFAULT + } +} + +const mapDispatchToProps = { + toggleBatchResultsMap: uiActions.toggleBatchResultsMap +} + +export default connect(mapStateToProps, mapDispatchToProps)(BatchMobileResultsScreen) diff --git a/lib/components/mobile/container.js b/lib/components/mobile/container.js index fc79f524e..0795ae7c7 100644 --- a/lib/components/mobile/container.js +++ b/lib/components/mobile/container.js @@ -2,9 +2,10 @@ import React, { Component } from 'react' export default class MobileContainer extends Component { render () { + const { children, className } = this.props return ( -
- {this.props.children} +
+ {children}
) } diff --git a/lib/components/mobile/edit-search-button.js b/lib/components/mobile/edit-search-button.js new file mode 100644 index 000000000..bdf514857 --- /dev/null +++ b/lib/components/mobile/edit-search-button.js @@ -0,0 +1,20 @@ +import React from 'react' +import { Button } from 'react-bootstrap' +import { connect } from 'react-redux' + +import * as uiActions from '../../actions/ui' + +/** + * Renders the "Edit" or "Back to search" button in mobile result views + * that takes the user back to the mobile search screen. + */ +const EditSearchButton = ({ showMobileSearchScreen, ...props }) => ( + -
- - - ) - } - - renderLocationsSummary = () => { - const { query } = this.props - - return ( - - - - - { query.from ? query.from.name : '' } - - - { query.to ? query.to.name : '' } - - - - - - - - ) - } - render () { const { activeItineraryIndex, error, realtimeEffects, - resultCount, useRealtime } = this.props const { expanded } = this.state - const narrativeContainerStyle = expanded - ? { top: 140, overflowY: 'auto' } - : { height: 80, overflowY: 'hidden' } - - // Ensure that narrative covers map. - narrativeContainerStyle.backgroundColor = 'white' - - let headerAction = null const showRealtimeAnnotation = realtimeEffects.isAffectedByRealtimeData && ( realtimeEffects.exceedsThreshold || realtimeEffects.routesDiffer || !useRealtime ) - if (error) { - return this.renderError() - } - return ( - 1 ? 's' : ''}` - : 'Waiting...' - } - headerAction={headerAction} - /> - {this.renderLocationsSummary()} - -
+ +
- -
- Option {activeItineraryIndex + 1} - -
- -
- -
- {this.renderDots()} + {error + ? + : ( + <> + + Option {activeItineraryIndex + 1} + + + + + + + {this.renderDots()} + + ) + } ) } @@ -245,25 +166,23 @@ const mapStateToProps = (state, ownProps) => { const realtimeEffects = getRealtimeEffects(state.otp) const itineraries = getActiveItineraries(state.otp) return { + activeItineraryIndex: activeSearch ? activeSearch.activeItinerary : null, + activeLeg: activeSearch ? activeSearch.activeLeg : null, + error: getActiveError(state.otp), query: state.otp.currentQuery, realtimeEffects, - error: getActiveError(state.otp), resultCount: response ? activeSearch.query.routingType === 'ITINERARY' ? itineraries.length : response.otp.profile.length : null, - useRealtime, - activeItineraryIndex: activeSearch ? activeSearch.activeItinerary : null, - activeLeg: activeSearch ? activeSearch.activeLeg : null + useRealtime } } const mapDispatchToProps = { - clearActiveSearch, - setMobileScreen, - setUseRealtimeResponse + setUseRealtimeResponse: narrativeActions.setUseRealtimeResponse } export default connect(mapStateToProps, mapDispatchToProps)(MobileResultsScreen) diff --git a/lib/components/narrative/narrative-itineraries.js b/lib/components/narrative/narrative-itineraries.js index 8cc02b610..b241ac753 100644 --- a/lib/components/narrative/narrative-itineraries.js +++ b/lib/components/narrative/narrative-itineraries.js @@ -12,6 +12,7 @@ import { setVisibleItinerary, updateItineraryFilter } from '../../actions/narrative' +import * as uiActions from '../../actions/ui' import Icon from '../narrative/icon' import { ComponentContext } from '../../util/contexts' import { @@ -23,6 +24,8 @@ import { import SaveTripButton from './save-trip-button' +const { ItineraryView } = uiActions + // TODO: move to utils? function humanReadableMode (modeStr) { if (!modeStr) return 'N/A' @@ -37,23 +40,48 @@ function humanReadableMode (modeStr) { class NarrativeItineraries extends Component { static propTypes = { + activeItinerary: PropTypes.number, containerStyle: PropTypes.object, itineraries: PropTypes.array, pending: PropTypes.bool, - activeItinerary: PropTypes.number, setActiveItinerary: PropTypes.func, setActiveLeg: PropTypes.func, setActiveStep: PropTypes.func, + setItineraryView: PropTypes.func, setUseRealtimeResponse: PropTypes.func, useRealtime: PropTypes.bool } static contextType = ComponentContext - state = {} + _setActiveLeg = (index, leg) => { + const { activeLeg, setActiveLeg, setItineraryView } = this.props + const isSameLeg = activeLeg === index + if (isSameLeg) { + // If clicking on the same leg again, reset it to null, + // and show the full itinerary (both desktop and mobile view) + setActiveLeg(null, null) + setItineraryView(ItineraryView.FULL) + } else { + // Focus on the newly selected leg. + setActiveLeg(index, leg) + setItineraryView(ItineraryView.LEG) + } + } + + _isShowingDetails = () => { + const { itineraryView } = this.props + return itineraryView === ItineraryView.FULL || + itineraryView === ItineraryView.LEG || + itineraryView === ItineraryView.LEG_HIDDEN + } _toggleDetailedItinerary = () => { - this.setState({showDetails: !this.state.showDetails}) + const { setActiveLeg, setItineraryView } = this.props + const newView = this._isShowingDetails() ? ItineraryView.LIST : ItineraryView.FULL + setItineraryView(newView) + // Reset the active leg. + setActiveLeg(null, null) } _onSortChange = evt => { @@ -94,19 +122,27 @@ class NarrativeItineraries extends Component { render () { const { activeItinerary, + activeLeg, activeSearch, + activeStep, containerStyle, errors, itineraries, pending, realtimeEffects, + setActiveItinerary, + setActiveStep, + setVisibleItinerary, sort, - useRealtime + timeFormat, + useRealtime, + visibleItinerary } = this.props const { ItineraryBody, LegIcon } = this.context if (!activeSearch) return null - const itineraryIsExpanded = activeItinerary !== undefined && activeItinerary !== null && this.state.showDetails + const showDetails = this._isShowingDetails() + const itineraryIsExpanded = activeItinerary !== undefined && activeItinerary !== null && showDetails const showRealtimeAnnotation = realtimeEffects.isAffectedByRealtimeData && ( realtimeEffects.exceedsThreshold || @@ -116,6 +152,7 @@ class NarrativeItineraries extends Component { const resultText = pending ? 'Finding your options...' : `${itineraries.length} itineraries found.` + return (
) })} + {/* Don't show errors if an itinerary is expanded. */} {/* FIXME: Flesh out error design/move to component? */} - {errors.map((e, i) => { + {!itineraryIsExpanded && errors.map((e, i) => { const mode = humanReadableMode(e.requestParameters.mode) return (
@@ -226,17 +270,20 @@ const mapStateToProps = (state, ownProps) => { const itineraries = getActiveItineraries(state.otp) const realtimeEffects = getRealtimeEffects(state.otp) const useRealtime = state.otp.useRealtime + const urlParams = coreUtils.query.getUrlParams() + return { - activeSearch, - errors: getResponsesWithErrors(state.otp), // swap out realtime itineraries with non-realtime depending on boolean - itineraries, - pending, - realtimeEffects, activeItinerary: activeSearch && activeSearch.activeItinerary, activeLeg: activeSearch && activeSearch.activeLeg, + activeSearch, activeStep: activeSearch && activeSearch.activeStep, + errors: getResponsesWithErrors(state.otp), + itineraries, + itineraryView: urlParams.ui_itineraryView || ItineraryView.DEFAULT, modes, + pending, + realtimeEffects, sort, timeFormat: coreUtils.time.getTimeFormat(state.otp.config), useRealtime, @@ -258,6 +305,7 @@ const mapDispatchToProps = (dispatch, ownProps) => { setActiveStep: (index, step) => { dispatch(setActiveStep({index, step})) }, + setItineraryView: payload => dispatch(uiActions.setItineraryView(payload)), setUseRealtimeResponse: payload => dispatch(setUseRealtimeResponse(payload)), setVisibleItinerary: payload => dispatch(setVisibleItinerary(payload)), updateItineraryFilter: payload => dispatch(updateItineraryFilter(payload)) diff --git a/lib/index.js b/lib/index.js index 1bf19fee9..3f1d1862c 100644 --- a/lib/index.js +++ b/lib/index.js @@ -30,6 +30,7 @@ import TripTools from './components/narrative/trip-tools' import LineItinerary from './components/narrative/line-itin/line-itinerary' import MobileMain from './components/mobile/main' +import MobileResultsScreen from './components/mobile/results-screen' import MobileSearchScreen from './components/mobile/search-screen' import NavLoginButton from './components/user/nav-login-button' @@ -47,6 +48,7 @@ import DesktopNav from './components/app/desktop-nav' import DefaultMainPanel from './components/app/default-main-panel' import BatchRoutingPanel from './components/app/batch-routing-panel' +import BatchResultsScreen from './components/mobile/batch-results-screen' import BatchSearchScreen from './components/mobile/batch-search-screen' import { setAutoPlan, setMapCenter } from './actions/config' @@ -97,6 +99,7 @@ export { // mobile components MobileMain, + MobileResultsScreen, MobileSearchScreen, // viewer components @@ -117,6 +120,7 @@ export { DefaultMainPanel, // batch routing components + BatchResultsScreen, BatchRoutingPanel, BatchSearchScreen, diff --git a/lib/reducers/create-otp-reducer.js b/lib/reducers/create-otp-reducer.js index 863b94bbb..1fadd42c0 100644 --- a/lib/reducers/create-otp-reducer.js +++ b/lib/reducers/create-otp-reducer.js @@ -945,6 +945,8 @@ function createOtpReducer (config) { return update(state, { filter: { $set: action.payload } } ) + case 'SET_PREVIOUS_ITINERARY_VIEW': + return update(state, { ui: { previousItineraryView: { $set: action.payload } } }) default: return state } diff --git a/lib/util/state.js b/lib/util/state.js index a3459b805..4caab3f7f 100644 --- a/lib/util/state.js +++ b/lib/util/state.js @@ -99,7 +99,6 @@ export function getActiveItineraries (otpState) { }) return hasCar default: - console.warn(`Filter (${filter}) not supported`) return true } })