diff --git a/lib/common/components/Loading.js b/lib/common/components/Loading.js index 0eae019f5..f7ffd80c4 100644 --- a/lib/common/components/Loading.js +++ b/lib/common/components/Loading.js @@ -4,10 +4,12 @@ import Icon from '@conveyal/woonerf/components/icon' import React, {Component} from 'react' import { Row, Col } from 'react-bootstrap' +import type {Style} from '../../types' + type Props = { inline?: boolean, small?: boolean, - style?: {[string]: string | number} + style?: Style } export default class Loading extends Component { diff --git a/lib/editor/components/MinuteSecondInput.js b/lib/editor/components/MinuteSecondInput.js index 29a0d92c3..cd2133a35 100644 --- a/lib/editor/components/MinuteSecondInput.js +++ b/lib/editor/components/MinuteSecondInput.js @@ -8,11 +8,13 @@ import { convertMMSSStringToSeconds } from '../../common/util/date-time' +import type {Style} from '../../types' + type Props = { disabled?: boolean, onChange: number => void, seconds: number, - style?: {[string]: string | number} + style?: Style } type State = { diff --git a/lib/editor/components/VirtualizedEntitySelect.js b/lib/editor/components/VirtualizedEntitySelect.js index ffc388a0b..f72a9558b 100644 --- a/lib/editor/components/VirtualizedEntitySelect.js +++ b/lib/editor/components/VirtualizedEntitySelect.js @@ -5,7 +5,7 @@ import VirtualizedSelect from 'react-virtualized-select' import {getEntityName} from '../util/gtfs' -import type {Entity} from '../../types' +import type {Entity, Style} from '../../types' export type EntityOption = { entity: Entity, @@ -20,7 +20,7 @@ type Props = { entityKey: string, onChange: any => void, optionRenderer?: Function, - style?: {[string]: number | string}, + style?: Style, value?: any } diff --git a/lib/editor/components/map/AddableStop.js b/lib/editor/components/map/AddableStop.js index f12c927e2..3b7311689 100644 --- a/lib/editor/components/map/AddableStop.js +++ b/lib/editor/components/map/AddableStop.js @@ -1,12 +1,11 @@ // @flow -import Icon from '@conveyal/woonerf/components/icon' import { divIcon } from 'leaflet' import React, {Component} from 'react' -import {Button, Dropdown, MenuItem} from 'react-bootstrap' import {Marker, Popup} from 'react-leaflet' import * as stopStrategiesActions from '../../actions/map/stopStrategies' +import AddPatternStopDropdown from '../pattern/AddPatternStopDropdown' import type {GtfsStop, Pattern} from '../../../types' @@ -28,6 +27,7 @@ export default class AddableStop extends Component { render () { const { activePattern, + addStopToPattern, stop } = this.props const color = 'blue' @@ -40,7 +40,6 @@ export default class AddableStop extends Component { className: '', iconSize: [24, 24] }) - // TODO: Refactor to share code with PatternStopButtons return ( {
{stopName}
- - - - - - Add to end (default) - - {activePattern.patternStops && activePattern.patternStops.map((stop, i) => { - const index = activePattern.patternStops.length - i - return ( - - {index === 1 ? 'Add to beginning' : `Insert as stop #${index}`} - - ) - })} - - +
diff --git a/lib/editor/components/map/pattern-debug-lines.js b/lib/editor/components/map/pattern-debug-lines.js index 99da76d6c..db0ee930d 100644 --- a/lib/editor/components/map/pattern-debug-lines.js +++ b/lib/editor/components/map/pattern-debug-lines.js @@ -5,8 +5,8 @@ import {Polyline} from 'react-leaflet' import lineDistance from 'turf-line-distance' import lineString from 'turf-linestring' -import {POINT_TYPE} from '../../constants' -import {isValidPoint} from '../../util/map' +import {PATTERN_TO_STOP_DISTANCE_THRESHOLD_METERS} from '../../constants' +import {getStopControlPoints} from '../../util/map' import type {ControlPoint, GtfsStop, Pattern} from '../../../types' import type {EditSettingsState} from '../../../types/reducers' @@ -20,8 +20,6 @@ type Props = { stops: Array } -const DISTANCE_THRESHOLD = 50 - /** * This react-leaflet component draws connecting lines between a pattern * geometry's anchor points (that are associated with stops) and their @@ -44,33 +42,33 @@ export default class PatternDebugLines extends PureComponent { // i.e., whether the line should be rendered. .map((cp, index) => ({...cp, cpIndex: index})) // Filter out the user-added anchors - .filter(cp => cp.pointType === POINT_TYPE.STOP) + .filter(getStopControlPoints) // The remaining number should match the number of stops .map((cp, index) => { const {cpIndex, point, stopId} = cp - if (!isValidPoint(point)) { - return null - } + // If hiding inactive segments (and this control point is not along + // a visible segment), do not show debug line. if (editSettings.hideInactiveSegments && (cpIndex > patternSegment + 1 || cpIndex < patternSegment - 1)) { return null } const patternStopIsActive = patternStop.index === index // Do not render if some other pattern stop is active - // $FlowFixMe - if ((patternStop.index || patternStop.index === 0) && !patternStopIsActive) { + if (typeof patternStop.index === 'number' && !patternStopIsActive) { return null } const {coordinates: cpCoord} = point.geometry + // Find stop entity for control point. const stop = stops.find(s => s.stop_id === stopId) if (!stop) { - // console.warn(`Could not find stop for pattern stop index=${index} patternStop#stopId=${stopId}`) + // If no stop entity found, do not attempt to draw a line to the + // missing stop. return null } const coordinates = [[cpCoord[1], cpCoord[0]], [stop.stop_lat, stop.stop_lon]] const distance: number = lineDistance(lineString(coordinates), 'meters') - const distanceGreaterThanThreshold = distance > DISTANCE_THRESHOLD + const distanceGreaterThanThreshold = distance > PATTERN_TO_STOP_DISTANCE_THRESHOLD_METERS if (distanceGreaterThanThreshold) { - console.warn(`Distance from pattern stop index=${index} to projected point is greater than ${DISTANCE_THRESHOLD} (${distance}).`) + console.warn(`Distance from pattern stop index=${index} to projected point is greater than ${PATTERN_TO_STOP_DISTANCE_THRESHOLD_METERS} (${distance}).`) } return ( { + _addStop = (index?: number) => { + const {activePattern, addStopToPattern, stop} = this.props + addStopToPattern(activePattern, stop, index) + } + + _matchesStopAtIndex = (index: number) => { + const {activePattern, stop} = this.props + const patternStopAtIndex = activePattern.patternStops[index] + return patternStopAtIndex && patternStopAtIndex.stopId === stop.stop_id + } + + _onAddToEnd = () => this._addStop() + + _onSelectStop = (key: number) => this._addStop(key) + + render () { + const {activePattern, index, label, size, style} = this.props + const {patternStops} = activePattern + const lastIndex = patternStops.length - 1 + // Check that first/last stop is not already set to this stop. + let addToEndDisabled = this._matchesStopAtIndex(lastIndex) + let addToBeginningDisabled = this._matchesStopAtIndex(0) + // Also, disable end/beginning if the current pattern stop being viewed + // occupies one of these positions. + if (typeof index === 'number') { + addToEndDisabled = addToEndDisabled || index >= lastIndex + addToBeginningDisabled = addToBeginningDisabled || index === 0 + } + return ( + + + + + + Add to end (default) + + {activePattern.patternStops && activePattern.patternStops.map((s, i) => { + // addIndex is in "reverse" order + const addIndex = activePattern.patternStops.length - i + let disableAdjacent = false + // If showing for current pattern stop, do not allow adding as an + // adjacent stop. + if (typeof index === 'number') { + disableAdjacent = (index >= addIndex - 2 && index < addIndex) + } + // Disable adding stop to current position or directly before/after + // current position + const addAtIndexDisabled = disableAdjacent || + this._matchesStopAtIndex(addIndex - 2) || + this._matchesStopAtIndex(addIndex - 1) + // Skip MenuItem index is the same as the pattern stop index + if (index === addIndex - 1 || addIndex === 1) { + return null + } + return ( + + {`Insert as stop #${addIndex}`} + + ) + })} + + Add to beginning + + + + ) + } +} diff --git a/lib/editor/components/pattern/EditShapePanel.js b/lib/editor/components/pattern/EditShapePanel.js index 2b67a033d..354edb338 100644 --- a/lib/editor/components/pattern/EditShapePanel.js +++ b/lib/editor/components/pattern/EditShapePanel.js @@ -2,13 +2,15 @@ import Icon from '@conveyal/woonerf/components/icon' import React, {Component} from 'react' -import {Button, ButtonGroup, ButtonToolbar, OverlayTrigger, Tooltip} from 'react-bootstrap' +import {Alert, Button, ButtonGroup, ButtonToolbar, OverlayTrigger, Tooltip} from 'react-bootstrap' import ll from '@conveyal/lonlat' import numeral from 'numeral' +import lineDistance from 'turf-line-distance' +import lineString from 'turf-linestring' import * as activeActions from '../../actions/active' import * as mapActions from '../../actions/map' -import {ARROW_MAGENTA} from '../../constants' +import {ARROW_MAGENTA, PATTERN_TO_STOP_DISTANCE_THRESHOLD_METERS} from '../../constants' import * as tripPatternActions from '../../actions/tripPattern' import OptionButton from '../../../common/components/OptionButton' import EditSettings from './EditSettings' @@ -17,6 +19,7 @@ import {polyline as getPolyline} from '../../../scenario-editor/utils/valhalla' import { controlPointsFromSegments, generateControlPointsFromPatternStops, + getStopControlPoints, getPatternDistance } from '../../util/map' @@ -139,6 +142,42 @@ export default class EditShapePanel extends Component { }) } + /** + * Checks the control points for stop control points that are located too far + * from the actual stop location. This is used to give instructions to the + * user on resolving the issue. + */ + _getPatternStopsWithShapeIssues = () => { + const {controlPoints, stops} = this.props + return controlPoints + .filter(getStopControlPoints) + .map((controlPoint, index) => { + const {point, stopId} = controlPoint + let exceedsThreshold = false + const {coordinates: cpCoord} = point.geometry + // Find stop entity for control point. + const stop = stops.find(s => s.stop_id === stopId) + if (!stop) { + // If no stop entity found, do not attempt to draw a line to the + // missing stop. + return {controlPoint, index, stop: null, distance: 0, exceedsThreshold} + } + const coordinates = [[cpCoord[1], cpCoord[0]], [stop.stop_lat, stop.stop_lon]] + const distance: number = lineDistance(lineString(coordinates), 'meters') + exceedsThreshold = distance > PATTERN_TO_STOP_DISTANCE_THRESHOLD_METERS + return { + controlPoint, + distance, + exceedsThreshold, + index, + stop + } + }) + // TODO: This can be removed if at some point we need to show stops where + // the distance threshold is not exceeded. + .filter(item => item.exceedsThreshold) + } + _beginEditing = () => { const {togglePatternEditing} = this.props togglePatternEditing() @@ -188,6 +227,7 @@ export default class EditShapePanel extends Component { const nextSegment = (!patternSegment && patternSegment !== 0) ? 0 : patternSegment + 1 + const patternStopsWithShapeIssues = this._getPatternStopsWithShapeIssues() return (

@@ -217,6 +257,54 @@ export default class EditShapePanel extends Component { }

+ {patternStopsWithShapeIssues.length > 0 + ? +

Pattern stop snapping issue

+
    + {patternStopsWithShapeIssues + .map(item => { + const {distance, index, stop} = item + if (!stop) return null + const roundedDist = Math.round(distance * 100) / 100 + return ( +
  • + #{index + 1} {stop.stop_name}{' '} + + {roundedDist} m + +
  • + ) + }) + } +
+

+ The stop(s) listed above are located + too far (max = {PATTERN_TO_STOP_DISTANCE_THRESHOLD_METERS}{' '} + meters) from the pattern shape. +

+

+ This can be resolved by: +

    +
  1. + moving the stop itself closer to the street's edge; +
  2. +
  3. + changing where the stop is "snapped" to the shape: click{' '} + Edit pattern geometry, uncheck{' '} + Hide stop handles, and move the stop handle + closer to the stop. Checking Hide inactive segments{' '} + can help isolate the problematic stop handle; or +
  4. +
  5. + regenerating the shape from existing stops: click{' '} + From stops. +
  6. +
+

+
+ : null + } + {editSettings.editGeometry ?
diff --git a/lib/editor/components/pattern/PatternStopButtons.js b/lib/editor/components/pattern/PatternStopButtons.js index 293fcf14f..3aa939aa2 100644 --- a/lib/editor/components/pattern/PatternStopButtons.js +++ b/lib/editor/components/pattern/PatternStopButtons.js @@ -2,13 +2,14 @@ import Icon from '@conveyal/woonerf/components/icon' import React, {Component} from 'react' -import { Button, Dropdown, OverlayTrigger, Tooltip, ButtonGroup, MenuItem } from 'react-bootstrap' +import { Button, OverlayTrigger, Tooltip, ButtonGroup } from 'react-bootstrap' import * as activeActions from '../../actions/active' import * as stopStrategiesActions from '../../actions/map/stopStrategies' import * as tripPatternActions from '../../actions/tripPattern' +import AddPatternStopDropdown from './AddPatternStopDropdown' -import type {Feed, GtfsStop, Pattern, PatternStop} from '../../../types' +import type {Feed, GtfsStop, Pattern, PatternStop, Style} from '../../../types' type Props = { activePattern: Pattern, @@ -23,7 +24,7 @@ type Props = { setActiveStop: typeof tripPatternActions.setActiveStop, size: string, stop: GtfsStop, - style?: {[string]: number | string}, + style?: Style, updatePatternStops: typeof tripPatternActions.updatePatternStops } @@ -44,7 +45,13 @@ export default class PatternStopButtons extends Component { _onClickRemove = () => { const {activePattern, index, removeStopFromPattern, stop} = this.props - removeStopFromPattern(activePattern, stop, index) + if ( + window.confirm( + `Are you sure you would like to remove ${stop.stop_name} (${stop.stop_id}) as pattern stop #${index + 1}?` + ) + ) { + removeStopFromPattern(activePattern, stop, index) + } } _onClickSave = () => this.props.saveActiveGtfsEntity('trippattern') @@ -55,11 +62,16 @@ export default class PatternStopButtons extends Component { } render () { - const {stop, index, activePattern, patternEdited, style, size} = this.props - const {patternStops} = activePattern - const lastIndex = patternStops.length - 1 - const addToEndDisabled = index >= lastIndex || patternStops[lastIndex].stopId === stop.id - const addToBeginningDisabled = index === 0 || patternStops[0].stopId === stop.id + const { + activePattern, + addStopToPattern, + index, + patternEdited, + patternStop, + size, + stop, + style + } = this.props return ( { onClick={this._onClickSave}> - Edit stop}> + Edit stop} + > - Remove from pattern}> + Remove stop from pattern + } + > - - - - - - Add to end (default) - - {activePattern.patternStops && activePattern.patternStops.map((s, i) => { - // addIndex is in "reverse" order - const addIndex = activePattern.patternStops.length - i - const addAtIndexDisabled = (index >= addIndex - 2 && index < addIndex) || - (patternStops[addIndex - 2] && patternStops[addIndex - 2].stopId === stop.id) || - (patternStops[addIndex - 1] && patternStops[addIndex - 1].stopId === stop.id) - // (patternStops[addIndex + 1] && patternStops[addIndex + 1].stopId === stop.id) - // skip MenuItem index is the same as the pattern stop index - if (index === addIndex - 1 || addIndex === 1) { - return null - } - // disable adding stop to current position or directly before/after current position - return ( - - {`Insert as stop #${addIndex}`} - - ) - })} - - Add to beginning - - - + ) } diff --git a/lib/editor/components/pattern/PatternStopsPanel.js b/lib/editor/components/pattern/PatternStopsPanel.js index 320fd8f4d..1192ecda8 100644 --- a/lib/editor/components/pattern/PatternStopsPanel.js +++ b/lib/editor/components/pattern/PatternStopsPanel.js @@ -8,9 +8,11 @@ import * as activeActions from '../../actions/active' import * as mapActions from '../../actions/map' import * as stopStrategiesActions from '../../actions/map/stopStrategies' import * as tripPatternActions from '../../actions/tripPattern' +import AddPatternStopDropdown from './AddPatternStopDropdown' import NormalizeStopTimesModal from './NormalizeStopTimesModal' import PatternStopContainer from './PatternStopContainer' import VirtualizedEntitySelect from '../VirtualizedEntitySelect' +import {getEntityBounds, getEntityName} from '../../util/gtfs' import type {Pattern, GtfsStop, Feed, ControlPoint, Coordinates} from '../../../types' import type {EditorStatus, EditSettingsUndoState, MapState} from '../../../types/reducers' @@ -34,19 +36,26 @@ type Props = { stops: Array, updateActiveGtfsEntity: typeof activeActions.updateActiveGtfsEntity, updateEditSetting: typeof activeActions.updateEditSetting, + updateMapSetting: typeof mapActions.updateMapSetting, updatePatternGeometry: typeof mapActions.updatePatternGeometry, updatePatternStops: typeof tripPatternActions.updatePatternStops } -type State = { showNormalizeStopTimesModal: boolean } +type State = { + patternStopCandidate: ?GtfsStop, + showNormalizeStopTimesModal: boolean + } export default class PatternStopsPanel extends Component { state = { - showNormalizeStopTimesModal: false + showNormalizeStopTimesModal: false, + patternStopCandidate: null } _toggleAddStopsMode = () => { const {editSettings, updateEditSetting} = this.props + // Clear stop candidate (if defined). + this.setState({patternStopCandidate: null}) updateEditSetting({ setting: 'addStops', value: !editSettings.present.addStops @@ -58,12 +67,16 @@ export default class PatternStopsPanel extends Component { _onCloseModal = () => this.setState({showNormalizeStopTimesModal: false}) - _addStopFromSelect = (input: any) => { + _selectStop = (input: any) => { if (!input) { + // Clear stop candidate if input is cleared. + this.setState({patternStopCandidate: null}) return } const stop: GtfsStop = input.entity - return this.props.addStopToPattern(this.props.activePattern, stop) + // Zoom to stop candidate + this.props.updateMapSetting({bounds: getEntityBounds(stop), target: +stop.id}) + this.setState({patternStopCandidate: stop}) } render () { @@ -88,6 +101,7 @@ export default class PatternStopsPanel extends Component { updatePatternStops } = this.props const {addStops} = editSettings.present + const {patternStopCandidate} = this.state const patternHasStops = activePattern.patternStops && activePattern.patternStops.length > 0 return ( @@ -174,8 +188,24 @@ export default class PatternStopsPanel extends Component { + onChange={this._selectStop} /> + {patternStopCandidate + ?
+

{getEntityName(patternStopCandidate)}

+ +
+ : null + }