diff --git a/lib/editor/actions/map/index.js b/lib/editor/actions/map/index.js index f79304923..bb7c4fc64 100644 --- a/lib/editor/actions/map/index.js +++ b/lib/editor/actions/map/index.js @@ -162,7 +162,7 @@ export function handleControlPointDrag ( patternCoordinates: any ) { return function (dispatch: dispatchFn, getState: getStateFn) { - const {avoidMotorways, currentDragId, followStreets} = getState().editor.editSettings.present + const {avoidMotorways, currentDragId, followStreets, followRail} = getState().editor.editSettings.present recalculateShape({ avoidMotorways, controlPoints, @@ -170,6 +170,7 @@ export function handleControlPointDrag ( dragId: currentDragId, editType: 'update', followStreets, + followRail, index, newPoint: latlng, patternCoordinates @@ -204,7 +205,7 @@ export function handleControlPointDragEnd ( dispatch(controlPointDragOrEnd()) // recalculate shape for final position - const {avoidMotorways, followStreets} = getState().editor.editSettings.present + const {avoidMotorways, followStreets, followRail} = getState().editor.editSettings.present recalculateShape({ avoidMotorways, controlPoints, @@ -212,6 +213,7 @@ export function handleControlPointDragEnd ( editType: 'update', index, followStreets, + followRail, newPoint: latlng, patternCoordinates, snapControlPointToNewSegment: true diff --git a/lib/editor/actions/map/stopStrategies.js b/lib/editor/actions/map/stopStrategies.js index bc19e3236..9453f3af6 100644 --- a/lib/editor/actions/map/stopStrategies.js +++ b/lib/editor/actions/map/stopStrategies.js @@ -222,7 +222,7 @@ export function addStopAtInterval (latlng: LatLng, activePattern: Pattern, contr export function addStopToPattern (pattern: Pattern, stop: GtfsStop, index?: ?number) { return async function (dispatch: dispatchFn, getState: getStateFn) { const {data, editSettings} = getState().editor - const {avoidMotorways, followStreets} = editSettings.present + const {avoidMotorways, followStreets, followRail} = editSettings.present const {patternStops: currentPatternStops, shapePoints} = pattern const patternStops = clone(currentPatternStops) const {controlPoints, patternSegments} = getControlPoints(getState()) @@ -260,7 +260,7 @@ export function addStopToPattern (pattern: Pattern, stop: GtfsStop, index?: ?num } else { dispatch(updatePatternStops(pattern, patternStops)) // Otherwise, check if a shape ought to be created. Then, save. - if (patternStops.length === 2 && followStreets) { + if (patternStops.length === 2 && (followStreets || followRail)) { // Create shape between stops the added stop is the second one and // followStreets is enabled. Otherwise, there is no need to create a // new shape because it would just be a straight line segment anyways. @@ -272,7 +272,7 @@ export function addStopToPattern (pattern: Pattern, stop: GtfsStop, index?: ?num } const points = [previousStop, stop] .map((stop, index) => ({lng: stop.stop_lon, lat: stop.stop_lat})) - const patternSegments = await getPolyline(points, true, avoidMotorways) + const patternSegments = await getPolyline(points, true, avoidMotorways, followRail) // Update pattern stops and geometry. const controlPoints = controlPointsFromSegments(patternStops, patternSegments) dispatch(updatePatternGeometry({controlPoints, patternSegments})) @@ -336,6 +336,7 @@ export function addStopToPattern (pattern: Pattern, stop: GtfsStop, index?: ?num defaultToStraightLine: false, editType: 'update', followStreets, + followRail, index: spliceIndex, newPoint: {lng: stop.stop_lon, lat: stop.stop_lat}, snapControlPointToNewSegment: true, @@ -377,6 +378,7 @@ export function addStopToPattern (pattern: Pattern, stop: GtfsStop, index?: ?num defaultToStraightLine: false, editType: 'update', followStreets, + followRail, index, newPoint: {lng: stop.stop_lon, lat: stop.stop_lat}, snapControlPointToNewSegment: true, @@ -406,12 +408,12 @@ export function addStopToPattern (pattern: Pattern, stop: GtfsStop, index?: ?num */ function extendPatternToPoint (pattern, endPoint, newEndPoint, stop = null, splitInterval = 0) { return async function (dispatch: dispatchFn, getState: getStateFn) { - const {avoidMotorways, followStreets} = getState().editor.editSettings.present + const {avoidMotorways, followStreets, followRail} = getState().editor.editSettings.present const {controlPoints, patternSegments} = getControlPoints(getState()) const clonedControlPoints = clone(controlPoints) let newShape - if (followStreets) { - newShape = await getPolyline([endPoint, newEndPoint], false, avoidMotorways) + if (followStreets || followRail) { + newShape = await getPolyline([endPoint, newEndPoint], false, avoidMotorways, followRail) } if (!newShape) { // Get single coordinate for straight line if polyline fails or if not diff --git a/lib/editor/components/pattern/EditSettings.js b/lib/editor/components/pattern/EditSettings.js index d353495fe..9e67e7a1d 100644 --- a/lib/editor/components/pattern/EditSettings.js +++ b/lib/editor/components/pattern/EditSettings.js @@ -5,11 +5,13 @@ import {Alert, Checkbox, Form, FormControl, ControlLabel} from 'react-bootstrap' import Rcslider from 'rc-slider' import {updateEditSetting} from '../../actions/active' -import {CLICK_OPTIONS} from '../../util' +import {CLICK_OPTIONS, SNAP_TO_OPTIONS} from '../../util' import toSentenceCase from '../../../common/util/to-sentence-case' import type {EditSettingsState} from '../../../types/reducers' +import type {GtfsRoute} from '../../../types' type Props = { + activeEntity: GtfsRoute, editSettings: EditSettingsState, patternSegment: number, updateEditSetting: typeof updateEditSetting @@ -54,17 +56,27 @@ export default class EditSettings extends Component { value }) + _onFollowOptionChange = (evt: SyntheticInputEvent) => { + // Update both settings based on selection + this.props.updateEditSetting({setting: 'followStreets', value: evt.target.value === SNAP_TO_OPTIONS.STREET}) + this.props.updateEditSetting({setting: 'followRail', value: evt.target.value === SNAP_TO_OPTIONS.RAIL}) + } + render () { // Edit Settings passed in are present - const {editSettings, patternSegment, updateEditSetting} = this.props + const {activeEntity, editSettings, patternSegment, updateEditSetting} = this.props const { editGeometry, followStreets, + followRail, onMapClick, stopInterval } = editSettings + + // Determine the current selection based on followStreets and followRail + const currentSelection = followStreets ? SNAP_TO_OPTIONS.STREET : (followRail ? SNAP_TO_OPTIONS.RAIL : SNAP_TO_OPTIONS.NONE) + const SETTINGS = [ - {type: 'followStreets', label: 'Snap to streets'}, {type: 'avoidMotorways', label: 'Avoid highways in routing'}, {type: 'hideStopHandles', label: 'Hide stop handles'}, {type: 'hideInactiveSegments', label: 'Hide inactive segments'}, @@ -73,8 +85,37 @@ export default class EditSettings extends Component { ] if (!editGeometry) return null const noSegmentIsActive = !patternSegment && patternSegment !== 0 + + // If route_type is rail, add "snap to rail" option + const snapToOptions = Object.keys(SNAP_TO_OPTIONS).reduce((acc, key) => { + if (key === 'RAIL') { + const routeType = activeEntity && activeEntity.route_type + if ([0, 1, 2].includes(routeType)) { + acc[key] = SNAP_TO_OPTIONS[key] + } + } else { + acc[key] = SNAP_TO_OPTIONS[key] + } + return acc + }, {}) + return (
+ Snap to + + {Object.keys(snapToOptions).map(key => ( + + ))} + {SETTINGS.map((s, i) => ( { // this state would cause the entire shape to disappear). disabled={ (s.type === 'hideInactiveSegments' && noSegmentIsActive) || - (s.type === 'avoidMotorways' && !followStreets) + (s.type === 'avoidMotorways' && currentSelection !== SNAP_TO_OPTIONS.STREET) } name={s.type} style={{margin: '3px 0'}} diff --git a/lib/editor/components/pattern/EditShapePanel.js b/lib/editor/components/pattern/EditShapePanel.js index 9f72213de..6965c9a8a 100644 --- a/lib/editor/components/pattern/EditShapePanel.js +++ b/lib/editor/components/pattern/EditShapePanel.js @@ -21,12 +21,13 @@ import { getPatternDistance, isValidStopControlPoint } from '../../util/map' -import type {ControlPoint, LatLng, Pattern, GtfsStop} from '../../../types' +import type {ControlPoint, GtfsRoute, LatLng, Pattern, GtfsStop} from '../../../types' import type {EditSettingsUndoState} from '../../../types/reducers' import EditSettings from './EditSettings' type Props = { + activeEntity: GtfsRoute, activePattern: Pattern, controlPoints: Array, editSettings: EditSettingsUndoState, @@ -41,7 +42,7 @@ type Props = { undoActiveTripPatternEdits: typeof tripPatternActions.undoActiveTripPatternEdits, updateActiveGtfsEntity: typeof activeActions.updateActiveGtfsEntity, updateEditSetting: typeof activeActions.updateEditSetting, - updatePatternGeometry: typeof mapActions.updatePatternGeometry, + updatePatternGeometry: typeof mapActions.updatePatternGeometry } export default class EditShapePanel extends Component { @@ -200,6 +201,7 @@ export default class EditShapePanel extends Component { render () { const { + activeEntity, activePattern, controlPoints, // FIXME use to describe which segment user is editing patternSegment, @@ -360,6 +362,7 @@ export default class EditShapePanel extends Component { diff --git a/lib/editor/reducers/settings.js b/lib/editor/reducers/settings.js index 92d726367..ed34377f8 100644 --- a/lib/editor/reducers/settings.js +++ b/lib/editor/reducers/settings.js @@ -21,6 +21,7 @@ export const defaultState = { distanceFromIntersection: 5, editGeometry: false, followStreets: true, + followRail: false, hideInactiveSegments: false, intersectionStep: 2, onMapClick: CLICK_OPTIONS[0], @@ -49,7 +50,8 @@ export const reducers = { ...defaultState, // Do not reset follow streets if exiting pattern editing. // TODO: Are there other edit settings that should not be overridden? - followStreets: state.followStreets + followStreets: state.followStreets, + followRail: state.followRail } }, 'SETTING_ACTIVE_GTFS_ENTITY' ( @@ -58,6 +60,7 @@ export const reducers = { ): EditSettingsState { // Default for no route type is true (most routes are buses/follow streets) let followStreets = true + const followRail = false const {activeEntity, component} = action.payload // Update value for follow streets when setting active route if (activeEntity && component === 'route') { @@ -70,7 +73,8 @@ export const reducers = { } return { ...defaultState, - followStreets + followStreets, + followRail } }, 'UPDATE_TEMP_PATTERN_GEOMETRY' ( diff --git a/lib/editor/util/index.js b/lib/editor/util/index.js index 4f9f4d29b..7066a891f 100644 --- a/lib/editor/util/index.js +++ b/lib/editor/util/index.js @@ -19,6 +19,11 @@ export const CLICK_OPTIONS: Array = [ 'ADD_STOPS_AT_INTERVAL', 'ADD_STOPS_AT_INTERSECTIONS' ] +export const SNAP_TO_OPTIONS = { + NONE: 'None', + STREET: 'Street', + RAIL: 'Rail' +} export const YEAR_FORMAT: string = 'YYYY-MM-DD' export const EXCEPTION_EXEMPLARS = { MONDAY: 0, diff --git a/lib/editor/util/map.js b/lib/editor/util/map.js index f11ae927a..5cd169b14 100644 --- a/lib/editor/util/map.js +++ b/lib/editor/util/map.js @@ -1,7 +1,7 @@ // @flow import ll from '@conveyal/lonlat' -import {divIcon} from 'leaflet' +import {Bounds, divIcon} from 'leaflet' import clone from 'lodash/cloneDeep' import fetch from 'isomorphic-fetch' import distance from '@turf/distance' @@ -44,11 +44,16 @@ type R5Response = { }> } -export const stopIsOutOfBounds = (stop: GtfsStop, bounds: any) => { - return stop.stop_lat > bounds.getNorth() || - stop.stop_lat < bounds.getSouth() || - stop.stop_lon > bounds.getEast() || - stop.stop_lon < bounds.getWest() +export const coordIsOutOfBounds = (coords: LatLng, bounds: Bounds) => { + if (!coords || !coords.lat || !coords.lng || !bounds) return true + + return coords.lat > bounds.getNorth() || + coords.lat < bounds.getSouth() || + coords.lng > bounds.getEast() || + coords.lng < bounds.getWest() +} +export const stopIsOutOfBounds = (stop: GtfsStop, bounds: Bounds) => { + return coordIsOutOfBounds({lat: stop.stop_lat, lng: stop.stop_lon}, bounds) } export const getStopIcon = ( @@ -264,6 +269,7 @@ export async function recalculateShape ({ dragId, editType, followStreets, + followRail = false, index, newPoint, patternCoordinates, @@ -274,6 +280,7 @@ export async function recalculateShape ({ defaultToStraightLine?: boolean, dragId?: null | string, editType: string, + followRail?: boolean, followStreets: boolean, index: number, newPoint?: LatLng, @@ -394,7 +401,8 @@ export async function recalculateShape ({ pointsToRoute, followStreets, defaultToStraightLine, - avoidMotorways + avoidMotorways, + followRail ) if (!newSegment || !newSegment.coordinates) { // If new segment calculation is unsuccessful, return null for coordinates and diff --git a/lib/scenario-editor/utils/valhalla.js b/lib/scenario-editor/utils/valhalla.js index c76a382c0..9f66b9c7c 100644 --- a/lib/scenario-editor/utils/valhalla.js +++ b/lib/scenario-editor/utils/valhalla.js @@ -1,15 +1,17 @@ // @flow +import {isEqual as coordinatesAreEqual} from '@conveyal/lonlat' import fetch from 'isomorphic-fetch' +import L from 'leaflet' import {decode as decodePolyline} from 'polyline' -import {isEqual as coordinatesAreEqual} from '@conveyal/lonlat' -import qs from 'qs' import lineString from 'turf-linestring' +import qs from 'qs' // This can be used for logging line strings to geojson.io URLs for easy // debugging. // import {logCoordsToGeojsonio} from '../../editor/util/debug' +import { coordIsOutOfBounds } from '../../editor/util/map' import type { Coordinates, LatLng @@ -53,6 +55,13 @@ type GraphHopperResponse = { paths: Array } +type GraphHopperAlternateServer = { + BBOX: Array, + KEY?: string, + ROUTING_TYPE: string, + URL?: string +} + /** * Convert GraphHopper routing JSON response to polyline. */ @@ -106,7 +115,8 @@ function handleGraphHopperRouting (path: Path, individualLegs: boolean = false): export async function polyline ( points: Array, individualLegs?: boolean = false, - avoidMotorways?: boolean = false + avoidMotorways?: boolean = false, + followRail?: boolean = false ): Promise { let json const geometry = [] @@ -126,7 +136,7 @@ export async function polyline ( const beginIndex = i + offset const endIndex = i + chunk + offset const chunkedPoints = points.slice(beginIndex, endIndex) - json = await routeWithGraphHopper(chunkedPoints, avoidMotorways) + json = await routeWithGraphHopper(chunkedPoints, avoidMotorways, followRail) const path = json && json.paths && json.paths[0] // Route between chunked list of points if (path) { @@ -150,19 +160,21 @@ export async function getSegment ( points: Coordinates, followRoad: boolean, defaultToStraightLine: boolean = true, - avoidMotorways: boolean = false + avoidMotorways: boolean = false, + followRail: boolean = false ): Promise { // Store geometry to be returned here. let geometry - if (followRoad) { - // if snapping to streets, use routing service. + if (followRoad || followRail) { + // if snapping to streets or rail, use routing service. const coordinates = await polyline( points.map(p => ({lng: p[0], lat: p[1]})), false, - avoidMotorways + avoidMotorways, + followRail ) if (!coordinates) { // If routing was unsuccessful, default to straight line (if desired by @@ -198,26 +210,65 @@ export async function getSegment ( * * Example URL: https://graphhopper.com/api/1/route?point=49.932707,11.588051&point=50.3404,11.64705&vehicle=car&debug=true&&type=json */ -export function routeWithGraphHopper (points: Array, avoidMotorways?: boolean): ?Promise { +export function routeWithGraphHopper (points: Array, avoidMotorways?: boolean, followRail?: boolean = false): ?Promise { if (points.length < 2) { console.warn('need at least two points to route with graphhopper', points) return null } - if (!process.env.GRAPH_HOPPER_KEY) { - throw new Error('GRAPH_HOPPER_KEY not set') - } + // Use custom url if it exists, otherwise default to the hosted service. - const GRAPH_HOPPER_URL = process.env.GRAPH_HOPPER_URL || 'https://graphhopper.com/api/1/' + let graphHopperUrl = process.env.GRAPH_HOPPER_URL || 'https://graphhopper.com/api/1/' + let graphHopperKey = process.env.GRAPH_HOPPER_KEY + + if (process.env.GRAPH_HOPPER_ALTERNATES) { + // $FlowFixMe This is a bit of a hack and now how env variables are supposed to work, but the yaml loader supports it. + const alternates: Array = process.env.GRAPH_HOPPER_ALTERNATES + alternates.forEach(alternative => { + const {BBOX, ROUTING_TYPE} = alternative + if (BBOX.length !== 4) { + console.warn('Invalid BBOX for GRAPH_HOPPER_ALTERNATIVE') + return + } + if (!alternative.URL && !alternative.KEY) { + console.warn('No URL or key provided for alternative graphhopper server.') + return + } + + const bounds = L.latLngBounds( + [alternative.BBOX[1], alternative.BBOX[0]], + [alternative.BBOX[3], alternative.BBOX[2]] + ) + + // Check if points are within bounds and routing type matches + if ( + points.every(point => !coordIsOutOfBounds(point, bounds)) && + ((ROUTING_TYPE === 'RAIL' && followRail) || (ROUTING_TYPE === 'STREET' && !followRail)) + ) { + if (alternative.URL) { + graphHopperUrl = alternative.URL + } + if (alternative.KEY) { + graphHopperKey = alternative.KEY + } + } + }) + } + const params = { - key: process.env.GRAPH_HOPPER_KEY, + key: graphHopperKey, vehicle: 'car', debug: true, - type: 'json' + type: 'json', + profile: '' + } + if (followRail) { + delete params.vehicle // vehicle is not supported in current open rail router version + params.profile = 'all_tracks' // this can be changed to return better results if needed } const locations = points.map(p => (`point=${p.lat},${p.lng}`)).join('&') // Avoiding motorways requires a POST request with a formatted body - const graphHopperRequest = avoidMotorways - ? fetch(`${GRAPH_HOPPER_URL}route?key=${params.key}`, + const graphHopperRequest = avoidMotorways && !followRail + ? fetch(`${graphHopperUrl}route${graphHopperKey ? `?key=${graphHopperKey}` : ''}`, { body: JSON.stringify({ 'ch.disable': true, @@ -237,7 +288,7 @@ export function routeWithGraphHopper (points: Array, avoidMotorways?: bo }, method: 'POST' }) - : fetch(`${GRAPH_HOPPER_URL}route?${locations}&${qs.stringify(params)}`) + : fetch(`${graphHopperUrl}route?${locations}&${qs.stringify(params)}`) return graphHopperRequest.then(res => res.json()) } diff --git a/lib/types/reducers.js b/lib/types/reducers.js index 010b8a233..6e416715f 100644 --- a/lib/types/reducers.js +++ b/lib/types/reducers.js @@ -214,6 +214,7 @@ export type EditSettingsState = { currentDragId: null | string, distanceFromIntersection: number, editGeometry: boolean, + followRail: boolean, followStreets: boolean, hideInactiveSegments: boolean, hideStopHandles: boolean,