diff --git a/__tests__/components/viewers/__snapshots__/stop-viewer.js.snap b/__tests__/components/viewers/__snapshots__/stop-viewer.js.snap index 2a519e0a3..e93d8f57d 100644 --- a/__tests__/components/viewers/__snapshots__/stop-viewer.js.snap +++ b/__tests__/components/viewers/__snapshots__/stop-viewer.js.snap @@ -1437,7 +1437,7 @@ exports[`components > viewers > stop viewer should render countdown times after title="20" > viewers > stop viewer should render countdown times for st title="20" > viewers > stop viewer should render times after midnight w title="20" > viewers > stop viewer should render with OTP transit index title="36" > viewers > stop viewer should render with TriMet transit in title="20" > + {/* The default overlays */} diff --git a/lib/components/map/itinerary-summary-overlay.tsx b/lib/components/map/itinerary-summary-overlay.tsx new file mode 100644 index 000000000..632ffd7ee --- /dev/null +++ b/lib/components/map/itinerary-summary-overlay.tsx @@ -0,0 +1,241 @@ +import { connect } from 'react-redux' +import { Feature, lineString, LineString, Position } from '@turf/helpers' +import { Itinerary, Location } from '@opentripplanner/types' +import { Marker } from 'react-map-gl' +import centroid from '@turf/centroid' +import distance from '@turf/distance' +import polyline from '@mapbox/polyline' +import React, { useContext, useState } from 'react' +import styled from 'styled-components' + +import * as narriativeActions from '../../actions/narrative' +import { AppReduxState } from '../../util/state-types' +import { boxShadowCss } from '../form/batch-styled' +import { ComponentContext } from '../../util/contexts' +import { doMergeItineraries } from '../narrative/narrative-itineraries' +import { + getActiveItinerary, + getActiveSearch, + getVisibleItineraryIndex +} from '../../util/state' +import MetroItineraryRoutes from '../narrative/metro/metro-itinerary-routes' + +type ItinWithGeometry = Itinerary & { + allLegGeometry: Feature + allStartTimes?: Itinerary[] + index?: number +} + +type Props = { + from: Location + itins: Itinerary[] + setActiveItinerary: ({ index }: { index: number | null | undefined }) => void + setVisibleItinerary: ({ index }: { index: number | null | undefined }) => void + to: Location + visible?: boolean + visibleItinerary?: number +} + +const Card = styled.div` + ${boxShadowCss} + + background: #fffffffa; + border-radius: 5px; + padding: 6px; + align-items: center; + display: flex; + flex-wrap: wrap; + + + span { + span { + span { + max-height: 28px; + min-height: 20px; + } + } + } + div { + margin-top: -0px!important; + } + .route-block-wrapper span { + padding: 0px; + } + * { + height: 26px; + } +} +` + +function addItinLineString(itin: Itinerary): ItinWithGeometry { + return { + ...itin, + allLegGeometry: lineString( + itin.legs.flatMap((leg) => polyline.decode(leg.legGeometry.points)) + ) + } +} +function addTrueIndex(array: ItinWithGeometry[]): ItinWithGeometry[] { + for (let i = 0; i < array.length; i++) { + const prevIndex = array?.[i - 1]?.index + const itin = array[i] + const nextIndex = itin?.allStartTimes?.length ?? 1 + array[i] = { + ...itin, + index: (prevIndex ?? -1) + nextIndex + } + } + return array +} + +type ItinUniquePoint = { + itin: ItinWithGeometry + uniquePoint: Position +} + +function getUniquePoint( + thisItin: ItinWithGeometry, + otherPoints: ItinUniquePoint[] +): ItinUniquePoint { + const otherMidpoints = otherPoints.map((mp) => mp.uniquePoint) + let maxDistance = -Infinity + const line = thisItin.allLegGeometry + const centerOfLine = centroid(line).geometry.coordinates + let uniquePoint = centerOfLine + + line.geometry.coordinates.forEach((point) => { + const totalDistance = otherMidpoints.reduce( + (prev, cur) => (prev += distance(point, cur)), + 0 + ) + + const selfDistance = distance(point, centerOfLine) + // maximize distance from all other points while minimizing distance to center of our own line + const averageDistance = totalDistance / otherMidpoints.length - selfDistance + + if (averageDistance > maxDistance) { + maxDistance = averageDistance + uniquePoint = point + } + }) + return { itin: thisItin, uniquePoint } +} + +const ItinerarySummaryOverlay = ({ + itins, + setActiveItinerary: setActive, + setVisibleItinerary: setVisible, + visible, + visibleItinerary +}: Props) => { + // @ts-expect-error React context is populated dynamically + const { LegIcon } = useContext(ComponentContext) + + const [sharedTimeout, setSharedTimeout] = useState( + null + ) + + if (!itins || !visible) return <> + const mergedItins: ItinWithGeometry[] = addTrueIndex( + doMergeItineraries(itins).mergedItineraries.map(addItinLineString) + ) + + const midPoints = mergedItins.reduce((prev, curItin) => { + prev.push(getUniquePoint(curItin, prev)) + return prev + }, []) + // The first point is probably not well placed, so let's run the algorithm again + if (midPoints.length > 1) { + midPoints[0] = getUniquePoint(mergedItins[0], midPoints) + } + + try { + return ( + <> + {midPoints.map( + (mp) => + // If no itinerary is hovered, show all of them. If one is selected, show only that one + // TODO: clean up conditionals, move these to a more appropriate place without breaking indexing + (visibleItinerary !== null && visibleItinerary !== undefined + ? visibleItinerary === mp.itin.index + : true) && + mp.uniquePoint && ( + + { + setActive({ index: mp.itin.index }) + }} + // TODO: useCallback here (getting weird errors?) + onMouseEnter={() => { + setSharedTimeout( + setTimeout(() => { + setVisible({ index: mp.itin.index }) + }, 150) + ) + }} + onMouseLeave={() => { + sharedTimeout && clearTimeout(sharedTimeout) + setVisible({ index: null }) + }} + > + + + + ) + )} + + ) + } catch (error) { + console.warn(`Can't create geojson from route: ${error}`) + return <> + } +} + +const mapStateToProps = (state: AppReduxState) => { + const { activeSearchId, config } = state.otp + if (config.itinerary?.previewOverlay !== true) { + return {} + } + if (!activeSearchId) return {} + + const visibleItinerary = getVisibleItineraryIndex(state) + const activeItinerary = getActiveItinerary(state) + + const activeSearch = getActiveSearch(state) + // @ts-expect-error state is not typed + const itins = activeSearch?.response.flatMap( + (serverResponse: { plan?: { itineraries?: Itinerary[] } }) => + serverResponse?.plan?.itineraries + ) + + // @ts-expect-error state is not typed + const query = activeSearch ? activeSearch?.query : state.otp.currentQuery + const { from, to } = query + + return { + from, + itins, + to, + visible: activeItinerary === undefined || activeItinerary === null, + visibleItinerary + } +} + +const mapDispatchToProps = { + setActiveItinerary: narriativeActions.setActiveItinerary, + setVisibleItinerary: narriativeActions.setVisibleItinerary +} + +export default connect( + mapStateToProps, + mapDispatchToProps +)(ItinerarySummaryOverlay) diff --git a/lib/components/narrative/metro/default-route-renderer.tsx b/lib/components/narrative/metro/default-route-renderer.tsx index aed908708..816348a98 100644 --- a/lib/components/narrative/metro/default-route-renderer.tsx +++ b/lib/components/narrative/metro/default-route-renderer.tsx @@ -9,6 +9,8 @@ const Block = styled.span<{ color: string; isOnColoredBackground?: boolean }>` display: inline-block; margin-top: -2px; padding: 3px 7px; + padding-left: 7px !important; /* TODO: this does not scale well to alternate zoom levels/text sizes */ + padding-right: 7px !important; /* Below is for route names that are too long: cut-off and show ellipsis. */ max-width: 150px; overflow: hidden; diff --git a/lib/components/narrative/narrative-itineraries.js b/lib/components/narrative/narrative-itineraries.js index d9f12359a..740d8ce4b 100644 --- a/lib/components/narrative/narrative-itineraries.js +++ b/lib/components/narrative/narrative-itineraries.js @@ -54,7 +54,7 @@ function makeStartTime(itinerary) { } } -const doMergeItineraries = memoize((itineraries) => { +export const doMergeItineraries = memoize((itineraries) => { const mergedItineraries = itineraries .reduce((prev, cur) => { const updatedItineraries = clone(prev) diff --git a/lib/util/config-types.ts b/lib/util/config-types.ts index 698d2d6fb..b7ce80a03 100644 --- a/lib/util/config-types.ts +++ b/lib/util/config-types.ts @@ -252,6 +252,7 @@ export interface ItineraryConfig { mergeItineraries?: boolean mutedErrors?: string[] onlyShowCountdownForRealtime?: boolean + previewOverlay?: boolean renderRouteNamesInBlocks?: boolean showFirstResultByDefault?: boolean showHeaderText?: boolean diff --git a/lib/util/state-types.ts b/lib/util/state-types.ts index 7a944d764..54f28fab1 100644 --- a/lib/util/state-types.ts +++ b/lib/util/state-types.ts @@ -9,13 +9,14 @@ import { import { AppConfig } from './config-types' export interface OtpState { + // TODO: Add other OTP states + activeSearchId?: string config: AppConfig filter: { sort: { type: string } } - // TODO: Add other OTP states ui: any // TODO } diff --git a/package.json b/package.json index 03fe38d46..2c5a1e160 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,8 @@ "@opentripplanner/vehicle-rental-overlay": "^2.1.3", "@styled-icons/fa-regular": "^10.34.0", "@styled-icons/fa-solid": "^10.34.0", + "@turf/centroid": "^6.5.0", + "@turf/helpers": "^6.5.0", "blob-stream": "^0.1.3", "bootstrap": "^3.3.7", "bowser": "^1.9.3", diff --git a/yarn.lock b/yarn.lock index 2ad4f229f..2b9c94e10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3326,6 +3326,14 @@ "@turf/helpers" "^6.5.0" "@turf/invariant" "^6.5.0" +"@turf/centroid@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/centroid/-/centroid-6.5.0.tgz#ecaa365412e5a4d595bb448e7dcdacfb49eb0009" + integrity sha512-MwE1oq5E3isewPprEClbfU5pXljIK/GUOMbn22UM3IFPDJX0KeoyLNwghszkdmFp/qMGL/M13MMWvU+GNLXP/A== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + "@turf/circle@^6.5.0": version "6.5.0" resolved "https://registry.yarnpkg.com/@turf/circle/-/circle-6.5.0.tgz#dc017d8c0131d1d212b7c06f76510c22bbeb093c"