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.
This commit is contained in:
Maurice
2026-10-08 01:20:29 +00:00
parent 7d8dcb02c8
commit b8c1fbf192
4 changed files with 470 additions and 162 deletions
+1 -1
View File
@@ -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,
+19 -161
View File
@@ -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' && (
<div style={{ position: 'absolute', inset: 0 }}>
<MapView
tripId={tripId}
dawarichTrack={dawarichTrail.track}
dawarichHiddenDates={dawarichHiddenDates}
places={roadtripActive ? roadtripMapPlaces : mapPlaces}
dayPlaces={dayPlaces}
route={roadtripActive ? roadtripMapLines : overviewActive ? tripOverview.lines : route}
routeColors={roadtripActive ? roadtripLineColors : overviewActive ? tripOverview.lineColors : undefined}
routeWalking={roadtripActive || overviewActive ? undefined : routeWalking}
followSelection={!mapLocked || isMobile}
onToggleFollow={isMobile ? undefined : toggleMapLocked}
routeVias={roadtripActive ? roadtripMapVias : routeVias}
dayBoundaryControls={roadtripActive ? dayBoundaryControls : undefined}
accessLines={roadtripActive ? roadtripRoutes.accessLines : undefined}
showTransitRoutes={transitRoutesShown}
// The route toggle belongs to one day, so the map needs that day to
// know which automated transports may ride it (#2019).
days={days}
selectedDayId={selectedDayId}
routeSegments={roadtripActive ? roadtripRoutes.segments : overviewActive ? tripOverview.segments : routeSegments}
selectedPlaceId={selectedPlaceId}
selectedPlace={selectedPlace}
onMarkerClick={handleMarkerClick}
onMapClick={handleMapClick}
onMapContextMenu={handleMapContextMenu}
// No center/zoom: the map frames itself on the trip's places at mount, and
// falls back to the world view when the trip has none.
tileUrl={mapTileUrl}
fitKey={fitKey}
dayOrderMap={dayOrderMap}
leftWidth={leftPanelPx}
rightWidth={rightPanelPx}
hasInspector={!!selectedPlace}
hasDayDetail={!!showDayDetail && !selectedPlace}
reservations={reservations}
showReservationStats={true}
// In road trip mode the rides that seam the drive are drawn as their own arcs
// beside the roads, on top of what the reader switched on under Days.
visibleConnectionIds={roadtripActive ? roadtripConnections : visibleConnections}
// The desktop plan shows the booking's detail; the narrow layout keeps the day
// list's transport view.
onReservationClick={(rid) => {
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}
<PlanMapStage
tripId={tripId} trip={trip} can={can} days={days} reservations={reservations} isMobile={isMobile}
selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedPlace={selectedPlace} showDayDetail={showDayDetail} handleSelectDay={handleSelectDay}
mapPlaces={mapPlaces} dayPlaces={dayPlaces} dayOrderMap={dayOrderMap} mapTileUrl={mapTileUrl} fitKey={fitKey} mapLocked={mapLocked} toggleMapLocked={toggleMapLocked} mapFocusPoints={mapFocusPoints}
route={route} routeWalking={routeWalking} routeSegments={routeSegments} routeVias={routeVias} transitRoutesShown={transitRoutesShown}
handleMarkerClick={handleMarkerClick} handleMapClick={handleMapClick} handleMapContextMenu={handleMapContextMenu} handlePoiClick={handlePoiClick}
visibleConnections={visibleConnections} setMapTransportDetail={setMapTransportDetail} openBookingDetail={openBookingDetail}
roadtripActive={roadtripActive} roadtripMapPlaces={roadtripMapPlaces} roadtripMapLines={roadtripMapLines} roadtripLineColors={roadtripLineColors} roadtripMapVias={roadtripMapVias}
roadtripRoutes={roadtripRoutes} roadtripConnections={roadtripConnections} roadtripVias={roadtripVias}
dayBoundaryControls={dayBoundaryControls} dropPoiOnRoute={dropPoiOnRoute} addRoadtripVia={addRoadtripVia} moveRoadtripVia={moveRoadtripVia} removeRoadtripVia={removeRoadtripVia}
routeAlternatives={routeAlternatives} alternativeOverlays={alternativeOverlays} highlightedAlternative={highlightedAlternative} chooseRouteAlternative={chooseRouteAlternative} setHighlightedAlternative={setHighlightedAlternative}
overviewActive={overviewActive} tripOverview={tripOverview} overviewShown={overviewShown} toggleOverview={toggleOverview}
dawarichEnabled={dawarichEnabled} dawarichTrail={dawarichTrail} dawarichHiddenDates={dawarichHiddenDates} dawarichTrailShown={dawarichTrailShown} toggleDawarichTrail={toggleDawarichTrail}
mobileSidebarOpen={mobileSidebarOpen} showPlaceForm={showPlaceForm} showMembersModal={showMembersModal} showReservationModal={showReservationModal}
poi={poi} tourMap={tourMap} leftPanelPx={leftPanelPx} rightPanelPx={rightPanelPx} mapInsetLeft={mapInsetLeft} mapInsetRight={mapInsetRight}
glMap={glMap} setGlMap={setGlMap} poiPillEnabled={poiPillEnabled} mapPois={mapPois} distanceUnit={distanceUnit}
/>
{/* 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 && (
<div style={{ position: 'absolute', bottom: 18, left: '50%', transform: 'translateX(-50%)', zIndex: 26, pointerEvents: 'none', display: 'flex', justifyContent: 'center' }}>
<LazyPanel id="roadtrip-alternatives">
<RoadtripAlternativesBar
open={routeAlternatives.open}
overlays={alternativeOverlays}
onChoose={chooseRouteAlternative}
onClose={routeAlternatives.close}
onHighlight={setHighlightedAlternative}
/>
</LazyPanel>
</div>
)}
{/* 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) && (
<div className="hidden md:flex" style={{
position: 'absolute', bottom: 18, right: mapInsetRight + 14, zIndex: 26,
pointerEvents: 'none', flexDirection: 'column', alignItems: 'flex-end', gap: 8,
}}>
{!roadtripActive && overviewActive && (
<TripRouteOverviewPanel
overview={tripOverview}
unit={distanceUnit}
selectedDayId={selectedDayId}
onSelectDay={handleSelectDay}
/>
)}
{!roadtripActive && (
<TripRouteOverviewPill active={overviewShown} onToggle={toggleOverview} />
)}
{/* 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 && (
<DawarichTrailPill
active={dawarichTrailShown}
status={dawarichTrail.status}
onToggle={toggleDawarichTrail}
/>
)}
</div>
)}
{(poiPillEnabled || glMap) && (
<div className="hidden md:flex" style={{
position: 'absolute', top: 14,
// Centred on the corridor the panels leave, not on the viewport: at
// 860px the viewport centre sits under the Places panel, where this
// cluster covered both collapse tabs and Add Place/Activity (#2247).
left: `calc(${mapInsetLeft}px + (100% - ${mapInsetLeft}px - ${mapInsetRight}px) / 2)`,
// No wider than that corridor either: once plugins add categories the pill
// scrolls inside it instead of running on under a panel.
maxWidth: `calc(100% - ${mapInsetLeft}px - ${mapInsetRight}px - 24px)`,
transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none', alignItems: 'flex-start', gap: 8,
}}>
{poiPillEnabled && (
<PoiCategoryPill categories={poi.categories} active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} errorKeys={poi.errorKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
)}
{glMap && <MapCompassPill map={glMap} />}
</div>
)}
{/* 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 && (
<div className="flex md:hidden" style={{ position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none' }}>
<MapCompassPill map={glMap} />
</div>
)}
{/* 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(
<div data-testid="mobile-poi-category-pill" className="flex md:hidden" style={{ position: 'fixed', insetInline: 12, bottom: 'calc(var(--bottom-nav-h, 0px) + 12px)', justifyContent: 'center', zIndex: 100, pointerEvents: 'none' }}>
<PoiCategoryPill categories={poi.categories} active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} errorKeys={poi.errorKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
</div>,
document.body
)}
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
{/* The panel's tab: a flap on its edge while open, a raised accent tile once it is tucked away. */}
<Tooltip label={leftHidden ? t('trip.mobilePlan') : t('common.collapse')} placement="right">
@@ -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<string, unknown> & Record<`on${string}`, (...args: unknown[]) => unknown>
const captured = vi.hoisted(() => ({} as Record<string, Record<string, unknown>>))
const stub = vi.hoisted(() => (name: string, testId: string) => (props: Record<string, unknown>) => {
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<Record<keyof PlanMapStageProps, unknown>> = {}): PlanMapStageProps {
const base: Partial<Record<keyof PlanMapStageProps, unknown>> = {
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<Record<keyof PlanMapStageProps, unknown>> = {}) {
const props = stageProps(overrides)
return { props, ...render(<PlanMapStage {...props} />) }
}
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(<PlanMapStage {...stageProps({ poiPillEnabled: false, glMap })} />)
// 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(<PlanMapStage {...stageProps(cover)} />)
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<string, unknown>
expect(recorded('poiPill')).toMatchObject({
categories: poi.categories, active: poi.active, onToggle: poi.toggle,
loadingKeys: poi.loadingKeys, errorKeys: poi.errorKeys, moved: false, onSearchArea: poi.searchArea,
})
})
})
@@ -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<typeof useTripPlanner>
type PageState = ReturnType<typeof useTripPlannerPage>
// Picked per concern so the key lists stay short and typed by the hook that owns them.
type StageTrip = Pick<Planner, 'tripId' | 'trip' | 'can' | 'days' | 'reservations' | 'isMobile' | 'selectedDayId' | 'selectedPlaceId' | 'selectedPlace' | 'showDayDetail' | 'handleSelectDay'>
type StageMap = Pick<Planner,
| 'mapPlaces' | 'dayPlaces' | 'dayOrderMap' | 'mapTileUrl' | 'fitKey' | 'mapLocked' | 'toggleMapLocked' | 'mapFocusPoints'
| 'route' | 'routeWalking' | 'routeSegments' | 'routeVias' | 'transitRoutesShown'
| 'handleMarkerClick' | 'handleMapClick' | 'handleMapContextMenu' | 'handlePoiClick'
| 'visibleConnections' | 'setMapTransportDetail' | 'openBookingDetail'
>
type StageRoadtrip = Pick<Planner,
| 'roadtripActive' | 'roadtripMapPlaces' | 'roadtripMapLines' | 'roadtripLineColors' | 'roadtripMapVias' | 'roadtripRoutes' | 'roadtripConnections' | 'roadtripVias'
| 'dayBoundaryControls' | 'dropPoiOnRoute' | 'addRoadtripVia' | 'moveRoadtripVia' | 'removeRoadtripVia'
>
type StageAlternatives = Pick<Planner, 'routeAlternatives' | 'alternativeOverlays' | 'highlightedAlternative' | 'chooseRouteAlternative' | 'setHighlightedAlternative'>
type StageOverlays = Pick<Planner,
| 'overviewActive' | 'tripOverview' | 'overviewShown' | 'toggleOverview'
| 'dawarichEnabled' | 'dawarichTrail' | 'dawarichHiddenDates' | 'dawarichTrailShown' | 'toggleDawarichTrail'
| 'mobileSidebarOpen' | 'showPlaceForm' | 'showMembersModal' | 'showReservationModal'
>
/** 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<SetStateAction<CompassMap | null>>
poiPillEnabled: boolean
mapPois: Poi[]
distanceUnit: Settings['distance_unit']
}
export interface PlanMapStageProps
extends StageTrip, StageMap, StageRoadtrip, StageAlternatives, StageOverlays, Pick<PageState, 'poi' | 'tourMap'>, 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<PlanMapStageProps>): ReactElement {
return (
<>
<MapView
tripId={tripId}
dawarichTrack={dawarichTrail.track}
dawarichHiddenDates={dawarichHiddenDates}
places={roadtripActive ? roadtripMapPlaces : mapPlaces}
dayPlaces={dayPlaces}
route={roadtripActive ? roadtripMapLines : overviewActive ? tripOverview.lines : route}
routeColors={roadtripActive ? roadtripLineColors : overviewActive ? tripOverview.lineColors : undefined}
routeWalking={roadtripActive || overviewActive ? undefined : routeWalking}
followSelection={!mapLocked || isMobile}
onToggleFollow={isMobile ? undefined : toggleMapLocked}
routeVias={roadtripActive ? roadtripMapVias : routeVias}
dayBoundaryControls={roadtripActive ? dayBoundaryControls : undefined}
accessLines={roadtripActive ? roadtripRoutes.accessLines : undefined}
showTransitRoutes={transitRoutesShown}
// The route toggle belongs to one day, so the map needs that day to
// know which automated transports may ride it (#2019).
days={days}
selectedDayId={selectedDayId}
routeSegments={roadtripActive ? roadtripRoutes.segments : overviewActive ? tripOverview.segments : routeSegments}
selectedPlaceId={selectedPlaceId}
selectedPlace={selectedPlace}
onMarkerClick={handleMarkerClick}
onMapClick={handleMapClick}
onMapContextMenu={handleMapContextMenu}
// No center/zoom: the map frames itself on the trip's places at mount, and
// falls back to the world view when the trip has none.
tileUrl={mapTileUrl}
fitKey={fitKey}
dayOrderMap={dayOrderMap}
leftWidth={leftPanelPx}
rightWidth={rightPanelPx}
hasInspector={!!selectedPlace}
hasDayDetail={!!showDayDetail && !selectedPlace}
reservations={reservations}
showReservationStats={true}
// In road trip mode the rides that seam the drive are drawn as their own arcs
// beside the roads, on top of what the reader switched on under Days.
visibleConnectionIds={roadtripActive ? roadtripConnections : visibleConnections}
// The desktop plan shows the booking's detail; the narrow layout keeps the day
// list's transport view.
onReservationClick={(rid) => {
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 && (
<div style={{ position: 'absolute', bottom: 18, left: '50%', /* rtl-lint-disable: centred on the map */ transform: 'translateX(-50%)', zIndex: 26, pointerEvents: 'none', display: 'flex', justifyContent: 'center' }}>
<LazyPanel id="roadtrip-alternatives">
<RoadtripAlternativesBar
open={routeAlternatives.open}
overlays={alternativeOverlays}
onChoose={chooseRouteAlternative}
onClose={routeAlternatives.close}
onHighlight={setHighlightedAlternative}
/>
</LazyPanel>
</div>
)}
{/* 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) && (
<div className="hidden md:flex" style={{
position: 'absolute', bottom: 18, right: mapInsetRight + 14, zIndex: 26, // rtl-lint-disable: measured from the right panel's inset
pointerEvents: 'none', flexDirection: 'column', alignItems: 'flex-end', gap: 8,
}}>
{!roadtripActive && overviewActive && (
<TripRouteOverviewPanel
overview={tripOverview}
unit={distanceUnit}
selectedDayId={selectedDayId}
onSelectDay={handleSelectDay}
/>
)}
{!roadtripActive && (
<TripRouteOverviewPill active={overviewShown} onToggle={toggleOverview} />
)}
{/* 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 && (
<DawarichTrailPill
active={dawarichTrailShown}
status={dawarichTrail.status}
onToggle={toggleDawarichTrail}
/>
)}
</div>
)}
{(poiPillEnabled || glMap) && (
<div className="hidden md:flex" style={{
position: 'absolute', top: 14,
// Centred on the corridor the panels leave, not on the viewport: at
// 860px the viewport centre sits under the Places panel, where this
// cluster covered both collapse tabs and Add Place/Activity (#2247).
left: `calc(${mapInsetLeft}px + (100% - ${mapInsetLeft}px - ${mapInsetRight}px) / 2)`, // rtl-lint-disable: centred between the measured panel insets
// No wider than that corridor either: once plugins add categories the pill
// scrolls inside it instead of running on under a panel.
maxWidth: `calc(100% - ${mapInsetLeft}px - ${mapInsetRight}px - 24px)`,
transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none', alignItems: 'flex-start', gap: 8,
}}>
{poiPillEnabled && (
<PoiCategoryPill categories={poi.categories} active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} errorKeys={poi.errorKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
)}
{glMap && <MapCompassPill map={glMap} />}
</div>
)}
{/* 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 && (
<div className="flex md:hidden" style={{ position: 'absolute', top: 14, left: '50%', /* rtl-lint-disable: centred on the map */ transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none' }}>
<MapCompassPill map={glMap} />
</div>
)}
{/* 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(
<div data-testid="mobile-poi-category-pill" className="flex md:hidden" style={{ position: 'fixed', insetInline: 12, bottom: 'calc(var(--bottom-nav-h, 0px) + 12px)', justifyContent: 'center', zIndex: 100, pointerEvents: 'none' }}>
<PoiCategoryPill categories={poi.categories} active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} errorKeys={poi.errorKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
</div>,
document.body
)}
</>
)
}