From b8c1fbf192a0c4ad94ed7f158b23bb38356a4764 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 01:20:29 +0000 Subject: [PATCH] refactor(client): the plan map and its floating controls move out of the planner page The plan tab's MapView with the route alternatives bar, the overview and trail stack, the POI and compass cluster, the phone compass and the phone POI portal move to pages/tripPlanner/PlanMapStage.tsx. It returns the same children as a fragment in the same place, so the DOM and the portal order stay as they were. Its props are picked from the planner hook's own types. The four physical sides that moved are map geometry and carry an rtl-lint-disable marker on their line, so the page's rtl baseline drops from 17 to 13. Two moved comments lose a dash. --- client/scripts/rtl-baseline.json | 2 +- client/src/pages/TripPlannerPage.tsx | 180 ++----------- .../pages/tripPlanner/PlanMapStage.test.tsx | 213 ++++++++++++++++ client/src/pages/tripPlanner/PlanMapStage.tsx | 237 ++++++++++++++++++ 4 files changed, 470 insertions(+), 162 deletions(-) create mode 100644 client/src/pages/tripPlanner/PlanMapStage.test.tsx create mode 100644 client/src/pages/tripPlanner/PlanMapStage.tsx diff --git a/client/scripts/rtl-baseline.json b/client/scripts/rtl-baseline.json index 82e209e06..a18e7c7cc 100644 --- a/client/scripts/rtl-baseline.json +++ b/client/scripts/rtl-baseline.json @@ -105,7 +105,7 @@ "pages/JourneyPublicPage.tsx": 3, "pages/LoginPage.tsx": 23, "pages/sharedTrip/SharedMap.tsx": 4, - "pages/TripPlannerPage.tsx": 17, + "pages/TripPlannerPage.tsx": 13, "pages/VacayPage.tsx": 1, "styles/collections.css": 1, "styles/studio.css": 7, diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 31758fc4a..6f197c1b9 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -4,9 +4,7 @@ import { useParams, useNavigate, useSearchParams } from 'react-router' import { useTripStore } from '../store/tripStore' import { useSettingsStore } from '../store/settingsStore' import { MapViewAuto as MapView } from '../components/Map/MapViewAuto' -import { MapCompassPill, type CompassMap } from '../components/Map/MapCompassPill' -import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../components/Map/TripRouteOverview' -import { DawarichTrailPill } from '../components/Map/DawarichTrailPill' +import type { CompassMap } from '../components/Map/MapCompassPill' import { getCached, fetchPhoto } from '../services/photoService' import DayPlanSidebar from '../components/Planner/DayPlanSidebar' import RoadtripModeSwitch from '../components/Roadtrip/RoadtripModeSwitch' @@ -53,13 +51,13 @@ import type { Accommodation, TripMember, Day, Place, Reservation } from '../type import { useTripPlannerPage } from './tripPlanner/useTripPlannerPage' import { ReservationsPanel, FileManager, CostsPanel, ExpenseModal, CollabPanel, - RoadtripSidebar, RoadtripCorridorPanel, RoadtripLimitsCard, RoadtripStopPopup, RoadtripStayModal, RoadtripTrackModal, RoadtripAlternativesBar, + RoadtripSidebar, RoadtripCorridorPanel, RoadtripLimitsCard, RoadtripStopPopup, RoadtripStayModal, RoadtripTrackModal, TourPlannerRail, TourPlannerToursRail, TransportModal, } from './tripPlanner/plannerLazy' import { LazyPanel } from './tripPlanner/LazyPanel' import { ListsContainer } from './tripPlanner/ListsContainer' +import { PlanMapStage } from './tripPlanner/PlanMapStage' import { useMergedMapPois } from '../components/Map/useMergedMapPois' -import PoiCategoryPill from '../components/Map/PoiCategoryPill' import { useTouchDragBridge } from '../hooks/useTouchDragBridge' import PanelResizeHandle from '../components/Planner/PanelResizeHandle' @@ -266,164 +264,24 @@ function TripPlannerPageDesktop(): React.ReactElement | null { {activeTab === 'plan' && (
- { - const r = reservations.find(x => x.id === rid) - if (!r) return - if (isMobile) setMapTransportDetail(r) - else openBookingDetail(r) - }} - /* In road trip mode the corridor's `visible` (not `search.results`: the map is - the picture of that very list, and filtering the list while seventy pins stay - on the map no longer answers "which of these") plus whatever the category - pill found in view. Outside it, only the pill's hits. */ - pois={mapPois} - onPoiClick={handlePoiClick} - // Only while road trip mode is on: outside it there is no drive to drop onto. - onPoiDropOnRoute={roadtripActive ? dropPoiOnRoute : undefined} - // Clicking the drawn route puts a via there; only in road trip mode, where - // the route is the thing being worked on. - onRouteClick={roadtripActive && can('day_edit', trip) ? addRoadtripVia : undefined} - roadtripVias={roadtripActive ? roadtripVias.byDay : undefined} - alternativeRoutes={alternativeOverlays} - focusPoints={overviewActive ? tripOverview.focusPoints : mapFocusPoints} - clusterLoosely={roadtripActive} - activeAlternative={highlightedAlternative} - onChooseAlternative={chooseRouteAlternative} - onHighlightAlternative={setHighlightedAlternative} - onMoveVia={can('day_edit', trip) ? moveRoadtripVia : undefined} - onRemoveVia={can('day_edit', trip) ? removeRoadtripVia : undefined} - onViewportChange={tourMap.captureViewport} - onMapReady={setGlMap} + - {/* Over the map rather than in a dialog: the answer to "which of these" is the - roads drawn behind it, so covering them to ask would hide the point. */} - {routeAlternatives.open && ( -
- - - -
- )} - - {/* Bottom-RIGHT. Not the top corridor between the panels, which is already - contested by the POI bar and the collapse tabs (#2247); and not the - bottom-left corner, where Leaflet's base-layer switcher sits at - z-index 1000 and would cover this. The right corner is free on both - renderers — the locate button that lives there is phone-only. */} - {(!roadtripActive || dawarichEnabled) && ( -
- {!roadtripActive && overviewActive && ( - - )} - {!roadtripActive && ( - - )} - {/* Stays in road-trip mode, unlike the overview: the route that was - actually driven is the thing you most want beside the planned - one. It is drawn, never applied — correcting the plan from the - recording is a different feature and deliberately not this one. */} - {dawarichEnabled && ( - - )} -
- )} - - {(poiPillEnabled || glMap) && ( -
- {poiPillEnabled && ( - - )} - {glMap && } -
- )} - - {/* Mobile: the compass/reset-orientation control lives centre-top on its own - (the desktop cluster above is hidden below md), between the edge Plan/Places tabs. */} - {glMap && ( -
- -
- )} - - {/* Mobile POI search controls live in a portal like the Plan/Places - buttons so map touch handlers cannot swallow the tap targets. */} - {poiPillEnabled && !mobileSidebarOpen && !showPlaceForm && !showMembersModal && !showReservationModal && createPortal( -
- -
, - document.body - )} -
{/* The panel's tab: a flap on its edge while open, a raised accent tile once it is tucked away. */} diff --git a/client/src/pages/tripPlanner/PlanMapStage.test.tsx b/client/src/pages/tripPlanner/PlanMapStage.test.tsx new file mode 100644 index 000000000..23d633775 --- /dev/null +++ b/client/src/pages/tripPlanner/PlanMapStage.test.tsx @@ -0,0 +1,213 @@ +// FE-PAGE-PLANNER-MAPSTAGE-001 to FE-PAGE-PLANNER-MAPSTAGE-010 +// +// The plan tab's map and its floating controls, rendered on their own. The map +// and the pills are stubs that record their props, so each mode the stage +// switches between (day route, road trip, trip overview) is read off directly. +import React from 'react' +import { render, screen } from '../../../tests/helpers/render' +import { buildTrip, buildPlace, buildReservation } from '../../../tests/helpers/factories' +import { PlanMapStage, type PlanMapStageProps } from './PlanMapStage' + +type Recorded = Record & Record<`on${string}`, (...args: unknown[]) => unknown> +const captured = vi.hoisted(() => ({} as Record>)) +const stub = vi.hoisted(() => (name: string, testId: string) => (props: Record) => { + captured[name] = props + return React.createElement('div', { 'data-testid': testId }) +}) +vi.mock('../../components/Map/MapViewAuto', () => ({ MapViewAuto: stub('map', 'map-view') })) +vi.mock('../../components/Map/MapCompassPill', () => ({ MapCompassPill: stub('compass', 'compass-pill') })) +vi.mock('../../components/Map/PoiCategoryPill', () => ({ default: stub('poiPill', 'poi-pill') })) +vi.mock('../../components/Map/TripRouteOverview', () => ({ + TripRouteOverviewPill: stub('overviewPill', 'overview-pill'), + TripRouteOverviewPanel: stub('overviewPanel', 'overview-panel'), +})) +vi.mock('../../components/Map/DawarichTrailPill', () => ({ DawarichTrailPill: stub('dawarichPill', 'dawarich-pill') })) +vi.mock('../../components/Roadtrip/RoadtripAlternativesBar', () => ({ default: stub('alternativesBar', 'alternatives-bar') })) + +function recorded(name: string): Recorded { + return captured[name] as Recorded +} + +const trip = buildTrip({ id: 12 }) +const place = buildPlace({ id: 4 }) +const flight = buildReservation({ id: 70, trip_id: 12 }) + +// The fixture holds what the stage reads; the hook's full types do not matter here. +function stageProps(overrides: Partial> = {}): PlanMapStageProps { + const base: Partial> = { + tripId: 12, trip, can: () => true, days: [], reservations: [flight], isMobile: false, + selectedDayId: 3, selectedPlaceId: null, selectedPlace: null, showDayDetail: null, handleSelectDay: vi.fn(), + mapPlaces: ['day places'], dayPlaces: [], dayOrderMap: {}, mapTileUrl: 'tiles', fitKey: 1, + mapLocked: false, toggleMapLocked: vi.fn(), mapFocusPoints: ['day focus'], + route: ['day route'], routeWalking: ['walking'], routeSegments: ['day segments'], routeVias: ['day vias'], transitRoutesShown: true, + handleMarkerClick: vi.fn(), handleMapClick: vi.fn(), handleMapContextMenu: vi.fn(), handlePoiClick: vi.fn(), + visibleConnections: ['day connections'], setMapTransportDetail: vi.fn(), openBookingDetail: vi.fn(), + roadtripActive: false, roadtripMapPlaces: ['stops'], roadtripMapLines: ['drive'], roadtripLineColors: ['red'], + roadtripMapVias: ['drive vias'], roadtripRoutes: { accessLines: ['access'], segments: ['drive segments'] }, + roadtripConnections: ['rides'], roadtripVias: { byDay: { 3: [] } }, + dayBoundaryControls: ['boundaries'], dropPoiOnRoute: vi.fn(), addRoadtripVia: vi.fn(), moveRoadtripVia: vi.fn(), removeRoadtripVia: vi.fn(), + routeAlternatives: { open: null, close: vi.fn() }, alternativeOverlays: ['overlays'], highlightedAlternative: null, + chooseRouteAlternative: vi.fn(), setHighlightedAlternative: vi.fn(), + overviewActive: false, overviewShown: false, toggleOverview: vi.fn(), + tripOverview: { lines: ['overview'], lineColors: ['blue'], segments: ['overview segments'], focusPoints: ['overview focus'] }, + dawarichEnabled: false, dawarichTrail: { track: ['recorded'], status: 'idle' }, dawarichHiddenDates: ['2025-06-02'], + dawarichTrailShown: false, toggleDawarichTrail: vi.fn(), + mobileSidebarOpen: null, showPlaceForm: false, showMembersModal: false, showReservationModal: false, + poi: { categories: { core: [], plugin: [] }, active: [], toggle: vi.fn(), loadingKeys: [], errorKeys: [], moved: false, searchArea: vi.fn() }, + tourMap: { captureViewport: vi.fn() }, + leftPanelPx: 320, rightPanelPx: 0, mapInsetLeft: 330, mapInsetRight: 0, + glMap: null, setGlMap: vi.fn(), poiPillEnabled: true, mapPois: ['pois'], distanceUnit: 'metric', + } + return { ...base, ...overrides } as unknown as PlanMapStageProps +} + +function renderStage(overrides: Partial> = {}) { + const props = stageProps(overrides) + return { props, ...render() } +} + +beforeEach(() => { + for (const key of Object.keys(captured)) delete captured[key] +}) + +describe('PlanMapStage', () => { + it('FE-PAGE-PLANNER-MAPSTAGE-001: on an ordinary day the map draws that day', () => { + renderStage() + const map = recorded('map') + + expect(map).toMatchObject({ + places: ['day places'], route: ['day route'], routeColors: undefined, routeWalking: ['walking'], + routeSegments: ['day segments'], routeVias: ['day vias'], focusPoints: ['day focus'], + visibleConnectionIds: ['day connections'], dayBoundaryControls: undefined, accessLines: undefined, + roadtripVias: undefined, onPoiDropOnRoute: undefined, onRouteClick: undefined, clusterLoosely: false, + followSelection: true, leftWidth: 320, rightWidth: 0, hasInspector: false, hasDayDetail: false, + dawarichTrack: ['recorded'], dawarichHiddenDates: ['2025-06-02'], pois: ['pois'], tileUrl: 'tiles', + }) + expect(screen.getByTestId('overview-pill')).toBeInTheDocument() + expect(screen.queryByTestId('overview-panel')).toBeNull() + expect(screen.queryByTestId('dawarich-pill')).toBeNull() + expect(screen.queryByTestId('alternatives-bar')).toBeNull() + }) + + it('FE-PAGE-PLANNER-MAPSTAGE-002: in road trip mode the map draws the drive and edits it', () => { + const { props } = renderStage({ roadtripActive: true }) + const map = recorded('map') + + expect(map).toMatchObject({ + places: ['stops'], route: ['drive'], routeColors: ['red'], routeWalking: undefined, + routeSegments: ['drive segments'], routeVias: ['drive vias'], dayBoundaryControls: ['boundaries'], + accessLines: ['access'], visibleConnectionIds: ['rides'], roadtripVias: { 3: [] }, clusterLoosely: true, + }) + expect(map.onPoiDropOnRoute).toBe(props.dropPoiOnRoute) + expect(map.onRouteClick).toBe(props.addRoadtripVia) + expect(map.onMoveVia).toBe(props.moveRoadtripVia) + expect(map.onRemoveVia).toBe(props.removeRoadtripVia) + // The overview belongs to the plan, so the whole stack goes while it is the only thing in it. + expect(screen.queryByTestId('overview-pill')).toBeNull() + }) + + it('FE-PAGE-PLANNER-MAPSTAGE-003: without the day right the drive cannot be edited from the map', () => { + const can = vi.fn((action: string) => action !== 'day_edit') + renderStage({ roadtripActive: true, can }) + const map = recorded('map') + + expect(can).toHaveBeenCalledWith('day_edit', trip) + expect(map.onRouteClick).toBeUndefined() + expect(map.onMoveVia).toBeUndefined() + expect(map.onRemoveVia).toBeUndefined() + }) + + it('FE-PAGE-PLANNER-MAPSTAGE-004: the trip overview replaces the day route and opens its panel', () => { + const { props } = renderStage({ overviewActive: true, overviewShown: true }) + const map = recorded('map') + + expect(map).toMatchObject({ + route: ['overview'], routeColors: ['blue'], routeSegments: ['overview segments'], + focusPoints: ['overview focus'], routeWalking: undefined, + }) + expect(recorded('overviewPanel')).toMatchObject({ overview: props.tripOverview, unit: 'metric', selectedDayId: 3 }) + recorded('overviewPanel').onSelectDay(5) + expect(props.handleSelectDay).toHaveBeenCalledWith(5) + expect(recorded('overviewPill')).toMatchObject({ active: true, onToggle: props.toggleOverview }) + }) + + it('FE-PAGE-PLANNER-MAPSTAGE-005: the recorded trail stays beside the drive in road trip mode', () => { + const { props } = renderStage({ roadtripActive: true, dawarichEnabled: true, dawarichTrailShown: true, mapInsetRight: 290 }) + + expect(recorded('dawarichPill')).toMatchObject({ active: true, status: 'idle', onToggle: props.toggleDawarichTrail }) + expect(screen.queryByTestId('overview-pill')).toBeNull() + const stack = screen.getByTestId('dawarich-pill').parentElement as HTMLElement + expect(stack.style.right).toBe('304px') + expect(stack.style.left).toBe('') + }) + + it('FE-PAGE-PLANNER-MAPSTAGE-006: an open choice of routes shows the alternatives bar over the map', async () => { + const open = { dayId: 3 } + const { props } = renderStage({ routeAlternatives: { open, close: vi.fn() } }) + + expect(await screen.findByTestId('alternatives-bar')).toBeInTheDocument() + expect(recorded('alternativesBar')).toMatchObject({ + open, + overlays: ['overlays'], + onChoose: props.chooseRouteAlternative, + onClose: (props.routeAlternatives as { close: unknown }).close, + onHighlight: props.setHighlightedAlternative, + }) + }) + + it('FE-PAGE-PLANNER-MAPSTAGE-007: a booking clicked on the map opens where the layout shows bookings', () => { + const desktop = renderStage() + recorded('map').onReservationClick(999) + expect(desktop.props.openBookingDetail).not.toHaveBeenCalled() + recorded('map').onReservationClick(70) + expect(desktop.props.openBookingDetail).toHaveBeenCalledWith(flight) + expect(desktop.props.setMapTransportDetail).not.toHaveBeenCalled() + desktop.unmount() + + const phone = renderStage({ isMobile: true, mapLocked: true }) + expect(recorded('map')).toMatchObject({ followSelection: true, onToggleFollow: undefined }) + recorded('map').onReservationClick(70) + expect(phone.props.setMapTransportDetail).toHaveBeenCalledWith(flight) + expect(phone.props.openBookingDetail).not.toHaveBeenCalled() + }) + + it('FE-PAGE-PLANNER-MAPSTAGE-008: the compass appears once the map hands itself over', () => { + const { props, rerender } = renderStage({ poiPillEnabled: false }) + expect(screen.queryByTestId('compass-pill')).toBeNull() + expect(screen.queryByTestId('poi-pill')).toBeNull() + expect(recorded('map').onMapReady).toBe(props.setGlMap) + + const glMap = { getBearing: () => 0 } + rerender() + // The desktop cluster and the phone's own centre-top copy. + expect(screen.getAllByTestId('compass-pill')).toHaveLength(2) + expect(recorded('compass').map).toBe(glMap) + }) + + it('FE-PAGE-PLANNER-MAPSTAGE-009: the phone POI pill steps aside for drawers and dialogs', () => { + const { rerender } = renderStage() + const portal = screen.getByTestId('mobile-poi-category-pill') + expect(portal.parentElement).toBe(document.body) + expect(portal).toContainElement(screen.getAllByTestId('poi-pill')[1]) + + for (const cover of [{ mobileSidebarOpen: 'left' }, { showPlaceForm: true }, { showMembersModal: true }, { showReservationModal: true }]) { + rerender() + expect(screen.queryByTestId('mobile-poi-category-pill')).toBeNull() + } + }) + + it('FE-PAGE-PLANNER-MAPSTAGE-010: the top cluster is centred on the corridor between the panels', () => { + const { props, container } = renderStage({ mapInsetLeft: 330, mapInsetRight: 290, selectedPlace: place, showDayDetail: { id: 3 } }) + const cluster = container.querySelector('div[style*="translateX(-50%)"][style*="z-index: 25"]') as HTMLElement + + // As the browser normalizes it: half the corridor past the left inset, and no wider than the corridor. + expect(cluster.style.left).toBe('calc(330px + 0.5 * (100% - 330px - 290px))') + expect(cluster.style.maxWidth).toBe('calc(100% - 644px)') + expect(recorded('map')).toMatchObject({ hasInspector: true, hasDayDetail: false, selectedPlace: place }) + const poi = props.poi as unknown as Record + expect(recorded('poiPill')).toMatchObject({ + categories: poi.categories, active: poi.active, onToggle: poi.toggle, + loadingKeys: poi.loadingKeys, errorKeys: poi.errorKeys, moved: false, onSearchArea: poi.searchArea, + }) + }) +}) diff --git a/client/src/pages/tripPlanner/PlanMapStage.tsx b/client/src/pages/tripPlanner/PlanMapStage.tsx new file mode 100644 index 000000000..aa23ae5c3 --- /dev/null +++ b/client/src/pages/tripPlanner/PlanMapStage.tsx @@ -0,0 +1,237 @@ +import type { Dispatch, ReactElement, SetStateAction } from 'react' +import { createPortal } from 'react-dom' +import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto' +import { MapCompassPill, type CompassMap } from '../../components/Map/MapCompassPill' +import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../../components/Map/TripRouteOverview' +import { DawarichTrailPill } from '../../components/Map/DawarichTrailPill' +import PoiCategoryPill from '../../components/Map/PoiCategoryPill' +import type { Poi } from '../../components/Map/poiCategories' +import type { Settings } from '../../types' +import { RoadtripAlternativesBar } from './plannerLazy' +import { LazyPanel } from './LazyPanel' +import type { useTripPlanner } from './useTripPlanner' +import type { useTripPlannerPage } from './useTripPlannerPage' + +type Planner = ReturnType +type PageState = ReturnType + +// Picked per concern so the key lists stay short and typed by the hook that owns them. +type StageTrip = Pick +type StageMap = Pick +type StageRoadtrip = Pick +type StageAlternatives = Pick +type StageOverlays = Pick + +/** What the page works out itself: the panel geometry, the compass map and the POI settings. */ +interface StagePageLocals { + leftPanelPx: number + rightPanelPx: number + mapInsetLeft: number + mapInsetRight: number + glMap: CompassMap | null + setGlMap: Dispatch> + poiPillEnabled: boolean + mapPois: Poi[] + distanceUnit: Settings['distance_unit'] +} + +export interface PlanMapStageProps + extends StageTrip, StageMap, StageRoadtrip, StageAlternatives, StageOverlays, Pick, StagePageLocals {} + +/** + * The plan tab's map and the controls floating over it. It returns a fragment on + * purpose: the children sit straight in the plan container, ahead of the side + * panels, so their stacking and the order of the portals in document.body stay + * exactly what they were when this lived in the page. + */ +export function PlanMapStage({ + tripId, trip, can, days, reservations, isMobile, + selectedDayId, selectedPlaceId, selectedPlace, showDayDetail, handleSelectDay, + mapPlaces, dayPlaces, dayOrderMap, mapTileUrl, fitKey, mapLocked, toggleMapLocked, mapFocusPoints, + route, routeWalking, routeSegments, routeVias, transitRoutesShown, + handleMarkerClick, handleMapClick, handleMapContextMenu, handlePoiClick, + visibleConnections, setMapTransportDetail, openBookingDetail, + roadtripActive, roadtripMapPlaces, roadtripMapLines, roadtripLineColors, roadtripMapVias, + roadtripRoutes, roadtripConnections, roadtripVias, + dayBoundaryControls, dropPoiOnRoute, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, + routeAlternatives, alternativeOverlays, highlightedAlternative, chooseRouteAlternative, setHighlightedAlternative, + overviewActive, tripOverview, overviewShown, toggleOverview, + dawarichEnabled, dawarichTrail, dawarichHiddenDates, dawarichTrailShown, toggleDawarichTrail, + mobileSidebarOpen, showPlaceForm, showMembersModal, showReservationModal, + poi, tourMap, leftPanelPx, rightPanelPx, mapInsetLeft, mapInsetRight, + glMap, setGlMap, poiPillEnabled, mapPois, distanceUnit, +}: Readonly): ReactElement { + return ( + <> + { + const r = reservations.find(x => x.id === rid) + if (!r) return + if (isMobile) setMapTransportDetail(r) + else openBookingDetail(r) + }} + /* In road trip mode the corridor's `visible` (not `search.results`: the map is + the picture of that very list, and filtering the list while seventy pins stay + on the map no longer answers "which of these") plus whatever the category + pill found in view. Outside it, only the pill's hits. */ + pois={mapPois} + onPoiClick={handlePoiClick} + // Only while road trip mode is on: outside it there is no drive to drop onto. + onPoiDropOnRoute={roadtripActive ? dropPoiOnRoute : undefined} + // Clicking the drawn route puts a via there; only in road trip mode, where + // the route is the thing being worked on. + onRouteClick={roadtripActive && can('day_edit', trip) ? addRoadtripVia : undefined} + roadtripVias={roadtripActive ? roadtripVias.byDay : undefined} + alternativeRoutes={alternativeOverlays} + focusPoints={overviewActive ? tripOverview.focusPoints : mapFocusPoints} + clusterLoosely={roadtripActive} + activeAlternative={highlightedAlternative} + onChooseAlternative={chooseRouteAlternative} + onHighlightAlternative={setHighlightedAlternative} + onMoveVia={can('day_edit', trip) ? moveRoadtripVia : undefined} + onRemoveVia={can('day_edit', trip) ? removeRoadtripVia : undefined} + onViewportChange={tourMap.captureViewport} + onMapReady={setGlMap} + /> + + {/* Over the map rather than in a dialog: the answer to "which of these" is the + roads drawn behind it, so covering them to ask would hide the point. */} + {routeAlternatives.open && ( +
+ + + +
+ )} + + {/* Bottom-RIGHT. Not the top corridor between the panels, which is already + contested by the POI bar and the collapse tabs (#2247); and not the + bottom-left corner, where Leaflet's base-layer switcher sits at + z-index 1000 and would cover this. The right corner is free on both + renderers: the locate button that lives there is phone-only. */} + {(!roadtripActive || dawarichEnabled) && ( +
+ {!roadtripActive && overviewActive && ( + + )} + {!roadtripActive && ( + + )} + {/* Stays in road-trip mode, unlike the overview: the route that was + actually driven is the thing you most want beside the planned + one. It is drawn, never applied. Correcting the plan from the + recording is a different feature and deliberately not this one. */} + {dawarichEnabled && ( + + )} +
+ )} + + {(poiPillEnabled || glMap) && ( +
+ {poiPillEnabled && ( + + )} + {glMap && } +
+ )} + + {/* Mobile: the compass/reset-orientation control lives centre-top on its own + (the desktop cluster above is hidden below md), between the edge Plan/Places tabs. */} + {glMap && ( +
+ +
+ )} + + {/* Mobile POI search controls live in a portal like the Plan/Places + buttons so map touch handlers cannot swallow the tap targets. */} + {poiPillEnabled && !mobileSidebarOpen && !showPlaceForm && !showMembersModal && !showReservationModal && createPortal( +
+ +
, + document.body + )} + + ) +}