import { useState, useEffect, useMemo, useCallback } from 'react'; import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState } from '../../types'; import type { SearchedPostcode } from './PostcodeSearch'; 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 DataSources from '../data-sources/DataSources'; 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 { useAreaSummary } from '../../hooks/useAreaSummary'; import { useUrlSync } from '../../hooks/useUrlSync'; import { apiUrl, buildFilterString } from '../../lib/api'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; export interface ExportState { onExport: () => void; exporting: boolean; } type MobileBottomTab = 'filters' | 'pois' | 'area'; interface MapPageProps { features: FeatureMeta[]; poiCategoryGroups: POICategoryGroup[]; initialFilters: FeatureFilters; initialViewState: ViewState; initialPOICategories: Set; initialTab: 'pois' | '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; } export default function MapPage({ features, poiCategoryGroups, initialFilters, initialViewState, initialPOICategories, initialTab, initialLoading, theme, pendingInfoFeature, onClearPendingInfoFeature, onNavigateTo, onExportStateChange, screenshotMode, ogMode, isMobile = false, }: MapPageProps) { const [searchedPostcode, setSearchedPostcode] = useState(null); const [selectedPOICategories, setSelectedPOICategories] = useState>(initialPOICategories); const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 600, 'left'); const [rightPaneWidth, rightPaneHandlers] = usePaneResize(288, 200, 500, 'right'); // Mobile state const [mobileBottomTab, setMobileBottomTab] = useState('filters'); const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); // Initialize filters first const { filters, activeFeature, dragValue, dragData, pinnedFeature, enabledFeatures, viewFeature, viewSource, filterRange, handleAddFilter, handleFilterChange, handleRemoveFilter, handleDragStart, handleDragChange, handleDragEnd, handleTogglePin, handleCancelPin, updateBoundsInfo, } = useFilters({ initialFilters, features, }); // Map data hook const mapData = useMapData({ filters, features, viewFeature, activeFeature, dragValue, dragData, }); // Keep filter bounds in sync with map data useEffect(() => { updateBoundsInfo(mapData.bounds, mapData.resolution); }, [mapData.bounds, mapData.resolution, updateBoundsInfo]); // Hexagon selection hook const selection = useHexagonSelection({ filters, features, resolution: mapData.resolution, }); // POI data const pois = usePOIData(mapData.bounds, selectedPOICategories); // Sync current state to URL useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab); // Set initial view and tab from URL state useEffect(() => { mapData.setInitialView(initialViewState); selection.setRightPaneTab(initialTab); }, []); // eslint-disable-line react-hooks/exhaustive-deps // On mobile, open drawer and switch tab when hexagon is clicked const { handleHexagonClick } = selection; const handleMobileHexagonClick = useCallback( (id: string, isPostcode?: boolean) => { handleHexagonClick(id, isPostcode); if (id) { setMobileDrawerOpen(true); setMobileBottomTab('area'); } }, [handleHexagonClick] ); // Compute hexagon location for external links const hexagonLocation = useMemo(() => { const hexId = selection.selectedHexagon?.id; const isPostcode = selection.selectedHexagon?.type === 'postcode'; if (isPostcode) { // For postcodes, get centroid from postcodeData 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 }; } else { // For hexagons, get lat/lon from hexagon data 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 }; } }, [ selection.selectedHexagon?.id, selection.selectedHexagon?.type, mapData.data, mapData.postcodeData, mapData.resolution, ]); // AI area summary const aiSummary = useAreaSummary({ stats: selection.areaStats, hexagonId: selection.selectedHexagon?.id || null, isPostcode: selection.selectedHexagon?.type === 'postcode', filters, features, }); // Export to Excel 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) .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 = 'narrowit-export.xlsx'; link.click(); URL.revokeObjectURL(link.href); }) .catch((err) => console.error('Export failed:', err)) .finally(() => setExporting(false)); }, [mapData.bounds, filters, features, exporting]); // Report export state to parent (Header) useEffect(() => { onExportStateChange?.({ onExport: handleExport, exporting }); }, [handleExport, exporting, onExportStateChange]); // Mobile legend data (computed from API-fetched data, which is already viewport-scoped) 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 as { count: number }).count : (d as { properties: { count: number } }).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]); // Signal screenshot readiness once map data has loaded useEffect(() => { if (screenshotMode && !mapData.loading && mapData.data.length > 0) { window.__screenshot_ready = true; } }, [screenshotMode, mapData.loading, mapData.data.length]); if (screenshotMode) { return (
{}} features={features} selectedHexagonId={null} hoveredHexagonId={null} onHexagonClick={() => {}} onHexagonHover={() => {}} initialViewState={initialViewState} theme={theme} screenshotMode ogMode={ogMode} bounds={mapData.bounds} />
); } // Shared pane content renderers const renderAreaPane = () => ( f.properties.postcode === selection.selectedHexagon?.id ) || null : null } onViewProperties={selection.handleViewPropertiesFromArea} onClose={selection.handleCloseSelection} hexagonLocation={hexagonLocation} filters={filters} onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)} aiSummary={aiSummary.summary} aiSummaryLoading={aiSummary.loading} aiSummaryError={aiSummary.error} /> ); const renderPropertiesPane = () => ( onNavigateTo('data-sources', slug)} /> ); const renderPOIPane = () => ( onNavigateTo('data-sources', slug)} /> ); const renderFilters = () => ( onNavigateTo('data-sources', slug, featureName)} openInfoFeature={pendingInfoFeature} onClearOpenInfoFeature={onClearPendingInfoFeature} /> ); // Mobile layout if (isMobile) { return (
{initialLoading && (

Connecting to server...

)} {/* Map — 45% */}
{mapData.loading && (
Loading...
)} onNavigateTo('data-sources')} />
{/* Bottom panel — 55% */}
{/* Legend */} {viewFeature && mapData.colorRange && mobileLegendMeta ? ( ) : ( )} {/* Tab bar */}
setMobileBottomTab('filters')} /> setMobileBottomTab('pois')} />
{/* Tab content */}
{mobileBottomTab === 'pois' ? (
{renderPOIPane()}
) : ( renderFilters() )}
{/* Mobile drawer for full-screen hexagon details */} {mobileDrawerOpen && selection.selectedHexagon && ( setMobileDrawerOpen(false)} renderArea={renderAreaPane} renderProperties={renderPropertiesPane} renderPOIs={renderPOIPane} /> )}
); } // Desktop layout (unchanged) return (
{initialLoading && (

Connecting to server...

)} {/* Left Pane */}
{renderFilters()}
{/* Map */}
{mapData.loading && (
Loading...
)} onNavigateTo('data-sources')} />
{/* Right Pane */}
selection.setRightPaneTab('area')} /> selection.setRightPaneTab('pois')} />
{selection.rightPaneTab === 'area' ? renderAreaPane() : selection.rightPaneTab === 'properties' ? renderPropertiesPane() : renderPOIPane()}
); }