705 lines
25 KiB
TypeScript
705 lines
25 KiB
TypeScript
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';
|
|
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 { 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,
|
|
MODE_LABELS,
|
|
travelFieldKey,
|
|
type TravelTimeInitial,
|
|
} from '../../hooks/useTravelTime';
|
|
import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api';
|
|
import { trackEvent } from '../../lib/analytics';
|
|
import { INITIAL_VIEW_STATE } from '../../lib/consts';
|
|
import { useLicense } from '../../hooks/useLicense';
|
|
import UpgradeModal from '../ui/UpgradeModal';
|
|
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
|
import { MapPinIcon } from '../ui/icons/MapPinIcon';
|
|
|
|
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;
|
|
user?: { id: string; subscription: string } | null;
|
|
onLoginClick?: () => void;
|
|
onRegisterClick?: () => void;
|
|
}
|
|
|
|
export default function MapPage({
|
|
features,
|
|
poiCategoryGroups,
|
|
initialFilters,
|
|
initialViewState,
|
|
initialPOICategories,
|
|
initialTab,
|
|
initialLoading,
|
|
theme,
|
|
pendingInfoFeature,
|
|
onClearPendingInfoFeature,
|
|
onNavigateTo,
|
|
onExportStateChange,
|
|
screenshotMode,
|
|
ogMode,
|
|
isMobile = false,
|
|
initialTravelTime,
|
|
user,
|
|
onLoginClick,
|
|
onRegisterClick,
|
|
}: MapPageProps) {
|
|
const [selectedPOICategories, setSelectedPOICategories] =
|
|
useState<Set<string>>(initialPOICategories);
|
|
|
|
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 600, 'left');
|
|
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 500, 'right');
|
|
|
|
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
|
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
|
|
|
const {
|
|
filters,
|
|
activeFeature,
|
|
dragValue,
|
|
pinnedFeature,
|
|
enabledFeatures,
|
|
viewFeature,
|
|
viewSource,
|
|
filterRange,
|
|
handleAddFilter,
|
|
handleFilterChange,
|
|
handleRemoveFilter,
|
|
handleSetFilters,
|
|
handleDragStart,
|
|
handleDragChange,
|
|
handleDragEnd,
|
|
handleTogglePin,
|
|
handleSetPin,
|
|
handleCancelPin,
|
|
} = useFilters({
|
|
initialFilters,
|
|
features,
|
|
});
|
|
|
|
const aiFilters = useAiFilters();
|
|
const handleAiFilterSubmit = useCallback(
|
|
async (query: string) => {
|
|
const result = await aiFilters.fetchAiFilters(query);
|
|
if (result) handleSetFilters(result.filters);
|
|
},
|
|
[aiFilters.fetchAiFilters, handleSetFilters]
|
|
);
|
|
|
|
const travelTime = useTravelTime(initialTravelTime);
|
|
|
|
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();
|
|
|
|
const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null);
|
|
|
|
const mapData = useMapData({
|
|
filters,
|
|
features,
|
|
viewFeature,
|
|
activeFeature,
|
|
travelTimeEntries: travelTime.entries,
|
|
});
|
|
|
|
// 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);
|
|
|
|
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime.entries);
|
|
|
|
useEffect(() => {
|
|
mapData.setInitialView(initialViewState);
|
|
selection.setRightPaneTab(initialTab);
|
|
}, []); // 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);
|
|
}, []);
|
|
|
|
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);
|
|
|
|
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 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) {
|
|
window.__screenshot_ready = true;
|
|
}
|
|
}
|
|
}, [screenshotMode, mapData.loading, mapData.data.length, mapData.postcodeData.length, mapData.usePostcodeView]);
|
|
|
|
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}
|
|
/>
|
|
);
|
|
|
|
const renderPropertiesPane = () => (
|
|
<PropertiesPane
|
|
properties={selection.properties}
|
|
total={selection.propertiesTotal}
|
|
loading={selection.loadingProperties}
|
|
hexagonId={selection.selectedHexagon?.id || null}
|
|
onLoadMore={selection.handleLoadMoreProperties}
|
|
/>
|
|
);
|
|
|
|
const renderPOIPane = () => (
|
|
<POIPane
|
|
groups={poiCategoryGroups}
|
|
selectedCategories={selectedPOICategories}
|
|
onCategoriesChange={setSelectedPOICategories}
|
|
poiCount={pois.length}
|
|
/>
|
|
);
|
|
|
|
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}
|
|
onTravelTimeToggleBest={travelTime.handleToggleBest}
|
|
aiFilterLoading={aiFilters.loading}
|
|
aiFilterError={aiFilters.error}
|
|
aiFilterNotes={aiFilters.notes}
|
|
onAiFilterSubmit={handleAiFilterSubmit}
|
|
isLicensed={user?.subscription === 'licensed'}
|
|
onUpgradeClick={() => onNavigateTo('pricing')}
|
|
onResetTutorial={tutorial.resetTutorial}
|
|
/>
|
|
);
|
|
|
|
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>
|
|
)}
|
|
|
|
<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={`Travel time (${MODE_LABELS[viewFeature.split('_')[1] as keyof typeof MODE_LABELS]})`}
|
|
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="Number of properties"
|
|
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);
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{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-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>
|
|
|
|
<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}
|
|
/>
|
|
{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-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}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-hidden">
|
|
{selection.rightPaneTab === 'properties'
|
|
? renderPropertiesPane()
|
|
: renderAreaPane()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{mapData.licenseRequired && (
|
|
<UpgradeModal
|
|
isLoggedIn={!!user}
|
|
onLoginClick={onLoginClick ?? (() => {})}
|
|
onRegisterClick={onRegisterClick ?? (() => {})}
|
|
onStartCheckout={() => license.startCheckout()}
|
|
onZoomToFreeZone={handleZoomToFreeZone}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|