perfect-postcode/frontend/src/components/map/map-page/MobileMapPage.tsx

295 lines
10 KiB
TypeScript

import { Suspense, type MutableRefObject, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import type {
ActualListing,
FeatureFilters,
FeatureMeta,
POI,
PostcodeGeometry,
ViewState,
} from '../../../types';
import type { useMapData } from '../../../hooks/useMapData';
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import type { OverlayId } from '../../../lib/overlays';
import type { BasemapId } from '../../../lib/basemaps';
import type { SearchedLocation } from '../LocationSearch';
import MobileBottomSheet from '../MobileBottomSheet';
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
import { EyeIcon } from '../../ui/icons/EyeIcon';
import { HouseIcon } from '../../ui/icons/HouseIcon';
import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
import type { MapFlyTo } from './types';
import { MapControlButton, type MapControlState } from './MapControlButton';
import { MapFallback, PaneFallback } from './Fallbacks';
import { MapErrorBoundary } from '../MapErrorBoundary';
import { LoadingOverlay } from './LoadingOverlay';
import { Map, MobileDrawer } from './lazyComponents';
type MapData = ReturnType<typeof useMapData>;
type RightPaneTab = 'properties' | 'area';
interface MobileMapPageProps {
initialLoading: boolean;
mapData: MapData;
pois: POI[];
activeOverlays: Set<OverlayId>;
activeCrimeTypes: Set<string>;
basemap: BasemapId;
colorOpacity: number;
mapViewFeature: string | null;
filterRange: [number, number] | null;
viewSource: 'drag' | 'eye' | null;
onCancelPin: () => void;
features: FeatureMeta[];
selectedHexagonId: string | null;
hoveredHexagonId: string | null;
onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
initialViewState: ViewState;
flyToRef: MutableRefObject<MapFlyTo | null>;
theme: 'light' | 'dark';
filters: FeatureFilters;
selectedPostcodeGeometry: PostcodeGeometry | null;
onLocationSearched: (location: SearchedLocation | null) => void;
onCurrentLocationFound: (lat: number, lng: number) => void;
currentLocation: { lat: number; lng: number } | null;
actualListings: ActualListing[];
actualListingsEnabled: boolean;
actualListingsLoading: boolean;
onToggleListingsPane?: () => void;
listingsPaneOpen: boolean;
listingsPane: ReactNode;
travelTimeEntries: TravelTimeEntry[];
bottomScreenInset: number;
onBottomSheetCoveredHeightChange: (height: number) => void;
mobileDrawerOpen: boolean;
onMobileDrawerClose: () => void;
onMobileDrawerPanelRectChange: (rect: DOMRectReadOnly) => void;
rightPaneTab: RightPaneTab;
onMobileDrawerTabChange: (tab: RightPaneTab) => void;
poiPaneOpen: boolean;
onTogglePoiPane: () => void;
poiButtonLabel: string;
poiPane: ReactNode;
poiControl: MapControlState;
overlayPaneOpen: boolean;
onToggleOverlayPane: () => void;
overlayPane: ReactNode;
overlayControl: MapControlState;
filtersPane: ReactNode;
mobileLegend: ReactNode;
renderAreaPane: () => ReactNode;
renderPropertiesPane: () => ReactNode;
toasts: ReactNode;
upgradeModal: ReactNode;
editingBar?: ReactNode;
}
export function MobileMapPage({
initialLoading,
mapData,
pois,
activeOverlays,
activeCrimeTypes,
basemap,
colorOpacity,
mapViewFeature,
filterRange,
viewSource,
onCancelPin,
features,
selectedHexagonId,
hoveredHexagonId,
onHexagonClick,
onHexagonHover,
initialViewState,
flyToRef,
theme,
filters,
selectedPostcodeGeometry,
onLocationSearched,
onCurrentLocationFound,
currentLocation,
actualListings,
actualListingsEnabled,
actualListingsLoading,
onToggleListingsPane,
listingsPaneOpen,
listingsPane,
travelTimeEntries,
bottomScreenInset,
onBottomSheetCoveredHeightChange,
mobileDrawerOpen,
onMobileDrawerClose,
onMobileDrawerPanelRectChange,
rightPaneTab,
onMobileDrawerTabChange,
poiPaneOpen,
onTogglePoiPane,
poiButtonLabel,
poiPane,
poiControl,
overlayPaneOpen,
onToggleOverlayPane,
overlayPane,
overlayControl,
filtersPane,
mobileLegend,
renderAreaPane,
renderPropertiesPane,
toasts,
upgradeModal,
editingBar,
}: MobileMapPageProps) {
const { t } = useTranslation();
const floatingPaneAvailableHeight = `max(12rem, calc(100dvh - ${Math.ceil(
bottomScreenInset
)}px - 7rem))`;
// The action buttons (listing/overlays/POI) are pinned to the top-right over
// the map. Reserve their width on the right of the top-card overlay so the
// expanded search bar shrinks to fit instead of sliding underneath them.
// Each button is p-2 + a 1.25rem icon (36px); they sit in a `gap-2` (8px) row.
const ACTION_BUTTON_WIDTH = 36;
const ACTION_BUTTON_GAP = 8;
const actionButtonCount = (onToggleListingsPane ? 1 : 0) + 2;
const topCardsRightInset =
actionButtonCount * ACTION_BUTTON_WIDTH + actionButtonCount * ACTION_BUTTON_GAP; // inter-button gaps + breathing room before the search
return (
<div className="flex-1 overflow-hidden relative">
<LoadingOverlay show={initialLoading} />
<div className="absolute inset-0">
<IndeterminateProgressBar show={mapData.loading && !initialLoading} />
<MapErrorBoundary>
<Suspense fallback={<MapFallback />}>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
activeOverlays={activeOverlays}
activeCrimeTypes={activeCrimeTypes}
basemap={basemap}
colorOpacity={colorOpacity}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={onCancelPin}
onResetPreviewScale={mapData.handleResetPreviewScale}
canResetPreviewScale={mapData.canResetPreviewScale}
features={features}
selectedHexagonId={selectedHexagonId}
hoveredHexagonId={hoveredHexagonId}
onHexagonClick={onHexagonClick}
onHexagonHover={onHexagonHover}
initialViewState={initialViewState}
flyToRef={flyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
currentLocation={currentLocation}
actualListings={actualListings}
bounds={mapData.bounds}
hideLegend
topCardsRightInset={topCardsRightInset}
travelTimeEntries={travelTimeEntries}
bottomScreenInset={bottomScreenInset}
/>
</Suspense>
</MapErrorBoundary>
</div>
<div className="absolute right-3 top-3 z-20 flex max-w-[calc(100%_-_1.5rem)] flex-row flex-wrap justify-end gap-2">
{onToggleListingsPane && (
<button
type="button"
onClick={onToggleListingsPane}
className={`rounded-lg bg-white p-2 shadow-lg dark:bg-warm-800 ${actualListingsEnabled ? 'text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' : 'text-warm-500 hover:text-red-600 dark:text-warm-400 dark:hover:text-red-400'}`}
aria-expanded={listingsPaneOpen}
aria-busy={actualListingsLoading}
aria-label={t('map.actualListings.label')}
title={t('map.actualListings.label')}
>
{actualListingsLoading ? (
<SpinnerIcon className="h-5 w-5 animate-spin" />
) : (
<HouseIcon className="h-5 w-5" />
)}
</button>
)}
<MapControlButton
{...overlayControl}
label={t('overlays.heading')}
paneOpen={overlayPaneOpen}
showLabel={false}
onClick={onToggleOverlayPane}
icon={(highlighted) => <EyeIcon className="h-5 w-5" filled={highlighted} />}
/>
<MapControlButton
{...poiControl}
label={poiButtonLabel}
paneOpen={poiPaneOpen}
showLabel={false}
onClick={onTogglePoiPane}
icon={() => <MapPinIcon className="h-5 w-5" />}
/>
</div>
{listingsPaneOpen && (
<div className="absolute top-24 right-3 left-3 z-20 flex max-h-[60dvh] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
{listingsPane}
</div>
)}
{overlayPaneOpen && (
<div className="absolute top-24 right-3 left-3 z-20 flex max-h-[60dvh] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
{overlayPane}
</div>
)}
{poiPaneOpen && (
<div
className="absolute top-24 right-3 left-3 z-20 flex min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900"
style={{
height: `min(22rem, ${floatingPaneAvailableHeight})`,
maxHeight: floatingPaneAvailableHeight,
}}
>
{poiPane}
</div>
)}
<MobileBottomSheet
legend={mobileLegend}
editingBar={editingBar}
onCoveredHeightChange={onBottomSheetCoveredHeightChange}
>
{filtersPane}
</MobileBottomSheet>
{mobileDrawerOpen && selectedHexagonId && (
<Suspense fallback={<PaneFallback />}>
<MobileDrawer
onClose={onMobileDrawerClose}
renderArea={renderAreaPane}
renderProperties={renderPropertiesPane}
tab={rightPaneTab}
onPanelRectChange={onMobileDrawerPanelRectChange}
onTabChange={onMobileDrawerTabChange}
/>
</Suspense>
)}
{toasts}
{upgradeModal}
</div>
);
}