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>(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(null); const pendingCurrentLocationFlyToRef = useRef<{ lat: number; lng: number } | null>(null); const pendingLocationSearchFlyToRef = useRef(null); const mobileDrawerPanelRectRef = useRef(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 ( ); } const renderAreaPane = () => ( }> ); const renderPropertiesPane = () => ( }> ); const renderPOIPane = () => ( }> setPoiPaneOpen(false)} /> ); const renderFilters = (options?: { destinationDropdownPortal?: boolean }) => ( }> 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} /> ); const handleTogglePoiPane = () => setPoiPaneOpen((open) => !open); const handleMobileDrawerTabChange = (tab: 'area' | 'properties') => { if (tab === 'properties') { handlePropertiesTabClick(); } else { setRightPaneTab(tab); } }; const exportToast = ( ); const toasts = exportToast; const editingBar = editingSearch && isMobile ? (
, }} />
) : null; const upgradeModal = mapData.licenseRequired ? ( onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick() } onRegisterClick={() => onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick() } onStartCheckout={() => license.startCheckout(checkoutReturnPath)} onZoomToFreeZone={handleZoomToFreeZone} isShareReturn={!!shareReturnViewRef.current} /> ) : null; if (isMobile) { return ( } renderAreaPane={renderAreaPane} renderPropertiesPane={renderPropertiesPane} toasts={toasts} upgradeModal={upgradeModal} editingBar={editingBar} /> ); } return ( setRightPaneTab('area')} onPropertiesTabClick={handlePropertiesTabClick} onCloseSelection={handleCloseSelection} renderAreaPane={renderAreaPane} renderPropertiesPane={renderPropertiesPane} toasts={toasts} upgradeModal={upgradeModal} /> ); }