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

936 lines
33 KiB
TypeScript

import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type {
FeatureMeta,
FeatureFilters,
POICategoryGroup,
ViewState,
PostcodeGeometry,
Property,
} from '../../types';
import type { SearchedLocation } from './LocationSearch';
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 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 { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import { useAiFilters } from '../../hooks/useAiFilters';
import { useUrlSync } from '../../hooks/useUrlSync';
import { useTutorial } from '../../hooks/useTutorial';
import { getTutorialStyles } from '../../lib/tutorial-styles';
import Joyride from 'react-joyride';
import {
useTravelTime,
useTranslatedModes,
travelFieldKey,
type TravelTimeInitial,
} from '../../hooks/useTravelTime';
import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api';
import { useFilterCounts } from '../../hooks/useFilterCounts';
import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE } from '../../lib/consts';
import { useLicense } from '../../hooks/useLicense';
import UpgradeModal from '../ui/UpgradeModal';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
export interface ExportState {
onExport: () => void;
exporting: boolean;
}
interface MapPageProps {
features: FeatureMeta[];
poiCategoryGroups: POICategoryGroup[];
initialFilters: FeatureFilters;
initialViewState: ViewState;
initialPOICategories: Set<string>;
initialTab: '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;
initialTravelTime?: TravelTimeInitial;
initialPostcode?: string;
user?: { id: string; subscription: string; isAdmin?: boolean } | null;
onLoginClick?: () => void;
onRegisterClick?: () => void;
onSaveProperty?: (property: Property) => void;
onUnsaveProperty?: (id: string) => void;
isPropertySaved?: (address?: string, postcode?: string) => boolean;
getSavedPropertyId?: (address?: string, postcode?: string) => string | undefined;
deferTutorial?: boolean;
}
export default function MapPage({
features,
poiCategoryGroups,
initialFilters,
initialViewState,
initialPOICategories,
initialTab,
initialLoading,
theme,
pendingInfoFeature,
onClearPendingInfoFeature,
onNavigateTo,
onExportStateChange,
screenshotMode,
ogMode,
isMobile = false,
initialTravelTime,
initialPostcode,
user,
onLoginClick,
onRegisterClick,
onSaveProperty,
onUnsaveProperty,
isPropertySaved,
getSavedPropertyId,
deferTutorial = false,
}: MapPageProps) {
const [selectedPOICategories, setSelectedPOICategories] =
useState<Set<string>>(initialPOICategories);
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
const [showBookmarkToast, setShowBookmarkToast] = useState(false);
const bookmarkToastDismissed = useRef(localStorage.getItem('bookmark_toast_dismissed') === '1');
const handleSavePropertyWithToast = useCallback(
(property: Property) => {
onSaveProperty?.(property);
if (!bookmarkToastDismissed.current) {
setShowBookmarkToast(true);
bookmarkToastDismissed.current = true;
}
},
[onSaveProperty]
);
const { t } = useTranslation();
const modes = useTranslatedModes();
const {
filters,
activeFeature,
dragValue,
pinnedFeature,
enabledFeatures,
viewFeature,
viewSource,
filterRange,
handleAddFilter,
handleFilterChange,
handleRemoveFilter,
handleSetFilters,
handleDragStart,
handleDragChange,
handleDragEnd,
handleDragEndNoCommit,
handleTogglePin,
handleSetPin,
handleCancelPin,
} = useFilters({
initialFilters,
features,
});
const aiFilters = useAiFilters();
const travelTime = useTravelTime(initialTravelTime);
const handleAiFilterSubmit = useCallback(
async (query: string) => {
// Derive current listing type from Listing status filter
const listingVal = filters['Listing status'] as string[] | undefined;
const listingType = listingVal?.includes('For sale')
? 'buy'
: listingVal?.includes('For rent')
? 'rent'
: 'historical';
// Build context from current filters for conversational refinement
const context = {
filters,
travelTime: travelTime.activeEntries.map((entry) => ({
mode: entry.mode,
label: entry.label,
min: entry.timeRange?.[0],
max: entry.timeRange?.[1],
})),
};
const hasContext = Object.keys(context.filters).length > 0 || context.travelTime.length > 0;
const result = await aiFilters.fetchAiFilters(
query,
hasContext ? context : undefined,
listingType
);
if (!result) return;
handleSetFilters(result.filters);
// Always sync travel time entries — clear stale ones when AI returns none
const newEntries = result.travelTimeFilters.map((tt) => ({
mode: tt.mode,
slug: tt.slug,
label: tt.label,
timeRange: [tt.min ?? 0, tt.max ?? 120] as [number, number],
useBest: false,
}));
travelTime.handleSetEntries(newEntries);
},
[
aiFilters.fetchAiFilters,
handleSetFilters,
travelTime.handleSetEntries,
travelTime.activeEntries,
filters,
]
);
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 handleTravelTimeDragEnd = useCallback(
(index: number) => {
const dv = handleDragEndNoCommit();
if (dv) travelTime.handleTimeRangeChange(index, dv);
},
[handleDragEndNoCommit, travelTime.handleTimeRangeChange]
);
const license = useLicense();
const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null);
const mapData = useMapData({
filters,
features,
viewFeature,
activeFeature,
travelTimeEntries: travelTime.entries,
});
const filterCounts = useFilterCounts(filters, features, mapData.bounds);
const handleTravelTimeSetDestination = useCallback(
(index: number, slug: string, label: string, lat: number, lon: number) => {
travelTime.handleSetDestination(index, slug, label);
if (slug) {
mapFlyToRef.current?.(lat, lon, mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom);
}
},
[travelTime.handleSetDestination, mapData.currentView?.zoom]
);
// First transit destination — used to pick the best central_postcode for journey display
const journeyDest = useMemo(() => {
const entry = travelTime.entries.find((e) => e.mode === 'transit' && e.slug);
return entry ? { mode: entry.mode, slug: entry.slug } : null;
}, [travelTime.entries]);
const selection = useHexagonSelection({
filters,
features,
resolution: mapData.resolution,
journeyDest,
});
const handleLocationSearchResult = useCallback(
(result: SearchedLocation | null) => {
if (result) {
selection.handleLocationSearch(result.postcode, result.geometry);
if (isMobile) setMobileDrawerOpen(true);
} else {
selection.handleCloseSelection();
}
},
[selection.handleLocationSearch, selection.handleCloseSelection, isMobile]
);
const handleZoomToFreeZone = useCallback(() => {
mapFlyToRef.current?.(
INITIAL_VIEW_STATE.latitude,
INITIAL_VIEW_STATE.longitude,
INITIAL_VIEW_STATE.zoom
);
}, []);
const pois = usePOIData(mapData.bounds, selectedPOICategories);
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
useUrlSync(
mapData.currentView,
filters,
features,
selectedPOICategories,
selection.rightPaneTab,
travelTime.entries
);
useEffect(() => {
mapData.setInitialView(initialViewState);
selection.setRightPaneTab(initialTab);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Navigate to a specific postcode on mount (e.g. from saved properties)
useEffect(() => {
if (!initialPostcode) return;
// Strip the `pc` param from the URL so it doesn't persist
const params = new URLSearchParams(window.location.search);
params.delete('pc');
const newUrl = params.toString() ? `/dashboard?${params}` : '/dashboard';
window.history.replaceState(window.history.state, '', newUrl);
// Fetch postcode geometry and fly to it
fetch(`/api/postcode/${encodeURIComponent(initialPostcode)}`, authHeaders())
.then((res) => {
if (!res.ok) throw new Error('Postcode not found');
return res.json();
})
.then(
(data: {
postcode: string;
latitude: number;
longitude: number;
geometry: PostcodeGeometry;
}) => {
mapFlyToRef.current?.(data.latitude, data.longitude, 16);
selection.handleLocationSearch(data.postcode, data.geometry);
if (isMobile) setMobileDrawerOpen(true);
}
)
.catch(() => {
// Silently fail — postcode might not exist
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Prevent browser back/forward navigation from horizontal trackpad swipes
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
e.preventDefault();
}
};
document.addEventListener('wheel', handleWheel, { passive: false });
return () => document.removeEventListener('wheel', handleWheel);
}, []);
// On mobile, push a guard history entry to absorb accidental back navigations
// (e.g. iOS Safari edge-swipe that CSS touch-action can't prevent)
useEffect(() => {
if (!isMobile) return;
window.history.pushState({ dashboardGuard: true }, '');
const handlePopState = () => {
window.history.pushState({ dashboardGuard: true }, '');
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [isMobile]);
const { handleHexagonClick } = selection;
const handleMobileHexagonClick = useCallback(
(id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => {
handleHexagonClick(id, isPostcode, geometry);
if (id) {
setMobileDrawerOpen(true);
}
},
[handleHexagonClick]
);
const hexagonLocation = useMemo(() => {
const hexId = selection.selectedHexagon?.id;
const isPostcode = selection.selectedHexagon?.type === 'postcode';
if (isPostcode) {
// For postcodes, get centroid from postcodeData; postcode string is the selection id
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, postcode: hexId, isPostcode: true };
} else {
// For hexagons, get lat/lon from hexagon data; central postcode comes from stats
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,
postcode: selection.areaStats?.central_postcode,
};
}
}, [
selection.selectedHexagon?.id,
selection.selectedHexagon?.type,
mapData.data,
mapData.postcodeData,
mapData.resolution,
selection.areaStats?.central_postcode,
]);
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial);
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, authHeaders())
.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 = 'perfect-postcode-export.xlsx';
link.click();
URL.revokeObjectURL(link.href);
trackEvent('Export');
})
.catch((err) => logNonAbortError('Export failed', err))
.finally(() => setExporting(false));
}, [mapData.bounds, filters, features, exporting]);
useEffect(() => {
onExportStateChange?.({ onExport: handleExport, exporting });
}, [handleExport, exporting, onExportStateChange]);
useEffect(() => {
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
}, [mapData.licenseRequired]);
const densityLabel = useMemo(() => {
const listingVal = filters['Listing status'] as string[] | undefined;
if (listingVal?.includes('For sale')) return 'Properties for sale';
if (listingVal?.includes('For rent')) return 'Properties for rent';
return 'Historical property matches';
}, [filters]);
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.count : d.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]);
useEffect(() => {
if (screenshotMode && !mapData.loading) {
const hasData = mapData.usePostcodeView
? mapData.postcodeData.length > 0
: mapData.data.length > 0;
if (hasData) {
// Wait for both deck.gl data AND MapLibre base map tile rendering.
// __map_idle is set by Map's onIdle callback, which fires after all
// tiles are loaded and rendered — critical for SwiftShader where
// edge tiles can lag behind the center.
const waitAndSignal = () => {
if (window.__map_idle) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
window.__screenshot_ready = true;
});
});
} else {
requestAnimationFrame(waitAndSignal);
}
};
waitAndSignal();
}
}
}, [
screenshotMode,
mapData.loading,
mapData.data.length,
mapData.postcodeData.length,
mapData.usePostcodeView,
]);
const bookmarkToast = showBookmarkToast && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-3 px-4 py-3 rounded-lg bg-navy-900 text-white text-sm shadow-lg animate-fade-in">
<BookmarkIcon className="w-4 h-4 text-teal-400 shrink-0" filled />
<span>Property saved!</span>
<button
onClick={() => {
setShowBookmarkToast(false);
onNavigateTo('saved', 'properties');
}}
className="px-3 py-1 rounded bg-teal-600 hover:bg-teal-500 text-white text-xs font-medium whitespace-nowrap"
>
View saved
</button>
<button
onClick={() => {
setShowBookmarkToast(false);
localStorage.setItem('bookmark_toast_dismissed', '1');
}}
className="text-warm-400 hover:text-warm-200 text-xs whitespace-nowrap"
>
Don&apos;t show again
</button>
</div>
);
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}
travelTimeEntries={travelTime.entries}
/>
</div>
);
}
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}
hexagonLocation={hexagonLocation}
filters={filters}
travelTimeEntries={travelTime.activeEntries}
isGroupExpanded={isAreaGroupExpanded}
onToggleGroup={toggleAreaGroup}
/>
);
const renderPropertiesPane = () => (
<PropertiesPane
properties={selection.properties}
total={selection.propertiesTotal}
loading={selection.loadingProperties}
hexagonId={selection.selectedHexagon?.id || null}
onLoadMore={selection.handleLoadMoreProperties}
onSaveProperty={onSaveProperty ? handleSavePropertyWithToast : undefined}
onUnsaveProperty={onUnsaveProperty}
isPropertySaved={isPropertySaved}
getSavedPropertyId={getSavedPropertyId}
/>
);
const renderPOIPane = () => (
<POIPane
groups={poiCategoryGroups}
selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length}
onClose={() => setPoiPaneOpen(false)}
/>
);
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}
pinnedFeature={pinnedFeature}
onTogglePin={handleTogglePin}
openInfoFeature={pendingInfoFeature}
onClearOpenInfoFeature={onClearPendingInfoFeature}
travelTimeEntries={travelTime.entries}
onTravelTimeAddEntry={travelTime.handleAddEntry}
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
onTravelTimeSetDestination={handleTravelTimeSetDestination}
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
onTravelTimeDragEnd={handleTravelTimeDragEnd}
onTravelTimeToggleBest={travelTime.handleToggleBest}
aiFilterLoading={aiFilters.loading}
aiFilterError={aiFilters.error}
aiFilterErrorType={aiFilters.errorType}
aiFilterNotes={aiFilters.notes}
aiFilterSummary={aiFilters.summary}
onAiFilterSubmit={handleAiFilterSubmit}
isLoggedIn={!!user}
onLoginRequired={onRegisterClick ?? (() => {})}
isLicensed={user?.subscription === 'licensed'}
isAdmin={user?.isAdmin === true}
onUpgradeClick={() => onNavigateTo('pricing')}
onResetTutorial={tutorial.resetTutorial}
filterImpacts={filterCounts.impacts}
/>
);
if (isMobile) {
return (
<div className="flex-1 flex flex-col overflow-hidden relative touch-pan-y">
{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>
)}
<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}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selection.selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
bounds={mapData.bounds}
hideLegend
travelTimeEntries={travelTime.entries}
/>
{mapData.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>
)}
<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>
{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()}
</div>
)}
</div>
<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' }}
>
{viewFeature && mapData.colorRange ? (
viewFeature.startsWith('tt_') ? (
<MapLegend
featureLabel={t('travel.travelTime', { mode: modes.label(viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit') })}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
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
raw={mobileLegendMeta.raw}
/>
) : null
) : (
<MapLegend
featureLabel={densityLabel}
range={mobileDensityRange}
showCancel={false}
onCancel={handleCancelPin}
mode="density"
theme={theme}
inline
/>
)}
<div className="flex-1 min-h-0">{renderFilters()}</div>
</div>
{mobileDrawerOpen && selection.selectedHexagon && (
<MobileDrawer
onClose={() => setMobileDrawerOpen(false)}
renderArea={renderAreaPane}
renderProperties={renderPropertiesPane}
tab={selection.rightPaneTab}
onTabChange={(t) => {
if (t === 'properties') {
selection.handlePropertiesTabClick();
} else {
selection.setRightPaneTab(t);
}
}}
/>
)}
{bookmarkToast}
{mapData.licenseRequired && (
<UpgradeModal
isLoggedIn={!!user}
onLoginClick={onLoginClick ?? (() => {})}
onRegisterClick={onRegisterClick ?? (() => {})}
onStartCheckout={() => license.startCheckout()}
onZoomToFreeZone={handleZoomToFreeZone}
/>
)}
</div>
);
}
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>
)}
<Joyride
steps={tutorial.steps}
run={tutorial.run}
continuous
showProgress
showSkipButton
callback={tutorial.handleCallback}
styles={getTutorialStyles(theme)}
disableScrolling
locale={{ last: 'Finish' }}
/>
<div
data-tutorial="filters"
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-3 cursor-col-resize flex items-center justify-center group 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="flex flex-col gap-1.5">
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
</div>
</div>
</div>
<div data-tutorial="map" 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}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selection.selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
bounds={mapData.bounds}
travelTimeEntries={travelTime.entries}
densityLabel={densityLabel}
/>
{mapData.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
data-tutorial="poi-button"
onClick={() => setPoiPaneOpen((p) => !p)}
className={`absolute bottom-4 right-4 z-10 px-3 py-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 flex items-center gap-2 ${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" />
<span className="text-sm font-medium">Points of interest</span>
</button>
{/* Floating POI panel */}
{poiPaneOpen && (
<div className="absolute bottom-14 right-4 z-10 w-80 max-h-[60vh] overflow-hidden flex flex-col rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900">
{renderPOIPane()}
</div>
)}
</div>
{selection.selectedHexagon && (
<div
data-tutorial="right-pane"
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
style={{ width: rightPaneWidth }}
>
<div
className="w-3 cursor-col-resize flex items-center justify-center group 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="flex flex-col gap-1.5">
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
</div>
</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}
/>
<button
onClick={selection.handleCloseSelection}
className="px-2 flex items-center text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Close pane"
>
<CloseIcon className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-hidden">
{selection.rightPaneTab === 'properties' ? renderPropertiesPane() : renderAreaPane()}
</div>
</div>
</div>
)}
{bookmarkToast}
{mapData.licenseRequired && (
<UpgradeModal
isLoggedIn={!!user}
onLoginClick={onLoginClick ?? (() => {})}
onRegisterClick={onRegisterClick ?? (() => {})}
onStartCheckout={() => license.startCheckout()}
onZoomToFreeZone={handleZoomToFreeZone}
/>
)}
</div>
);
}