diff --git a/assets/src/components/map/follower.tsx b/assets/src/components/map/follower.tsx index 21e87e2d3..084f3616a 100644 --- a/assets/src/components/map/follower.tsx +++ b/assets/src/components/map/follower.tsx @@ -25,7 +25,7 @@ export type UpdateMapFromPointsFn = (map: LeafletMap, points: LatLng[]) => void export interface FollowerProps { positions: LatLng[] - onUpdate: UpdateMapFromPointsFn + onUpdate?: UpdateMapFromPointsFn } export const Follower = ({ @@ -48,7 +48,7 @@ export const Follower = ({ if (isAnimatingFollowUpdate !== undefined) { isAnimatingFollowUpdate.current = true } - onUpdate(map, currentLatLngs) + onUpdate?.(map, currentLatLngs) } }, [map, shouldFollow, isAnimatingFollowUpdate, currentLatLngs, onUpdate]) @@ -64,8 +64,10 @@ export interface InteractiveFollowState { // Gathers all state needed for the Follower to be able to display it's state // as well as support turning off when interrupted -export const useInteractiveFollowerState = (): InteractiveFollowState => { - const [shouldFollow, setShouldFollow] = useState(true) +export const useInteractiveFollowerState = ( + initEnabled = true +): InteractiveFollowState => { + const [shouldFollow, setShouldFollow] = useState(initEnabled) const isAnimatingFollowUpdate: MutableRefObject = useRef(false) return { @@ -177,53 +179,61 @@ export const usePickerContainerFollowerFn = () => { return onUpdate } -export const drawerOffsetAutoCenter: UpdateMapFromPointsFn = (map, points) => { - if (points.length === 0) { - // If there are no points, blink to default center - map.setView(defaultCenter, 13, { animate: false }) - return - } +export const drawerOffsetAutoCenter = + (useCurrentZoom: boolean): UpdateMapFromPointsFn => + (map, points) => { + if (points.length === 0) { + // If there are no points, blink to default center + map.setView(defaultCenter, 13, { animate: false }) + return + } - const { width, height } = map.getContainer().getBoundingClientRect() - const mapContainerBounds = new Bounds([0, 0], [width, height]) - - // ``` - // vpcElement.getBoundingClientRect().right - mapElement.getBoundingClientRect().left - // -> 445 - // ``` - // Create a new inner bounds from the map bounds + "padding" to shrink the - // inner bounds - // In this case, we get the top left of the inner bounds by padding the left - // with the distance from the right side of the VPC to the left side of the - // map container - const topLeft = new Point(445, 0) - - if (points.length === 1) { - const targetZoom = 16 - const innerBounds = new Bounds(topLeft, mapContainerBounds.getBottomRight()) - // The "new center" is the offset between the two bounding boxes centers - const offset = innerBounds - .getCenter() - .subtract(mapContainerBounds.getCenter()) - - const targetPoint = map - // Project the target point into screenspace for the target zoom - .project(points[0], targetZoom) - // Offset the target point in screenspace to move the center of the map - // to apply the padding to the center - .subtract(offset), - // convert the target point to worldspace from screenspace - targetLatLng = map.unproject(targetPoint, targetZoom) - - // Zoom/Pan center of map to offset location in worldspace - map.setView(targetLatLng, targetZoom) - } else { - const pointsBounds = Leaflet.latLngBounds(points) - map.fitBounds(pointsBounds, { - paddingBottomRight: [50, 20], - paddingTopLeft: topLeft, - }) + const { width, height } = map.getContainer().getBoundingClientRect() + const mapContainerBounds = new Bounds([0, 0], [width, height]) + + // ``` + // vpcElement.getBoundingClientRect().right - mapElement.getBoundingClientRect().left + // -> 445 + // ``` + // Create a new inner bounds from the map bounds + "padding" to shrink the + // inner bounds + // In this case, we get the top left of the inner bounds by padding the left + // with the distance from the right side of the VPC to the left side of the + // map container + const topLeft = new Point(445, 0) + + if (points.length === 1) { + const currentZoom = map.getZoom() + const targetZoom = useCurrentZoom && currentZoom >= 13 ? currentZoom : 16 + const innerBounds = new Bounds( + topLeft, + mapContainerBounds.getBottomRight() + ) + // The "new center" is the offset between the two bounding boxes centers + const offset = innerBounds + .getCenter() + .subtract(mapContainerBounds.getCenter()) + + const targetPoint = map + // Project the target point into screenspace for the target zoom + .project(points[0], targetZoom) + // Offset the target point in screenspace to move the center of the map + // to apply the padding to the center + .subtract(offset), + // convert the target point to worldspace from screenspace + targetLatLng = map.unproject(targetPoint, targetZoom) + + // Zoom/Pan center of map to offset location in worldspace + map.setView(targetLatLng, targetZoom) + } else { + const pointsBounds = Leaflet.latLngBounds(points) + map.fitBounds(pointsBounds, { + paddingBottomRight: [50, 20], + paddingTopLeft: topLeft, + }) + } } -} + +export const fixedZoomDrawerOffsetAutoCenter = drawerOffsetAutoCenter(false) // #endregion Follower Update Functions diff --git a/assets/src/components/mapPage.tsx b/assets/src/components/mapPage.tsx index b45c31277..f97898a28 100644 --- a/assets/src/components/mapPage.tsx +++ b/assets/src/components/mapPage.tsx @@ -205,6 +205,9 @@ const Selection = ({ } const MapPage = (): ReactElement => { + const [followerShouldSetZoomLevel, setFollowerShouldSetZoomLevel] = + useState(true) + const [{ searchPageState, openView }, dispatch] = useContext(StateDispatchContext), { selectedEntity = null } = searchPageState @@ -303,12 +306,18 @@ const MapPage = (): ReactElement => { {selectedEntity ? ( { + setFollowerShouldSetZoomLevel(false) + setVehicleSelection(...args) + }} fetchedSelectedLocation={fetchedSelectedLocation} /> ) : ( { + setFollowerShouldSetZoomLevel(true) + selectVehicleResult(...args) + }} onSelectLocationResult={selectLocationResult} /> )} @@ -316,8 +325,19 @@ const MapPage = (): ReactElement => {
{ + setFollowerShouldSetZoomLevel(false) + setVehicleSelection(...args) + }} fetchedSelectedLocation={fetchedSelectedLocation} + initializeRouteFollowerEnabled={followerShouldSetZoomLevel === true} + vehicleUseCurrentZoom={followerShouldSetZoomLevel === false} + onInterruptVehicleFollower={ + (followerShouldSetZoomLevel === false || undefined) && + (() => { + setFollowerShouldSetZoomLevel(false) + }) + } />
diff --git a/assets/src/components/mapPage/mapDisplay.tsx b/assets/src/components/mapPage/mapDisplay.tsx index 8066ad14c..f1b8b6d73 100644 --- a/assets/src/components/mapPage/mapDisplay.tsx +++ b/assets/src/components/mapPage/mapDisplay.tsx @@ -32,7 +32,9 @@ import Map, { vehicleToLeafletLatLng } from "../map" import { RecenterControlWithInterruptibleFollower, useInteractiveFollowerState, + fixedZoomDrawerOffsetAutoCenter, drawerOffsetAutoCenter, + InterruptibleFollower, } from "../map/follower" import { LocationMarker, @@ -57,6 +59,7 @@ import { useAllStops } from "../../hooks/useAllStops" import { LocationType, RouteType } from "../../models/stopData" import usePullbackVehicles from "../../hooks/usePullbackVehicles" import { fullStoryEvent } from "../../helpers/fullStory" +import { RecenterControl } from "../map/controls/recenterControl" const SecondaryRouteVehicles = ({ selectedVehicleRoute, @@ -177,7 +180,7 @@ const MapElementsNoSelection = () => { return ( @@ -231,11 +234,15 @@ const SelectedVehicleDataLayers = ({ routePatterns, selectVehicle, stops, + useCurrentZoom, + onInterruptFollower, }: { vehicleOrGhost: Vehicle | Ghost | null routePatterns: ByRoutePatternId | null selectVehicle: (vehicleOrGhost: Vehicle | Ghost) => void stops: Stop[] + useCurrentZoom: boolean + onInterruptFollower?: () => void }) => { const position = (selectedVehicleOrGhost && @@ -265,6 +272,7 @@ const SelectedVehicleDataLayers = ({ (routePatternForVehicle?.shape?.stops || []).map((s) => s.id) ) + const onUpdate = drawerOffsetAutoCenter(useCurrentZoom) return ( <> {selectedVehicleOrGhost && ( @@ -304,10 +312,22 @@ const SelectedVehicleDataLayers = ({ : stops } /> - { + followerState.setShouldFollow(true) + }} + /> + 0 ? onUpdate : undefined} positions={position} {...followerState} + setShouldFollow={(...args) => { + onInterruptFollower?.() + followerState.setShouldFollow(...args) + }} /> ) @@ -318,11 +338,13 @@ const SelectedRouteDataLayers = ({ routePatterns, selectVehicle, stops, + initializeFollowerEnabled, }: { routePatternIdentifier: RoutePatternIdentifier routePatterns: ByRoutePatternId | null selectVehicle: (vehicleOrGhost: Vehicle | Ghost) => void stops: Stop[] + initializeFollowerEnabled: boolean }) => { const selectedRoutePattern: RoutePattern | undefined = routePatterns ? routePatterns[routePatternIdentifier.routePatternId] @@ -332,7 +354,8 @@ const SelectedRouteDataLayers = ({ Leaflet.latLng(p.lat, p.lon) ) || [] : [] - const followerState = useInteractiveFollowerState() + + const followerState = useInteractiveFollowerState(initializeFollowerEnabled) const routePatternStopIdSet = new Set( (selectedRoutePattern?.shape?.stops || []).map((s) => s.id) @@ -352,7 +375,7 @@ const SelectedRouteDataLayers = ({ onVehicleSelect={selectVehicle} /> @@ -374,7 +397,7 @@ const SelectedLocationDataLayer = ({ <> @@ -386,10 +409,16 @@ const SelectionLayers = ({ selectedEntity, selectVehicle, fetchedSelectedLocation, + initializeRouteFollowerEnabled, + vehicleUseCurrentZoom, + onInterruptVehicleFollower, }: { selectedEntity: SelectedEntity | null selectVehicle: (vehicleOrGhost: Vehicle | Ghost) => void fetchedSelectedLocation: LocationSearchResult | null + initializeRouteFollowerEnabled: boolean + vehicleUseCurrentZoom: boolean + onInterruptVehicleFollower?: () => void }) => { const liveSelectedEntity: LiveSelectedEntity | null = useLiveSelectedEntity( selectedEntity, @@ -412,6 +441,8 @@ const SelectionLayers = ({ routePatterns={routePatterns} selectVehicle={selectVehicle} stops={stops} + useCurrentZoom={vehicleUseCurrentZoom} + onInterruptFollower={onInterruptVehicleFollower} /> ) case SelectedEntityType.RoutePattern: @@ -424,6 +455,7 @@ const SelectionLayers = ({ routePatterns={routePatterns} selectVehicle={selectVehicle} stops={stops} + initializeFollowerEnabled={initializeRouteFollowerEnabled} /> ) case SelectedEntityType.Location: @@ -536,11 +568,17 @@ const DataLayers = ({ setSelection, fetchedSelectedLocation, pullbackLayerEnabled, + initializeRouteFollowerEnabled, + vehicleUseCurrentZoom, + onInterruptVehicleFollower, }: { selectedEntity: SelectedEntity | null setSelection: (selectedEntity: SelectedEntity | null) => void fetchedSelectedLocation: LocationSearchResult | null pullbackLayerEnabled: boolean + initializeRouteFollowerEnabled: boolean + vehicleUseCurrentZoom: boolean + onInterruptVehicleFollower?: () => void }): JSX.Element => { const streetViewActive = useContext(StreetViewModeEnabledContext) @@ -581,6 +619,9 @@ const DataLayers = ({ selectedEntity={selectedEntity} selectVehicle={selectVehicle} fetchedSelectedLocation={fetchedSelectedLocation} + initializeRouteFollowerEnabled={initializeRouteFollowerEnabled} + vehicleUseCurrentZoom={vehicleUseCurrentZoom} + onInterruptVehicleFollower={onInterruptVehicleFollower} /> void - streetViewInitiallyEnabled?: boolean fetchedSelectedLocation: LocationSearchResult | null + streetViewInitiallyEnabled?: boolean + initializeRouteFollowerEnabled?: boolean + vehicleUseCurrentZoom?: boolean + onInterruptVehicleFollower?: () => void }) => { const [ { @@ -634,6 +681,9 @@ const MapDisplay = ({ selectedEntity={selectedEntity} fetchedSelectedLocation={fetchedSelectedLocation} pullbackLayerEnabled={pullbackLayerEnabled} + initializeRouteFollowerEnabled={initializeRouteFollowerEnabled} + vehicleUseCurrentZoom={vehicleUseCurrentZoom} + onInterruptVehicleFollower={onInterruptVehicleFollower} /> {(open, setOpen) => ( diff --git a/assets/tests/components/mapPage.test.tsx b/assets/tests/components/mapPage.test.tsx index 61b719d7e..4d4bd695b 100644 --- a/assets/tests/components/mapPage.test.tsx +++ b/assets/tests/components/mapPage.test.tsx @@ -87,6 +87,7 @@ import { import useSearchResultsByCategory from "../../src/hooks/useSearchResultsByCategory" import { useLocationSearchSuggestions } from "../../src/hooks/useLocationSearchSuggestions" import { fullStoryEvent } from "../../src/helpers/fullStory" +import { recenterControl } from "../testHelpers/selectors/components/map/controls/recenterControl" jest.mock("../../src/hooks/useLocationSearchResults", () => ({ __esModule: true, @@ -1069,6 +1070,51 @@ describe("", () => { expect(vehiclePropertiesCard.query()).not.toBeInTheDocument() }) + test("when VehiclePropertiesCard Route Button is clicked, follower should be initially disabled", async () => { + const route = routeFactory.build() + const routePattern = routePatternFactory.build({ routeId: route.id }) + const vehicle = vehicleFactory.build({ + routeId: routePattern.routeId, + routePatternId: routePattern.id, + }) + + mockUseVehicleForId([vehicle]) + mockUseVehiclesForRouteMap({ + [vehicle.routeId]: [vehicle], + }) + jest.mocked(usePatternsByIdForRoute).mockReturnValue({ + [routePattern.id]: routePattern, + }) + + render( + + + + + + ) + + await userEvent.click( + getByRole(vehiclePropertiesCard.get(), "button", { + name: "Route Variant Name", + }) + ) + + expect(recenterControl.get().dataset.isActive).toBe("false") + }) + test("clicking vehicle when RPC is open closes RPC and opens VPC", async () => { jest.spyOn(global, "scrollTo").mockImplementationOnce(jest.fn()) diff --git a/assets/tests/components/mapPage/mapDisplay.test.tsx b/assets/tests/components/mapPage/mapDisplay.test.tsx index 7aa4f6235..8a28dbdad 100644 --- a/assets/tests/components/mapPage/mapDisplay.test.tsx +++ b/assets/tests/components/mapPage/mapDisplay.test.tsx @@ -48,6 +48,7 @@ import { getAllStopIcons, } from "../../testHelpers/selectors/components/mapPage/map" import { fullStoryEvent } from "../../../src/helpers/fullStory" +import { recenterControl } from "../../testHelpers/selectors/components/map/controls/recenterControl" jest.mock("userTestGroups", () => ({ __esModule: true, @@ -860,4 +861,29 @@ describe("", () => { expect(mockSetSelection).not.toHaveBeenCalled() }) }) + + test.each([ + { enabled: true, expected: "true" }, + { enabled: false, expected: "false" }, + ])( + "when initializeRouteFollowerEnabled is $enabled and route is selected, follower enabled state should be $expected", + ({ enabled, expected }) => { + setHtmlWidthHeightForLeafletMap() + + render( + + ) + + expect(recenterControl.get().dataset.isActive).toBe(expected) + } + ) })