936 lines
33 KiB
TypeScript
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'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>
|
|
);
|
|
}
|