import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState, PostcodeGeometry } from '../../types'; import type { SearchedLocation } from './LocationSearch'; import type { Page } from '../ui/Header'; import Map from './Map'; import Filters from './Filters'; import POIPane from './POIPane'; import { PropertiesPane } from './PropertiesPane'; import AreaPane from './AreaPane'; import MobileDrawer from './MobileDrawer'; import MapLegend from './MapLegend'; import { TabButton } from '../ui/TabButton'; import { useMapData } from '../../hooks/useMapData'; import { usePOIData } from '../../hooks/usePOIData'; import { useFilters } from '../../hooks/useFilters'; import { useHexagonSelection } from '../../hooks/useHexagonSelection'; import { usePaneResize } from '../../hooks/usePaneResize'; import { useAiFilters } from '../../hooks/useAiFilters'; import { useUrlSync } from '../../hooks/useUrlSync'; import { useTutorial } from '../../hooks/useTutorial'; import { getTutorialStyles } from '../../lib/tutorial-styles'; import Joyride from 'react-joyride'; import { useTravelTime, MODE_LABELS, travelFieldKey, type TravelTimeInitial, } from '../../hooks/useTravelTime'; import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api'; import { trackEvent } from '../../lib/analytics'; import { INITIAL_VIEW_STATE } from '../../lib/consts'; import { useLicense } from '../../hooks/useLicense'; import UpgradeModal from '../ui/UpgradeModal'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; import { MapPinIcon } from '../ui/icons/MapPinIcon'; export interface ExportState { onExport: () => void; exporting: boolean; } interface MapPageProps { features: FeatureMeta[]; poiCategoryGroups: POICategoryGroup[]; initialFilters: FeatureFilters; initialViewState: ViewState; initialPOICategories: Set; initialTab: 'properties' | 'area'; initialLoading: boolean; theme: 'light' | 'dark'; pendingInfoFeature: string | null; onClearPendingInfoFeature: () => void; onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void; onExportStateChange?: (state: ExportState) => void; screenshotMode?: boolean; ogMode?: boolean; isMobile?: boolean; initialTravelTime?: TravelTimeInitial; user?: { id: string; subscription: string } | null; onLoginClick?: () => void; onRegisterClick?: () => void; } export default function MapPage({ features, poiCategoryGroups, initialFilters, initialViewState, initialPOICategories, initialTab, initialLoading, theme, pendingInfoFeature, onClearPendingInfoFeature, onNavigateTo, onExportStateChange, screenshotMode, ogMode, isMobile = false, initialTravelTime, user, onLoginClick, onRegisterClick, }: MapPageProps) { const [selectedPOICategories, setSelectedPOICategories] = useState>(initialPOICategories); const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 600, 'left'); const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 500, 'right'); const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); const [poiPaneOpen, setPoiPaneOpen] = useState(false); const { filters, activeFeature, dragValue, pinnedFeature, enabledFeatures, viewFeature, viewSource, filterRange, handleAddFilter, handleFilterChange, handleRemoveFilter, handleSetFilters, handleDragStart, handleDragChange, handleDragEnd, handleTogglePin, handleSetPin, handleCancelPin, } = useFilters({ initialFilters, features, }); const aiFilters = useAiFilters(); const handleAiFilterSubmit = useCallback( async (query: string) => { const result = await aiFilters.fetchAiFilters(query); if (result) handleSetFilters(result.filters); }, [aiFilters.fetchAiFilters, handleSetFilters] ); const travelTime = useTravelTime(initialTravelTime); const handleTravelTimeSetDestination = useCallback( (index: number, slug: string, label: string) => { travelTime.handleSetDestination(index, slug, label); const entry = travelTime.entries[index]; if (entry) { handleSetPin(`tt_${entry.mode}_${slug}`); } }, [travelTime.handleSetDestination, travelTime.entries, handleSetPin] ); const handleTravelTimeRemoveEntry = useCallback( (index: number) => { const entry = travelTime.entries[index]; if (entry?.slug && pinnedFeature === travelFieldKey(entry)) { handleCancelPin(); } travelTime.handleRemoveEntry(index); }, [travelTime.handleRemoveEntry, travelTime.entries, pinnedFeature, handleCancelPin] ); const license = useLicense(); const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null); const mapData = useMapData({ filters, features, viewFeature, activeFeature, travelTimeEntries: travelTime.entries, }); // First transit destination — used to pick the best central_postcode for journey display const journeyDest = useMemo(() => { const entry = travelTime.entries.find((e) => e.mode === 'transit' && e.slug); return entry ? { mode: entry.mode, slug: entry.slug } : null; }, [travelTime.entries]); const selection = useHexagonSelection({ filters, features, resolution: mapData.resolution, journeyDest, }); const handleLocationSearchResult = useCallback( (result: SearchedLocation | null) => { if (result) { selection.handleLocationSearch(result.postcode, result.geometry); if (isMobile) setMobileDrawerOpen(true); } else { selection.handleCloseSelection(); } }, [selection.handleLocationSearch, selection.handleCloseSelection, isMobile] ); const handleZoomToFreeZone = useCallback(() => { mapFlyToRef.current?.( INITIAL_VIEW_STATE.latitude, INITIAL_VIEW_STATE.longitude, INITIAL_VIEW_STATE.zoom ); }, []); const pois = usePOIData(mapData.bounds, selectedPOICategories); useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime.entries); useEffect(() => { mapData.setInitialView(initialViewState); selection.setRightPaneTab(initialTab); }, []); // eslint-disable-line react-hooks/exhaustive-deps // Prevent browser back/forward navigation from horizontal trackpad swipes useEffect(() => { const handleWheel = (e: WheelEvent) => { if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { e.preventDefault(); } }; document.addEventListener('wheel', handleWheel, { passive: false }); return () => document.removeEventListener('wheel', handleWheel); }, []); const { handleHexagonClick } = selection; const handleMobileHexagonClick = useCallback( (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => { handleHexagonClick(id, isPostcode, geometry); if (id) { setMobileDrawerOpen(true); } }, [handleHexagonClick] ); const hexagonLocation = useMemo(() => { const hexId = selection.selectedHexagon?.id; const isPostcode = selection.selectedHexagon?.type === 'postcode'; if (isPostcode) { // For postcodes, get centroid from postcodeData; postcode string is the selection id const postcodeFeature = mapData.postcodeData.find((f) => f.properties.postcode === hexId); if (!postcodeFeature?.properties.centroid) return null; const [lon, lat] = postcodeFeature.properties.centroid; return { lat, lon, resolution: mapData.resolution, postcode: hexId, isPostcode: true }; } else { // For hexagons, get lat/lon from hexagon data; central postcode comes from stats const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null; if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null; return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution, postcode: selection.areaStats?.central_postcode, }; } }, [ selection.selectedHexagon?.id, selection.selectedHexagon?.type, mapData.data, mapData.postcodeData, mapData.resolution, selection.areaStats?.central_postcode, ]); const tutorial = useTutorial(initialLoading, isMobile); const [exporting, setExporting] = useState(false); const handleExport = useCallback(() => { if (!mapData.bounds || exporting) return; const { south, west, north, east } = mapData.bounds; const params = new URLSearchParams({ bounds: `${south},${west},${north},${east}`, }); const filterStr = buildFilterString(filters, features); if (filterStr) params.set('filters', filterStr); const url = apiUrl('export', params); setExporting(true); fetch(url, authHeaders()) .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.blob(); }) .then((blob) => { const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = 'perfect-postcode-export.xlsx'; link.click(); URL.revokeObjectURL(link.href); trackEvent('Export'); }) .catch((err) => logNonAbortError('Export failed', err)) .finally(() => setExporting(false)); }, [mapData.bounds, filters, features, exporting]); useEffect(() => { onExportStateChange?.({ onExport: handleExport, exporting }); }, [handleExport, exporting, onExportStateChange]); useEffect(() => { if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown'); }, [mapData.licenseRequired]); const mobileLegendMeta = useMemo( () => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null), [viewFeature, features] ); const mobileDensityRange = useMemo((): [number, number] => { const items = mapData.usePostcodeView ? mapData.postcodeData : mapData.data; if (items.length === 0) return [0, 1]; let min = Infinity; let max = -Infinity; for (const d of items) { const c = 'count' in d ? d.count : d.properties.count; if (c < min) min = c; if (c > max) max = c; } if (min === Infinity) return [0, 1]; if (min === max) return [min, min + 1]; return [min, max]; }, [mapData.data, mapData.postcodeData, mapData.usePostcodeView]); useEffect(() => { if (screenshotMode && !mapData.loading) { const hasData = mapData.usePostcodeView ? mapData.postcodeData.length > 0 : mapData.data.length > 0; if (hasData) { window.__screenshot_ready = true; } } }, [screenshotMode, mapData.loading, mapData.data.length, mapData.postcodeData.length, mapData.usePostcodeView]); if (screenshotMode) { return (
{}} features={features} selectedHexagonId={null} hoveredHexagonId={null} onHexagonClick={() => {}} onHexagonHover={() => {}} initialViewState={initialViewState} theme={theme} screenshotMode ogMode={ogMode} bounds={mapData.bounds} travelTimeEntries={travelTime.entries} />
); } const renderAreaPane = () => ( f.properties.postcode === selection.selectedHexagon?.id ) || null : null } onViewProperties={selection.handleViewPropertiesFromArea} hexagonLocation={hexagonLocation} filters={filters} travelTimeEntries={travelTime.activeEntries} /> ); const renderPropertiesPane = () => ( ); const renderPOIPane = () => ( ); const renderFilters = () => ( onNavigateTo('pricing')} onResetTutorial={tutorial.resetTutorial} /> ); if (isMobile) { return (
{initialLoading && (

Connecting to server...

)}
{mapData.loading && (
Loading...
)} {poiPaneOpen && (
{renderPOIPane()}
)}
{viewFeature && mapData.colorRange ? ( viewFeature.startsWith('tt_') ? ( ) : mobileLegendMeta ? ( ) : null ) : ( )}
{renderFilters()}
{mobileDrawerOpen && selection.selectedHexagon && ( setMobileDrawerOpen(false)} renderArea={renderAreaPane} renderProperties={renderPropertiesPane} tab={selection.rightPaneTab} onTabChange={(t) => { if (t === 'properties') { selection.handlePropertiesTabClick(); } else { selection.setRightPaneTab(t); } }} /> )} {mapData.licenseRequired && ( {})} onRegisterClick={onRegisterClick ?? (() => {})} onStartCheckout={() => license.startCheckout()} onZoomToFreeZone={handleZoomToFreeZone} /> )}
); } return (
{initialLoading && (

Connecting to server...

)}
{renderFilters()}
{mapData.loading && (
Loading...
)} {/* Floating POI button */} {/* Floating POI panel */} {poiPaneOpen && (
{renderPOIPane()}
)}
{selection.selectedHexagon && (
selection.setRightPaneTab('area')} />
{selection.rightPaneTab === 'properties' ? renderPropertiesPane() : renderAreaPane()}
)} {mapData.licenseRequired && ( {})} onRegisterClick={onRegisterClick ?? (() => {})} onStartCheckout={() => license.startCheckout()} onZoomToFreeZone={handleZoomToFreeZone} /> )}
); }