changes
This commit is contained in:
parent
524580eb25
commit
ffe080adef
82 changed files with 2652 additions and 2956 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue