295 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|