diff --git a/app/component/itinerary/navigator/NaviCardContainer.js b/app/component/itinerary/navigator/NaviCardContainer.js index 89366f9817..b680a0c72c 100644 --- a/app/component/itinerary/navigator/NaviCardContainer.js +++ b/app/component/itinerary/navigator/NaviCardContainer.js @@ -12,9 +12,9 @@ import { getItineraryAlerts, getTransitLegState, LEGTYPE, + DESTINATION_RADIUS, } from './NaviUtils'; -const DESTINATION_RADIUS = 20; // meters const TIME_AT_DESTINATION = 3; // * 10 seconds const TOPBAR_PADDING = 8; // pixels @@ -82,7 +82,16 @@ function NaviCardContainer( // Alerts for NaviStack addMessages( incomingMessages, - getItineraryAlerts(legs, intl, messages, match.params, router), + getItineraryAlerts( + legs, + time, + position, + origin, + intl, + messages, + match.params, + router, + ), ); if (currentLeg) { @@ -107,16 +116,11 @@ function NaviCardContainer( : activeMessages; // handle messages that are updated. - const updatedMessages = previousValidMessages.map(msg => { - const incoming = incomingMessages.get(msg.id); - if (incoming) { - incomingMessages.delete(msg.id); - return incoming; - } - return msg; - }); + const keptMessages = previousValidMessages.filter( + msg => !!incomingMessages.get(msg.id), + ); const newMessages = Array.from(incomingMessages.values()); - setActiveMessages([...updatedMessages, ...newMessages]); + setActiveMessages([...keptMessages, ...newMessages]); setMessages(new Map([...messages, ...incomingMessages])); } @@ -210,7 +214,8 @@ NaviCardContainer.propTypes = { lat: PropTypes.number, lon: PropTypes.number, }), - mapLayerRef: PropTypes.func.isRequired, + mapLayerRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]) + .isRequired, origin: PropTypes.shape({ x: PropTypes.number.isRequired, y: PropTypes.number.isRequired, diff --git a/app/component/itinerary/navigator/NaviContainer.js b/app/component/itinerary/navigator/NaviContainer.js index e4a4092a53..505916eb41 100644 --- a/app/component/itinerary/navigator/NaviContainer.js +++ b/app/component/itinerary/navigator/NaviContainer.js @@ -9,8 +9,8 @@ import NaviBottom from './NaviBottom'; import NaviCardContainer from './NaviCardContainer'; import { useRealtimeLegs } from './hooks/useRealtimeLegs'; import NavigatorOutroModal from './navigatoroutro/NavigatorOutroModal'; +import { DESTINATION_RADIUS } from './NaviUtils'; -const DESTINATION_RADIUS = 20; // meters const ADDITIONAL_ARRIVAL_TIME = 60000; // 60 seconds in ms function NaviContainer( @@ -27,7 +27,10 @@ function NaviContainer( ) { const [isPositioningAllowed, setPositioningAllowed] = useState(false); - const position = getStore('PositionStore').getLocationState(); + let position = getStore('PositionStore').getLocationState(); + if (!position.hasLocation) { + position = null; + } const { realTimeLegs, @@ -41,7 +44,7 @@ function NaviContainer( } = useRealtimeLegs(relayEnvironment, legs); useEffect(() => { - if (position.hasLocation) { + if (position) { mapRef?.enableMapTracking(); setPositioningAllowed(true); } else { @@ -107,7 +110,8 @@ NaviContainer.propTypes = { isNavigatorIntroDismissed: PropTypes.bool, // eslint-disable-next-line mapRef: PropTypes.object, - mapLayerRef: PropTypes.func.isRequired, + mapLayerRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]) + .isRequired, }; NaviContainer.contextTypes = { diff --git a/app/component/itinerary/navigator/NaviInstructions.js b/app/component/itinerary/navigator/NaviInstructions.js index 0786f8bbbb..7af09f1909 100644 --- a/app/component/itinerary/navigator/NaviInstructions.js +++ b/app/component/itinerary/navigator/NaviInstructions.js @@ -2,11 +2,11 @@ import React from 'react'; import { FormattedMessage, intlShape } from 'react-intl'; import PropTypes from 'prop-types'; import cx from 'classnames'; -import { GeodeticToEnu, displayDistance } from '../../../util/geo-utils'; +import { displayDistance } from '../../../util/geo-utils'; import { legShape, configShape } from '../../../util/shapes'; import { legDestination, legTimeStr, legTime } from '../../../util/legUtils'; import RouteNumber from '../../RouteNumber'; -import { LEGTYPE, getLocalizedMode, pathProgress } from './NaviUtils'; +import { LEGTYPE, getLocalizedMode, getRemainingTraversal } from './NaviUtils'; import { durationToString } from '../../../util/timeUtils'; export default function NaviInstructions( @@ -18,20 +18,12 @@ export default function NaviInstructions( ); if (legType === LEGTYPE.MOVE) { - let remainingTraversal; - - if (position?.lat && position?.lon) { - // TODO: maybe apply only when distance is close enough to the path - const posXY = GeodeticToEnu(position.lat, position.lon, origin); - const { traversed } = pathProgress(posXY, leg.geometry); - remainingTraversal = 1.0 - traversed; - } else { - // estimate from elapsed time - remainingTraversal = Math.max( - (legTime(leg.end) - time) / (leg.duration * 1000), - 0, - ); - } + const remainingTraversal = getRemainingTraversal( + leg, + position, + origin, + time, + ); const duration = leg.duration * remainingTraversal; const distance = leg.distance * remainingTraversal; @@ -43,7 +35,7 @@ export default function NaviInstructions( {legDestination(intl, leg, null, nextLeg)} -
+
{displayDistance(distance, config, intl.formatNumber)} ( {durationToString(duration * 1000)})
diff --git a/app/component/itinerary/navigator/NaviUtils.js b/app/component/itinerary/navigator/NaviUtils.js index 16483126ff..a8d970074f 100644 --- a/app/component/itinerary/navigator/NaviUtils.js +++ b/app/component/itinerary/navigator/NaviUtils.js @@ -1,5 +1,7 @@ import React from 'react'; +import distance from '@digitransit-search-util/digitransit-search-util-distance'; import { FormattedMessage } from 'react-intl'; +import { GeodeticToEnu } from '../../../util/geo-utils'; import { legTime } from '../../../util/legUtils'; import { timeStr } from '../../../util/timeUtils'; import { getFaresFromLegs } from '../../../util/fareUtils'; @@ -8,7 +10,93 @@ import { getItineraryPagePath } from '../../../util/path'; const TRANSFER_SLACK = 60000; const DISPLAY_MESSAGE_THRESHOLD = 120 * 1000; // 2 minutes -function findTransferProblem(legs) { + +export const DESTINATION_RADIUS = 20; // meters + +function dist(p1, p2) { + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + return Math.sqrt(dx * dx + dy * dy); +} + +function vSub(p1, p2) { + const dx = p1.x - p2.x; + const dy = p1.y - p2.y; + return { dx, dy }; +} + +// compute how big part of a path has been traversed +// returns position's projection to path, distance from path +// and the ratio traversed/full length +export function pathProgress(pos, geom) { + const lengths = []; + + let p1 = geom[0]; + let dst = dist(pos, p1); + let minI = 0; + let minF = 0; + let totalLength = 0; + + for (let i = 0; i < geom.length - 1; i++) { + const p2 = geom[i + 1]; + const { dx, dy } = vSub(p2, p1); + const d = Math.sqrt(dx * dx + dy * dy); + lengths.push(d); + totalLength += d; + + if (d > 0.001) { + // interval distance in meters, safety check + const dlt = vSub(pos, p1); + const dp = dlt.dx * dx + dlt.dy * dy; // dot prod + + if (dp > 0) { + let f; + let cDist; + if (dp > 1) { + cDist = dist(p2, pos); + f = 1; + } else { + f = dp / d; // normalize + cDist = Math.sqrt(dlt.x * dlt.x + dlt.y * dlt.y - f * f); // pythag. + } + if (cDist < dst) { + dst = cDist; + minI = i; + minF = f; + } + } + } + p1 = p2; + } + + let traversed = minF * lengths[minI]; // last partial segment + for (let i = 0; i < minI; i++) { + traversed += lengths[i]; + } + traversed /= totalLength; + const { dx, dy } = vSub(geom[minI + 1], geom[minI]); + const projected = { + x: geom[minI].x + minF * dx, + y: geom[minI].y + minF * dy, + }; + + return { projected, distance: dst, traversed }; +} + +export function getRemainingTraversal(leg, pos, origin, time) { + if (pos) { + // TODO: maybe apply only when distance is close enough to the path + const posXY = GeodeticToEnu(pos.lat, pos.lon, origin); + const { traversed } = pathProgress(posXY, leg.geometry); + return 1.0 - traversed; + } + // estimate from elapsed time + return Math.max((legTime(leg.end) - time) / (leg.duration * 1000), 0); +} + +function findTransferProblems(legs, time, position, origin) { + const problems = []; + for (let i = 1; i < legs.length - 1; i++) { const prev = legs[i - 1]; const leg = legs[i]; @@ -16,8 +104,14 @@ function findTransferProblem(legs) { if (prev.transitLeg && leg.transitLeg && !leg.interlineWithPreviousLeg) { // transfer at a stop - if (legTime(leg.start) - legTime(prev.end) < TRANSFER_SLACK) { - return [prev, leg]; + const start = legTime(leg.start); + const end = legTime(prev.end); + if (start > time && start - end < TRANSFER_SLACK) { + problems.push({ + severity: start > end ? 'ALERT' : 'WARNING', + fromLeg: prev, + toLeg: leg, + }); } } @@ -25,14 +119,45 @@ function findTransferProblem(legs) { // transfer with some walking const t1 = legTime(prev.end); const t2 = legTime(next.start); - const transferDuration = legTime(leg.end) - legTime(leg.start); - const slack = t2 - t1 - transferDuration; - if (slack < TRANSFER_SLACK) { - return [prev, next]; + if (t2 > time) { + // transfer is not over yet + if (t1 > t2) { + // certain failure, next transit departs before previous arrives + problems.push({ + severity: 'ALERT', + fromLeg: prev, + toLeg: next, + }); + } else { + const transferDuration = legTime(leg.end) - legTime(leg.start); + // check if user is already at the next departure stop + const atStop = + position && distance(position, leg.to) <= DESTINATION_RADIUS; + const slack = t2 - t1 - transferDuration; + if (!atStop && slack < TRANSFER_SLACK) { + // original transfer not possible + let severity = 'WARNING'; + const toGo = getRemainingTraversal(leg, position, origin, time); + const timeLeft = (t2 - time) / 1000; + if (toGo > 0 && timeLeft > 0) { + const originalSpeed = leg.distance / leg.duration; + const newSpeed = (toGo * leg.distance) / timeLeft; + if (newSpeed > 2 * originalSpeed) { + // double speed compared to user's routing preference + severity = 'ALERT'; + } + } + problems.push({ + severity, + fromLeg: prev, + toLeg: next, + }); + } + } } } } - return null; + return problems; } export const getLocalizedMode = (mode, intl) => { return intl.formatMessage({ @@ -144,8 +269,19 @@ export const getTransitLegState = (leg, intl, messages, time) => { return state; }; -export const getItineraryAlerts = (legs, intl, messages, location, router) => { - const canceled = legs.filter(leg => leg.realtimeState === 'CANCELED'); +export const getItineraryAlerts = ( + legs, + time, + position, + origin, + intl, + messages, + location, + router, +) => { + const canceled = legs.filter( + leg => leg.realtimeState === 'CANCELED' && legTime(leg.start) > time, + ); let content; const alerts = legs.flatMap(leg => { return leg.alerts @@ -178,7 +314,6 @@ export const getItineraryAlerts = (legs, intl, messages, location, router) => { id: alert.id, })); }); - const transferProblem = findTransferProblem(legs); const abortTrip = ; const withShowRoutesBtn = children => (
@@ -228,26 +363,33 @@ export const getItineraryAlerts = (legs, intl, messages, location, router) => { }); } - if (transferProblem !== null) { - const transferId = `transfer-${transferProblem[0].legId}-${transferProblem[1].legId}}`; - if (!messages.get(transferId)) { + const transferProblems = findTransferProblems(legs, time, position, origin); + if (transferProblems.length) { + let prob = transferProblems.find(p => p.severity === 'ALERT'); + if (!prob) { + // just take first + [prob] = transferProblems; + } + const transferId = `transfer-${prob.fromLeg.legId}-${prob.toLeg.legId}}`; + const alert = messages.get(transferId); + if (!alert || alert.severity !== prob.severity) { content = withShowRoutesBtn(
{abortTrip}
, ); alerts.push({ - severity: 'ALERT', + severity: prob.severity, content, id: transferId, - hideClose: true, + hideClose: prob.severity === 'ALERT', }); } } @@ -345,73 +487,3 @@ export const LEGTYPE = { PENDING: 'PENDING', END: 'END', }; - -function dist(p1, p2) { - const dx = p2.x - p1.x; - const dy = p2.y - p1.y; - return Math.sqrt(dx * dx + dy * dy); -} - -function vSub(p1, p2) { - const dx = p1.x - p2.x; - const dy = p1.y - p2.y; - return { dx, dy }; -} - -// compute how big part of a path has been traversed -// returns position's projection to path, distance from path -// and the ratio traversed/full length -export function pathProgress(pos, geom) { - const lengths = []; - - let p1 = geom[0]; - let distance = dist(pos, p1); - let minI = 0; - let minF = 0; - let totalLength = 0; - - for (let i = 0; i < geom.length - 1; i++) { - const p2 = geom[i + 1]; - const { dx, dy } = vSub(p2, p1); - const d = Math.sqrt(dx * dx + dy * dy); - lengths.push(d); - totalLength += d; - - if (d > 0.001) { - // interval distance in meters, safety check - const dlt = vSub(pos, p1); - const dp = dlt.dx * dx + dlt.dy * dy; // dot prod - - if (dp > 0) { - let f; - let cDist; - if (dp > 1) { - cDist = dist(p2, pos); - f = 1; - } else { - f = dp / d; // normalize - cDist = Math.sqrt(dlt.x * dlt.x + dlt.y * dlt.y - f * f); // pythag. - } - if (cDist < distance) { - distance = cDist; - minI = i; - minF = f; - } - } - } - p1 = p2; - } - - let traversed = minF * lengths[minI]; // last partial segment - for (let i = 0; i < minI; i++) { - traversed += lengths[i]; - } - traversed /= totalLength; - const { dx, dy } = vSub(geom[minI + 1], geom[minI]); - const projected = { - x: geom[minI].x + minF * dx, - y: geom[minI].y + minF * dy, - }; - - return { projected, distance, traversed }; -}