perfect-postcode/frontend/src/components/map/MapPage.tsx

569 lines
19 KiB
TypeScript

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<string>;
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<SearchedPostcode | null>(null);
const [selectedPOICategories, setSelectedPOICategories] =
useState<Set<string>>(initialPOICategories);
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 600, 'left');
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(288, 200, 500, 'right');
// Mobile state
const [mobileBottomTab, setMobileBottomTab] = useState<MobileBottomTab>('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 (
<div className="h-full w-full">
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={[]}
onViewChange={mapData.handleViewChange}
viewFeature={viewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={() => {}}
features={features}
selectedHexagonId={null}
hoveredHexagonId={null}
onHexagonClick={() => {}}
onHexagonHover={() => {}}
initialViewState={initialViewState}
theme={theme}
screenshotMode
ogMode={ogMode}
bounds={mapData.bounds}
/>
</div>
);
}
// Shared pane content renderers
const renderAreaPane = () => (
<AreaPane
stats={selection.areaStats}
globalFeatures={features}
loading={selection.loadingAreaStats}
hexagonId={selection.selectedHexagon?.id || null}
isPostcode={selection.selectedHexagon?.type === 'postcode'}
postcodeData={
selection.selectedHexagon?.type === 'postcode'
? mapData.postcodeData.find(
(f) => 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 = () => (
<PropertiesPane
properties={selection.properties}
total={selection.propertiesTotal}
loading={selection.loadingProperties}
hexagonId={selection.selectedHexagon?.id || null}
onLoadMore={selection.handleLoadMoreProperties}
onClose={selection.handleCloseSelection}
onNavigateToSource={(slug) => onNavigateTo('data-sources', slug)}
/>
);
const renderPOIPane = () => (
<POIPane
groups={poiCategoryGroups}
selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length}
onNavigateToSource={(slug) => onNavigateTo('data-sources', slug)}
/>
);
const renderFilters = () => (
<Filters
features={features}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
enabledFeatures={enabledFeatures}
onAddFilter={handleAddFilter}
onRemoveFilter={handleRemoveFilter}
onFilterChange={handleFilterChange}
onDragStart={handleDragStart}
onDragChange={handleDragChange}
onDragEnd={handleDragEnd}
zoom={mapData.zoom}
itemCount={mapData.usePostcodeView ? mapData.postcodeData.length : mapData.data.length}
usePostcodeView={mapData.usePostcodeView}
pinnedFeature={pinnedFeature}
onTogglePin={handleTogglePin}
onCancelPin={handleCancelPin}
onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)}
openInfoFeature={pendingInfoFeature}
onClearOpenInfoFeature={onClearPendingInfoFeature}
/>
);
// Mobile layout
if (isMobile) {
return (
<div className="flex-1 flex flex-col overflow-hidden relative">
{initialLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-4">
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">
Connecting to server...
</p>
</div>
</div>
)}
{/* Map — 45% */}
<div className="relative overflow-hidden" style={{ flex: '45 0 0' }}>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
onViewChange={mapData.handleViewChange}
viewFeature={viewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={handleCancelPin}
features={features}
selectedHexagonId={selection.selectedHexagon?.id || null}
hoveredHexagonId={selection.hoveredHexagon}
onHexagonClick={handleMobileHexagonClick}
onHexagonHover={selection.handleHexagonHover}
initialViewState={initialViewState}
theme={theme}
filters={filters}
searchedPostcode={searchedPostcode}
onPostcodeSearched={setSearchedPostcode}
bounds={mapData.bounds}
hideLegend
/>
{mapData.loading && (
<div className="absolute bottom-2 left-2 bg-white dark:bg-navy-800 dark:text-warm-200 px-2 py-1 rounded shadow text-xs">
Loading...
</div>
)}
<DataSources onNavigate={() => onNavigateTo('data-sources')} />
</div>
{/* Bottom panel — 55% */}
<div
className="bg-white dark:bg-warm-900 border-t border-warm-200 dark:border-warm-700 overflow-hidden flex flex-col"
style={{ flex: '55 0 0' }}
>
{/* Legend */}
{viewFeature && mapData.colorRange && mobileLegendMeta ? (
<MapLegend
featureLabel={
viewSource === 'eye'
? `Previewing \u201c${mobileLegendMeta.name}\u201d`
: mobileLegendMeta.name
}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
mode="feature"
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
theme={theme}
inline
/>
) : (
<MapLegend
featureLabel="Property density"
range={mobileDensityRange}
showCancel={false}
onCancel={handleCancelPin}
mode="density"
theme={theme}
inline
/>
)}
{/* Tab bar */}
<div className="flex shrink-0 border-b border-warm-200 dark:border-warm-700 text-sm">
<TabButton
label="Filters"
isActive={mobileBottomTab === 'filters'}
onClick={() => setMobileBottomTab('filters')}
/>
<TabButton
label="POIs"
isActive={mobileBottomTab === 'pois'}
onClick={() => setMobileBottomTab('pois')}
/>
</div>
{/* Tab content */}
<div className="flex-1 min-h-0">
{mobileBottomTab === 'pois' ? (
<div className="h-full overflow-y-auto">{renderPOIPane()}</div>
) : (
renderFilters()
)}
</div>
</div>
{/* Mobile drawer for full-screen hexagon details */}
{mobileDrawerOpen && selection.selectedHexagon && (
<MobileDrawer
onClose={() => setMobileDrawerOpen(false)}
renderArea={renderAreaPane}
renderProperties={renderPropertiesPane}
renderPOIs={renderPOIPane}
/>
)}
</div>
);
}
// Desktop layout (unchanged)
return (
<div className="flex-1 flex overflow-hidden relative">
{initialLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-4">
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">
Connecting to server...
</p>
</div>
</div>
)}
{/* Left Pane */}
<div
className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
style={{ width: leftPaneWidth }}
>
<div className="flex-1 flex flex-col overflow-hidden">{renderFilters()}</div>
<div
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
{...leftPaneHandlers}
>
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
</div>
</div>
{/* Map */}
<div className="flex-1 relative">
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
onViewChange={mapData.handleViewChange}
viewFeature={viewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={handleCancelPin}
features={features}
selectedHexagonId={selection.selectedHexagon?.id || null}
hoveredHexagonId={selection.hoveredHexagon}
onHexagonClick={selection.handleHexagonClick}
onHexagonHover={selection.handleHexagonHover}
initialViewState={initialViewState}
theme={theme}
filters={filters}
searchedPostcode={searchedPostcode}
onPostcodeSearched={setSearchedPostcode}
bounds={mapData.bounds}
/>
{mapData.loading && (
<div className="absolute bottom-4 left-4 bg-white dark:bg-navy-800 dark:text-warm-200 px-3 py-1 rounded shadow">
Loading...
</div>
)}
<DataSources onNavigate={() => onNavigateTo('data-sources')} />
</div>
{/* Right Pane */}
<div
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
style={{ width: rightPaneWidth }}
>
<div
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
{...rightPaneHandlers}
>
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
<TabButton
label="Area"
isActive={selection.rightPaneTab === 'area'}
onClick={() => selection.setRightPaneTab('area')}
/>
<TabButton
label="Properties"
isActive={selection.rightPaneTab === 'properties'}
onClick={selection.handlePropertiesTabClick}
/>
<TabButton
label="POIs"
isActive={selection.rightPaneTab === 'pois'}
onClick={() => selection.setRightPaneTab('pois')}
/>
</div>
<div className="flex-1 overflow-hidden">
{selection.rightPaneTab === 'area'
? renderAreaPane()
: selection.rightPaneTab === 'properties'
? renderPropertiesPane()
: renderPOIPane()}
</div>
</div>
</div>
</div>
);
}