perfect-postcode/frontend/src/components/map/MapPage.tsx
Andras Schmelczer 6ea544a0f6
Some checks failed
CI / Check (push) Failing after 6m52s
Build and publish Docker image / build-and-push (push) Failing after 16m5s
fmt
2026-05-17 19:48:55 +01:00

816 lines
27 KiB
TypeScript

import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import type { MapFlyToOptions, PostcodeGeometry } from '../../types';
import type { SearchedLocation } from './LocationSearch';
import { useMapData } from '../../hooks/useMapData';
import { usePOIData } from '../../hooks/usePOIData';
import { useActualListings } from '../../hooks/useActualListings';
import { buildTravelParam } from '../../lib/travel-params';
import { useFilters } from '../../hooks/useFilters';
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
import { usePaneResize } from '../../hooks/usePaneResize';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import { useAiFilters } from '../../hooks/useAiFilters';
import { useUrlSync } from '../../hooks/useUrlSync';
import { useTutorial } from '../../hooks/useTutorial';
import { getTutorialStyles } from '../../lib/tutorial-styles';
import { travelFieldKey, useTravelTime } from '../../hooks/useTravelTime';
import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
import { useFilterCounts } from '../../hooks/useFilterCounts';
import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE, POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
import { useLicense } from '../../hooks/useLicense';
import { stateToParams } from '../../lib/url-state';
import {
AreaPane,
Filters,
POIPane,
PropertiesPane,
UpgradeModal,
} from './map-page/lazyComponents';
import { PaneFallback } from './map-page/Fallbacks';
import { DesktopMapPage } from './map-page/DesktopMapPage';
import { MobileMapPage } from './map-page/MobileMapPage';
import { ScreenshotMapPage } from './map-page/ScreenshotMapPage';
import { ExportToast } from './map-page/Toasts';
import { MobileMapLegend } from './map-page/MobileMapLegend';
import { useExportController } from './map-page/useExportController';
import {
useHexagonLocation,
useJourneyDestination,
useMapViewFeature,
useMobileDensityRange,
useMobileLegendMeta,
} from './map-page/derivedState';
import {
useHorizontalSwipeNavigationGuard,
useInitialMapPageView,
useInitialPostcodeSelection,
useMobileBackNavigationGuard,
useScreenshotReadySignal,
} from './map-page/effects';
import type { MapFlyTo, MapPageProps } from './map-page/types';
export type { ExportState } from './map-page/types';
type PendingFlyTo = { lat: number; lng: number; zoom: number };
export default function MapPage({
features,
poiCategoryGroups,
initialFilters,
initialViewState,
initialPOICategories,
initialTab,
initialLoading,
theme,
pendingInfoFeature,
onClearPendingInfoFeature,
onNavigateTo,
onExportStateChange,
onDashboardParamsChange,
screenshotMode,
ogMode,
isMobile = false,
initialTravelTime,
initialPostcode,
shareCode,
user,
onLoginClick,
onRegisterClick,
onCheckoutLoginClick,
onCheckoutRegisterClick,
deferTutorial = false,
onSaveSearch,
savingSearch,
editingSearch,
onCancelEdit,
onUpdateEdit,
onUpdateEditInPlace,
}: MapPageProps) {
const { t } = useTranslation();
const [selectedPOICategories, setSelectedPOICategories] =
useState<Set<string>>(initialPOICategories);
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [mobileBottomSheetHeight, setMobileBottomSheetHeight] = useState(0);
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
const {
filters,
activeFeature,
dragValue,
pinnedFeature,
enabledFeatures,
viewFeature,
viewSource,
filterRange,
handleAddFilter,
handleFilterChange,
handleRemoveFilter,
handleSetFilters,
handleDragStart,
handleDragChange,
handleDragEnd,
handleDragEndNoCommit,
handleTogglePin,
handleCancelPin,
} = useFilters({
initialFilters,
features,
});
const {
fetchAiFilters,
loading: aiFilterLoading,
error: aiFilterError,
errorType: aiFilterErrorType,
notes: aiFilterNotes,
summary: aiFilterSummary,
} = useAiFilters();
const {
entries,
activeEntries,
handleAddEntry,
handleRemoveEntry,
handleSetDestination,
handleSetEntries,
handleTimeRangeChange,
handleToggleBest,
} = useTravelTime(initialTravelTime);
const mapFlyToRef = useRef<MapFlyTo | null>(null);
const pendingCurrentLocationFlyToRef = useRef<{ lat: number; lng: number } | null>(null);
const pendingLocationSearchFlyToRef = useRef<PendingFlyTo | null>(null);
const mobileDrawerPanelRectRef = useRef<DOMRectReadOnly | null>(null);
const areaPaneScrollTopRef = useRef(0);
const propertiesPaneScrollTopRef = useRef(0);
const getMobileMapFlyToOptions = useCallback((): MapFlyToOptions | undefined => {
if (!isMobile) return undefined;
const panelRect = mobileDrawerPanelRectRef.current;
if (mobileDrawerOpen && panelRect) {
const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
if (bottomInset > 0) {
return { visibleViewportArea: { bottom: bottomInset } };
}
}
return mobileBottomSheetHeight > 0
? { visibleArea: { bottom: mobileBottomSheetHeight } }
: undefined;
}, [isMobile, mobileBottomSheetHeight, mobileDrawerOpen]);
const mapData = useMapData({
filters,
features,
viewFeature,
activeFeature,
pinnedFeature,
filterRange,
travelTimeEntries: entries,
shareCode,
});
const handleAiFilterSubmit = useCallback(
async (query: string) => {
const context = {
filters,
travelTime: activeEntries.map((entry) => ({
mode: entry.mode,
label: entry.label,
min: entry.timeRange?.[0],
max: entry.timeRange?.[1],
})),
};
const hasContext = Object.keys(context.filters).length > 0 || context.travelTime.length > 0;
const result = await fetchAiFilters(query, hasContext ? context : undefined);
if (!result) return;
handleSetFilters(result.filters);
handleSetEntries(
result.travelTimeFilters.map((travelTimeFilter) => ({
mode: travelTimeFilter.mode,
slug: travelTimeFilter.slug,
label: travelTimeFilter.label,
timeRange: [travelTimeFilter.min ?? 0, travelTimeFilter.max ?? 120] as [number, number],
useBest: false,
}))
);
const firstTravelTime = result.travelTimeFilters[0];
if (!firstTravelTime?.slug) return;
try {
const res = await fetch(
apiUrl('travel-destinations', new URLSearchParams({ mode: firstTravelTime.mode })),
authHeaders({})
);
if (!res.ok) return;
const data: { destinations: { slug: string; lat: number; lon: number }[] } =
await res.json();
const destination = data.destinations.find((item) => item.slug === firstTravelTime.slug);
if (destination) {
mapFlyToRef.current?.(
destination.lat,
destination.lon,
mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom,
getMobileMapFlyToOptions()
);
}
} catch {
// Filters are already applied; destination panning is non-critical.
}
},
[
activeEntries,
fetchAiFilters,
filters,
getMobileMapFlyToOptions,
handleSetEntries,
handleSetFilters,
mapData.currentView?.zoom,
]
);
const handleClearAll = useCallback(() => {
handleSetFilters({});
handleCancelPin();
handleSetEntries([]);
}, [handleSetFilters, handleCancelPin, handleSetEntries]);
const handleTravelTimeRemoveEntry = useCallback(
(index: number) => {
const entry = entries[index];
if (entry?.slug && pinnedFeature === travelFieldKey(entry)) {
handleCancelPin();
}
handleRemoveEntry(index);
},
[handleCancelPin, handleRemoveEntry, entries, pinnedFeature]
);
const handleTravelTimeDragEnd = useCallback(
(index: number) => {
const dragEndValue = handleDragEndNoCommit();
if (dragEndValue) handleTimeRangeChange(index, dragEndValue);
},
[handleDragEndNoCommit, handleTimeRangeChange]
);
const filterCounts = useFilterCounts(filters, features, mapData.bounds, entries, shareCode);
const license = useLicense();
const handleTravelTimeSetDestination = useCallback(
(index: number, slug: string, label: string, _lat: number, _lon: number) => {
handleSetDestination(index, slug, label);
},
[handleSetDestination]
);
const journeyDest = useJourneyDestination(entries);
const {
selectedHexagon,
properties,
propertiesTotal,
loadingProperties,
areaStats,
loadingAreaStats,
unfilteredAreaCount,
areaStatsUseFilters,
setAreaStatsUseFilters,
hoveredHexagon,
rightPaneTab,
setRightPaneTab,
handleHexagonClick,
handleHexagonHover,
handlePropertiesTabClick,
handleLoadMoreProperties,
handleCloseSelection,
selectedPostcodeGeometry,
handleLocationSearch,
handleCurrentLocationSearch,
} = useHexagonSelection({
filters,
features,
hexagonData: mapData.committedHexagonData,
resolution: mapData.resolution,
usePostcodeView: mapData.usePostcodeView,
travelTimeEntries: entries,
shareCode,
journeyDest,
});
const consumePendingLocationSearchFlyTo = useCallback((rect?: DOMRectReadOnly | null) => {
const pending = pendingLocationSearchFlyToRef.current;
const panelRect = rect ?? mobileDrawerPanelRectRef.current;
if (!pending || !panelRect) return;
const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
const flyTo = mapFlyToRef.current;
if (!flyTo) return;
flyTo(pending.lat, pending.lng, pending.zoom, {
visibleViewportArea: { bottom: bottomInset },
});
pendingLocationSearchFlyToRef.current = null;
}, []);
const handleLocationSearchResult = useCallback(
(result: SearchedLocation | null) => {
if (result) {
const markerLat = result.markerLatitude;
const markerLng = result.markerLongitude;
if (markerLat != null && markerLng != null) {
setCurrentLocation({ lat: markerLat, lng: markerLng });
} else {
setCurrentLocation(null);
}
handleLocationSearch(
result.postcode,
result.geometry,
result.latitude,
result.longitude,
result.openProperties,
result.focusAddress
);
if (isMobile) {
pendingLocationSearchFlyToRef.current = {
lat: markerLat ?? result.latitude,
lng: markerLng ?? result.longitude,
zoom: result.openProperties ? 17 : POSTCODE_SEARCH_ZOOM,
};
setMobileDrawerOpen(true);
consumePendingLocationSearchFlyTo();
}
} else {
setCurrentLocation(null);
pendingLocationSearchFlyToRef.current = null;
handleCloseSelection();
}
},
[consumePendingLocationSearchFlyTo, handleCloseSelection, handleLocationSearch, isMobile]
);
const consumePendingCurrentLocationFlyTo = useCallback((rect?: DOMRectReadOnly | null) => {
const pending = pendingCurrentLocationFlyToRef.current;
const panelRect = rect ?? mobileDrawerPanelRectRef.current;
if (!pending || !panelRect) return;
const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
const flyTo = mapFlyToRef.current;
if (!flyTo) return;
flyTo(pending.lat, pending.lng, 17, {
visibleViewportArea: { bottom: bottomInset },
});
pendingCurrentLocationFlyToRef.current = null;
}, []);
const handleCurrentLocationFound = useCallback(
(lat: number, lng: number) => {
if (isMobile) {
pendingCurrentLocationFlyToRef.current = { lat, lng };
consumePendingCurrentLocationFlyTo();
} else {
mapFlyToRef.current?.(lat, lng, 17);
}
setCurrentLocation({ lat, lng });
handleCurrentLocationSearch(lat, lng);
if (isMobile) setMobileDrawerOpen(true);
},
[consumePendingCurrentLocationFlyTo, handleCurrentLocationSearch, isMobile]
);
const handleMobileDrawerPanelRectChange = useCallback(
(rect: DOMRectReadOnly) => {
mobileDrawerPanelRectRef.current = rect;
consumePendingCurrentLocationFlyTo(rect);
consumePendingLocationSearchFlyTo(rect);
},
[consumePendingCurrentLocationFlyTo, consumePendingLocationSearchFlyTo]
);
const handleMobileDrawerClose = useCallback(() => {
pendingCurrentLocationFlyToRef.current = null;
pendingLocationSearchFlyToRef.current = null;
mobileDrawerPanelRectRef.current = null;
setMobileDrawerOpen(false);
}, []);
const shareReturnViewRef = useRef(shareCode ? initialViewState : null);
const handleZoomToFreeZone = useCallback(() => {
const target = shareReturnViewRef.current ?? INITIAL_VIEW_STATE;
mapFlyToRef.current?.(target.latitude, target.longitude, target.zoom);
}, []);
const pois = usePOIData(mapData.bounds, selectedPOICategories);
const actualListingsFilterParam = useMemo(
() => buildFilterString(filters, features),
[filters, features]
);
const actualListingsTravelParam = useMemo(() => buildTravelParam(entries), [entries]);
const { listings: actualListings } = useActualListings(mapData.bounds, {
filterParam: actualListingsFilterParam,
travelParam: actualListingsTravelParam,
});
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
useUrlSync(
mapData.currentView,
filters,
features,
selectedPOICategories,
rightPaneTab,
entries,
shareCode
);
useInitialMapPageView(mapData, initialViewState, initialTab, setRightPaneTab);
useInitialPostcodeSelection({
initialPostcode,
isMobile,
flyTo: mapFlyToRef,
onLocationSearch: handleLocationSearch,
onOpenMobileDrawer: (target) => {
pendingLocationSearchFlyToRef.current = target;
setMobileDrawerOpen(true);
consumePendingLocationSearchFlyTo();
},
});
useHorizontalSwipeNavigationGuard();
useMobileBackNavigationGuard(isMobile);
useScreenshotReadySignal({
screenshotMode,
loading: mapData.loading,
boundsReady: mapData.bounds != null,
dataLength: mapData.data.length,
postcodeDataLength: mapData.postcodeData.length,
usePostcodeView: mapData.usePostcodeView,
licenseRequired: mapData.licenseRequired,
});
const handleMobileHexagonClick = useCallback(
(id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => {
handleHexagonClick(id, isPostcode, geometry);
if (id) {
setMobileDrawerOpen(true);
}
},
[handleHexagonClick]
);
const hexagonLocation = useHexagonLocation(
selectedHexagon,
mapData.postcodeData,
mapData.resolution,
areaStats
);
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial || mapData.licenseRequired);
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
const densityLabel = t('mapLegend.historicalMatches');
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
const mobileLegendMeta = useMobileLegendMeta(viewFeature, features);
const mapViewFeature = useMapViewFeature(viewFeature);
const mobileDensityRange = useMobileDensityRange(mapData);
const { exportNotice, clearExportNotice } = useExportController({
bounds: mapData.bounds,
filters,
features,
travelTimeEntries: entries,
shareCode,
t,
onExportStateChange,
});
const shareAndSaveView = isMobile
? (mapData.currentVisibleView ?? mapData.currentView)
: mapData.currentView;
const dashboardParams = useMemo(
() =>
stateToParams(
shareAndSaveView,
filters,
features,
selectedPOICategories,
rightPaneTab,
entries,
shareCode
).toString(),
[entries, features, filters, rightPaneTab, selectedPOICategories, shareCode, shareAndSaveView]
);
const handleSaveSearch = useCallback(
async (name: string) => {
await onSaveSearch?.(name, dashboardParams);
},
[dashboardParams, onSaveSearch]
);
const handleUpdateEditInPlaceWithParams = useCallback(async () => {
await onUpdateEditInPlace?.(dashboardParams);
}, [dashboardParams, onUpdateEditInPlace]);
const checkoutReturnPath = useMemo(
() => `/dashboard${dashboardParams ? `?${dashboardParams}` : ''}`,
[dashboardParams]
);
useEffect(() => {
onDashboardParamsChange?.(dashboardParams);
}, [dashboardParams, onDashboardParamsChange]);
useEffect(() => {
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
}, [mapData.licenseRequired]);
if (screenshotMode) {
return (
<ScreenshotMapPage
mapData={mapData}
mapViewFeature={mapViewFeature}
filterRange={filterRange}
viewSource={viewSource}
features={features}
initialViewState={initialViewState}
theme={theme}
ogMode={ogMode}
travelTimeEntries={entries}
/>
);
}
const renderAreaPane = () => (
<Suspense fallback={<PaneFallback />}>
<AreaPane
stats={areaStats}
globalFeatures={features}
loading={loadingAreaStats}
hexagonId={selectedHexagon?.id || null}
isPostcode={selectedHexagon?.type === 'postcode'}
hexagonLocation={hexagonLocation}
filters={filters}
unfilteredCount={unfilteredAreaCount}
statsUseFilters={areaStatsUseFilters}
onStatsUseFiltersChange={setAreaStatsUseFilters}
travelTimeEntries={activeEntries}
shareCode={shareCode}
isGroupExpanded={isAreaGroupExpanded}
onToggleGroup={toggleAreaGroup}
scrollTopRef={areaPaneScrollTopRef}
scrollRestoreKey={selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null}
scrollSaveDisabled={loadingAreaStats && areaStats == null}
/>
</Suspense>
);
const renderPropertiesPane = () => (
<Suspense fallback={<PaneFallback />}>
<PropertiesPane
properties={properties}
total={propertiesTotal}
loading={loadingProperties}
hexagonId={selectedHexagon?.id || null}
onLoadMore={handleLoadMoreProperties}
scrollTopRef={propertiesPaneScrollTopRef}
scrollRestoreKey={selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null}
scrollSaveDisabled={loadingProperties && properties.length === 0}
/>
</Suspense>
);
const renderPOIPane = () => (
<Suspense fallback={<PaneFallback />}>
<POIPane
groups={poiCategoryGroups}
selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length}
onClose={() => setPoiPaneOpen(false)}
/>
</Suspense>
);
const renderFilters = (options?: { destinationDropdownPortal?: boolean }) => (
<Suspense fallback={<PaneFallback />}>
<Filters
features={features}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
enabledFeatures={enabledFeatures}
onAddFilter={handleAddFilter}
onRemoveFilter={handleRemoveFilter}
onFilterChange={handleFilterChange}
onDragStart={handleDragStart}
onDragChange={handleDragChange}
onDragEnd={handleDragEnd}
pinnedFeature={pinnedFeature}
onTogglePin={handleTogglePin}
openInfoFeature={pendingInfoFeature}
onClearOpenInfoFeature={onClearPendingInfoFeature}
travelTimeEntries={entries}
onTravelTimeAddEntry={handleAddEntry}
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
onTravelTimeSetDestination={handleTravelTimeSetDestination}
onTravelTimeRangeChange={handleTimeRangeChange}
onTravelTimeDragEnd={handleTravelTimeDragEnd}
onTravelTimeToggleBest={handleToggleBest}
aiFilterLoading={aiFilterLoading}
aiFilterError={aiFilterError}
aiFilterErrorType={aiFilterErrorType}
aiFilterNotes={aiFilterNotes}
aiFilterSummary={aiFilterSummary}
onAiFilterSubmit={handleAiFilterSubmit}
isLoggedIn={!!user}
onLoginRequired={onRegisterClick}
isLicensed={user?.subscription === 'licensed'}
onUpgradeClick={() => onNavigateTo('pricing')}
onResetTutorial={!isMobile ? tutorial.resetTutorial : undefined}
filterImpacts={filterCounts.impacts}
onClearAll={handleClearAll}
onSaveSearch={onSaveSearch ? handleSaveSearch : undefined}
savingSearch={savingSearch}
editingSearchName={editingSearch?.name ?? null}
onUpdateSearch={
editingSearch && onUpdateEditInPlace ? handleUpdateEditInPlaceWithParams : undefined
}
onExitEditing={onCancelEdit}
destinationDropdownPortal={options?.destinationDropdownPortal}
/>
</Suspense>
);
const handleTogglePoiPane = () => setPoiPaneOpen((open) => !open);
const handleMobileDrawerTabChange = (tab: 'area' | 'properties') => {
if (tab === 'properties') {
handlePropertiesTabClick();
} else {
setRightPaneTab(tab);
}
};
const exportToast = (
<ExportToast notice={exportNotice} closeLabel={t('common.close')} onClose={clearExportNotice} />
);
const toasts = exportToast;
const editingBar =
editingSearch && isMobile ? (
<div className="flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700 bg-warm-50 dark:bg-navy-900">
<span
className="flex-1 min-w-0 truncate text-xs text-warm-700 dark:text-warm-200"
title={editingSearch.name}
>
<Trans
i18nKey="savedPage.isBeingUpdated"
values={{ name: editingSearch.name }}
components={{
strong: <strong className="font-semibold text-navy-950 dark:text-warm-100" />,
}}
/>
</span>
<button
onClick={onCancelEdit}
className="shrink-0 cursor-pointer px-2.5 py-1 rounded text-xs font-medium border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-200 hover:bg-warm-100 dark:hover:bg-navy-800"
>
{t('common.cancel')}
</button>
<button
onClick={() => onUpdateEdit?.(dashboardParams)}
disabled={savingSearch}
className="shrink-0 cursor-pointer px-2.5 py-1 rounded text-xs font-medium bg-teal-600 text-white hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait flex items-center gap-1.5"
>
{savingSearch ? t('savedPage.updating') : t('common.update')}
</button>
</div>
) : null;
const upgradeModal = mapData.licenseRequired ? (
<Suspense fallback={null}>
<UpgradeModal
isLoggedIn={!!user}
onLoginClick={() =>
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
}
onRegisterClick={() =>
onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick()
}
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
onZoomToFreeZone={handleZoomToFreeZone}
isShareReturn={!!shareReturnViewRef.current}
/>
</Suspense>
) : null;
if (isMobile) {
return (
<MobileMapPage
initialLoading={initialLoading}
mapData={mapData}
pois={pois}
mapViewFeature={mapViewFeature}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={handleCancelPin}
features={features}
selectedHexagonId={selectedHexagon?.id || null}
hoveredHexagonId={hoveredHexagon}
onHexagonClick={handleMobileHexagonClick}
onHexagonHover={handleHexagonHover}
initialViewState={initialViewState}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
onCurrentLocationFound={handleCurrentLocationFound}
currentLocation={currentLocation}
actualListings={actualListings}
travelTimeEntries={entries}
bottomScreenInset={mobileBottomSheetHeight}
onBottomSheetCoveredHeightChange={setMobileBottomSheetHeight}
mobileDrawerOpen={mobileDrawerOpen}
onMobileDrawerClose={handleMobileDrawerClose}
onMobileDrawerPanelRectChange={handleMobileDrawerPanelRectChange}
rightPaneTab={rightPaneTab}
onMobileDrawerTabChange={handleMobileDrawerTabChange}
poiPaneOpen={poiPaneOpen}
onTogglePoiPane={handleTogglePoiPane}
poiButtonLabel={t('poiPane.pointsOfInterest')}
poiPane={renderPOIPane()}
filtersPane={renderFilters({ destinationDropdownPortal: false })}
mobileLegend={
<MobileMapLegend
mapViewFeature={mapViewFeature}
colorRange={mapData.colorRange}
viewSource={viewSource}
mobileLegendMeta={mobileLegendMeta}
densityLabel={densityLabel}
densityRange={mobileDensityRange}
theme={theme}
canResetPreviewScale={mapData.canResetPreviewScale}
onCancelPin={handleCancelPin}
onResetPreviewScale={mapData.handleResetPreviewScale}
/>
}
renderAreaPane={renderAreaPane}
renderPropertiesPane={renderPropertiesPane}
toasts={toasts}
upgradeModal={upgradeModal}
editingBar={editingBar}
/>
);
}
return (
<DesktopMapPage
initialLoading={initialLoading}
tutorial={tutorial}
tutorialTheme={tutorialTheme}
leftPaneWidth={leftPaneWidth}
leftPaneHandlers={leftPaneHandlers}
filtersPane={renderFilters()}
mapData={mapData}
pois={pois}
mapViewFeature={mapViewFeature}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={handleCancelPin}
features={features}
selectedHexagonId={selectedHexagon?.id || null}
hoveredHexagonId={hoveredHexagon}
onHexagonClick={handleHexagonClick}
onHexagonHover={handleHexagonHover}
initialViewState={initialViewState}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
onCurrentLocationFound={handleCurrentLocationFound}
currentLocation={currentLocation}
actualListings={actualListings}
travelTimeEntries={entries}
densityLabel={densityLabel}
totalCount={hasActiveFilters ? filterCounts.total : undefined}
poiPaneOpen={poiPaneOpen}
onTogglePoiPane={handleTogglePoiPane}
poiPane={renderPOIPane()}
showSelectionPane={!!selectedHexagon}
rightPaneWidth={rightPaneWidth}
rightPaneHandlers={rightPaneHandlers}
rightPaneTab={rightPaneTab}
onAreaTabClick={() => setRightPaneTab('area')}
onPropertiesTabClick={handlePropertiesTabClick}
onCloseSelection={handleCloseSelection}
renderAreaPane={renderAreaPane}
renderPropertiesPane={renderPropertiesPane}
toasts={toasts}
upgradeModal={upgradeModal}
/>
);
}