This commit is contained in:
Andras Schmelczer 2026-02-18 21:22:15 +00:00
parent 524580eb25
commit ffe080adef
82 changed files with 2652 additions and 2956 deletions

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
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';
@ -16,7 +16,6 @@ import { useFilters } from '../../hooks/useFilters';
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
import { usePaneResize } from '../../hooks/usePaneResize';
import { useAiFilters } from '../../hooks/useAiFilters';
import { useAreaSummary } from '../../hooks/useAreaSummary';
import { useUrlSync } from '../../hooks/useUrlSync';
import { useTutorial } from '../../hooks/useTutorial';
import { getTutorialStyles } from '../../lib/tutorial-styles';
@ -28,6 +27,7 @@ import {
type TravelTimeInitial,
} from '../../hooks/useTravelTime';
import { apiUrl, assertOk, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api';
import { INITIAL_VIEW_STATE } from '../../lib/consts';
import { useLicense } from '../../hooks/useLicense';
import UpgradeModal from '../ui/UpgradeModal';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
@ -87,13 +87,9 @@ export default function MapPage({
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 600, 'left');
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(288, 200, 500, 'right');
// Mobile state
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
// POI floating panel state
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
// Initialize filters first
const {
filters,
activeFeature,
@ -112,6 +108,7 @@ export default function MapPage({
handleDragChange,
handleDragEnd,
handleTogglePin,
handleSetPin,
handleCancelPin,
updateBoundsInfo,
} = useFilters({
@ -119,7 +116,6 @@ export default function MapPage({
features,
});
// AI filters hook
const aiFilters = useAiFilters();
const handleAiFilterSubmit = useCallback(
async (query: string) => {
@ -129,13 +125,34 @@ export default function MapPage({
[aiFilters.fetchAiFilters, handleSetFilters]
);
// Travel time hook
const travelTime = useTravelTime(initialTravelTime);
// License hook
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();
// Map data hook
const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null);
const mapData = useMapData({
filters,
features,
@ -146,19 +163,16 @@ export default function MapPage({
travelTimeEntries: travelTime.entries,
});
// 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,
});
// Location search handler — selects postcode + shows stats
const handleLocationSearchResult = useCallback(
(result: SearchedLocation | null) => {
if (result) {
@ -171,10 +185,16 @@ export default function MapPage({
[selection.handleLocationSearch, selection.handleCloseSelection, isMobile]
);
// POI data
const handleZoomToFreeZone = useCallback(() => {
mapFlyToRef.current?.(
INITIAL_VIEW_STATE.latitude,
INITIAL_VIEW_STATE.longitude,
INITIAL_VIEW_STATE.zoom
);
}, []);
const pois = usePOIData(mapData.bounds, selectedPOICategories);
// Compute data range for travel time slider per entry index (full min/max for slider bounds)
const travelTimeDataRanges = useMemo((): globalThis.Map<number, [number, number]> => {
const ranges = new globalThis.Map<number, [number, number]>();
for (let i = 0; i < travelTime.entries.length; i++) {
@ -193,16 +213,13 @@ export default function MapPage({
return ranges;
}, [travelTime.entries, mapData.data]);
// Sync current state to URL
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime.entries);
// 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, geometry?: PostcodeGeometry) => {
@ -214,7 +231,6 @@ export default function MapPage({
[handleHexagonClick]
);
// Compute hexagon location for external links
const hexagonLocation = useMemo(() => {
const hexId = selection.selectedHexagon?.id;
const isPostcode = selection.selectedHexagon?.type === 'postcode';
@ -239,19 +255,8 @@ export default function MapPage({
mapData.resolution,
]);
// Tutorial
const tutorial = useTutorial(initialLoading, isMobile);
// 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;
@ -280,12 +285,10 @@ export default function MapPage({
.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]
@ -305,7 +308,6 @@ export default function MapPage({
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;
@ -337,13 +339,11 @@ export default function MapPage({
ogMode={ogMode}
bounds={mapData.bounds}
travelTimeEntries={travelTime.entries}
travelTimeColorRanges={mapData.travelTimeColorRanges}
/>
</div>
);
}
// Shared pane content renderers
const renderAreaPane = () => (
<AreaPane
stats={selection.areaStats}
@ -362,9 +362,6 @@ export default function MapPage({
onClose={selection.handleCloseSelection}
hexagonLocation={hexagonLocation}
filters={filters}
aiSummary={aiSummary.summary}
aiSummaryLoading={aiSummary.loading}
aiSummaryError={aiSummary.error}
/>
);
@ -375,7 +372,6 @@ export default function MapPage({
loading={selection.loadingProperties}
hexagonId={selection.selectedHexagon?.id || null}
onLoadMore={selection.handleLoadMoreProperties}
onClose={selection.handleCloseSelection}
/>
);
@ -403,14 +399,13 @@ export default function MapPage({
onDragEnd={handleDragEnd}
pinnedFeature={pinnedFeature}
onTogglePin={handleTogglePin}
onCancelPin={handleCancelPin}
openInfoFeature={pendingInfoFeature}
onClearOpenInfoFeature={onClearPendingInfoFeature}
travelTimeEntries={travelTime.entries}
travelTimeDataRanges={travelTimeDataRanges}
onTravelTimeAddEntry={travelTime.handleAddEntry}
onTravelTimeRemoveEntry={travelTime.handleRemoveEntry}
onTravelTimeSetDestination={travelTime.handleSetDestination}
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
onTravelTimeSetDestination={handleTravelTimeSetDestination}
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
aiFilterLoading={aiFilters.loading}
aiFilterError={aiFilters.error}
@ -419,7 +414,6 @@ export default function MapPage({
/>
);
// Mobile layout
if (isMobile) {
return (
<div className="flex-1 flex flex-col overflow-hidden relative">
@ -434,7 +428,6 @@ export default function MapPage({
</div>
)}
{/* Map — 45% */}
<div className="relative overflow-hidden" style={{ flex: '45 0 0' }}>
<Map
data={mapData.data}
@ -453,6 +446,7 @@ export default function MapPage({
onHexagonClick={handleMobileHexagonClick}
onHexagonHover={selection.handleHexagonHover}
initialViewState={initialViewState}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selection.selectedPostcodeGeometry}
@ -460,21 +454,21 @@ export default function MapPage({
bounds={mapData.bounds}
hideLegend
travelTimeEntries={travelTime.entries}
travelTimeColorRanges={mapData.travelTimeColorRanges}
/>
{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 className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">Loading...</span>
</div>
</div>
)}
{/* Floating POI button */}
<button
onClick={() => setPoiPaneOpen((p) => !p)}
className={`absolute bottom-2 right-2 z-10 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
>
<MapPinIcon className="w-5 h-5" />
</button>
{/* Floating POI panel */}
{poiPaneOpen && (
<div className="absolute bottom-12 right-2 z-10 w-[calc(100%-1rem)] max-h-[60%] overflow-hidden flex flex-col rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900">
{renderPOIPane()}
@ -482,67 +476,54 @@ export default function MapPage({
)}
</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 */}
{(() => {
const primaryIdx = travelTime.entries.findIndex(
(e, i) => e.slug && mapData.travelTimeColorRanges.get(i)
);
if (primaryIdx >= 0) {
return (
<MapLegend
featureLabel={`Travel time (${MODE_LABELS[travelTime.entries[primaryIdx].mode]})`}
range={mapData.travelTimeColorRanges.get(primaryIdx)!}
showCancel={false}
onCancel={handleCancelPin}
mode="feature"
theme={theme}
inline
suffix=" min"
/>
);
}
if (viewFeature && mapData.colorRange && mobileLegendMeta) {
return (
<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
/>
);
}
return (
{viewFeature && mapData.colorRange ? (
viewFeature.startsWith('tt_') ? (
<MapLegend
featureLabel="Property density"
range={mobileDensityRange}
showCancel={false}
featureLabel={`Travel time (${MODE_LABELS[viewFeature.split('_')[1] as keyof typeof MODE_LABELS]})`}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
mode="density"
mode="feature"
theme={theme}
inline
suffix=" min"
/>
) : 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
/>
);
})()}
{/* Filters content */}
) : null
) : (
<MapLegend
featureLabel="Number of properties"
range={mobileDensityRange}
showCancel={false}
onCancel={handleCancelPin}
mode="density"
theme={theme}
inline
/>
)}
<div className="flex-1 min-h-0">
{renderFilters()}
</div>
</div>
{/* Mobile drawer for full-screen hexagon details */}
{mobileDrawerOpen && selection.selectedHexagon && (
<MobileDrawer
onClose={() => setMobileDrawerOpen(false)}
@ -557,14 +538,13 @@ export default function MapPage({
onLoginClick={onLoginClick ?? (() => {})}
onRegisterClick={onRegisterClick ?? (() => {})}
onStartCheckout={() => license.startCheckout()}
onDismiss={() => {}}
onZoomToFreeZone={handleZoomToFreeZone}
/>
)}
</div>
);
}
// Desktop layout (unchanged)
return (
<div className="flex-1 flex overflow-hidden relative">
{initialLoading && (
@ -589,7 +569,6 @@ export default function MapPage({
disableScrolling
/>
{/* Left Pane */}
<div
data-tutorial="filters"
className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
@ -604,7 +583,6 @@ export default function MapPage({
</div>
</div>
{/* Map */}
<div data-tutorial="map" className="flex-1 relative">
<Map
data={mapData.data}
@ -623,17 +601,20 @@ export default function MapPage({
onHexagonClick={selection.handleHexagonClick}
onHexagonHover={selection.handleHexagonHover}
initialViewState={initialViewState}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selection.selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
bounds={mapData.bounds}
travelTimeEntries={travelTime.entries}
travelTimeColorRanges={mapData.travelTimeColorRanges}
/>
{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 className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">Loading...</span>
</div>
</div>
)}
{/* Floating POI button */}
@ -652,7 +633,6 @@ export default function MapPage({
)}
</div>
{/* Right Pane */}
<div
data-tutorial="right-pane"
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
@ -692,7 +672,7 @@ export default function MapPage({
onLoginClick={onLoginClick ?? (() => {})}
onRegisterClick={onRegisterClick ?? (() => {})}
onStartCheckout={() => license.startCheckout()}
onDismiss={() => {}}
onZoomToFreeZone={handleZoomToFreeZone}
/>
)}
</div>