569 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|