perfect-postcode/frontend/src/components/map/MapPage.tsx
2026-07-12 20:37:39 +01:00

1315 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import type { ActualListing, POI, PostcodeGeometry } from '../../types';
import type { SearchedLocation } from './LocationSearch';
import { useMapData } from '../../hooks/useMapData';
import { usePOIData } from '../../hooks/usePOIData';
import { useActualListings } from '../../hooks/useActualListings';
import { buildTravelParam } from '../../lib/travel-params';
import { useFilters, countEffectiveFilters } 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 {
MAX_TRAVEL_MINUTES,
parseServerMode,
resolveTransitVariant,
travelFieldKey,
useTravelTime,
type TransportMode,
} from '../../hooks/useTravelTime';
import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
import { useFilterCounts } from '../../hooks/useFilterCounts';
import { trackEvent } from '../../lib/analytics';
import {
DEMO_MAX_FILTERS,
REGISTERED_MAX_FILTERS,
INITIAL_VIEW_STATE,
POSTCODE_ZOOM_THRESHOLD,
} from '../../lib/consts';
import { boundsToCenterZoom } from '../../lib/fit-bounds';
import type { OverlayId } from '../../lib/overlays';
import { CRIME_TYPE_VALUES } from '../../lib/crime-types';
import type { BasemapId } from '../../lib/basemaps';
import { useLicense } from '../../hooks/useLicense';
import { stateToParams } from '../../lib/url-state';
import { DEFAULT_COLOR_OPACITY, normalizeColorOpacity } from '../../lib/color-opacity';
import { DEFAULT_LISTINGS_MODE, filterListingsByMode, type ListingsMode } from '../../lib/listings';
import { groupFeaturesByCategory } from '../../lib/features';
import {
getActiveAmenityFilterFeatureNames,
isPoiFilterFeatureName,
} from '../../lib/poi-distance-filter';
import {
AreaPane,
Filters,
ListingPane,
OverlayPane,
POIPane,
PropertiesPane,
UpgradeModal,
} from './map-page/lazyComponents';
import { PaneFallback } from './map-page/Fallbacks';
import { DesktopMapPage } from './map-page/DesktopMapPage';
import { MobileMapPage } from './map-page/MobileMapPage';
import { ScreenshotMapPage } from './map-page/ScreenshotMapPage';
import { ExportToast } from './map-page/Toasts';
import { MobileMapLegend } from './map-page/MobileMapLegend';
import { useExportController } from './map-page/useExportController';
import {
useHexagonLocation,
useJourneyDestination,
useMapViewFeature,
useMobileDensityRange,
useMobileLegendMeta,
} from './map-page/derivedState';
import {
useHorizontalSwipeNavigationGuard,
useInitialMapPageView,
useInitialPostcodeSelection,
useMobileBackNavigationGuard,
useScreenshotReadySignal,
} from './map-page/effects';
import { useMobileDrawer } from './map-page/useMobileDrawer';
import type { MapFlyTo, MapPageProps } from './map-page/types';
export type { ExportState } from './map-page/types';
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
const EMPTY_POIS: POI[] = [];
export default function MapPage({
features,
poiCategoryGroups,
initialFilters,
initialViewState,
initialPOICategories,
initialOverlays,
initialCrimeTypes,
initialBasemap = 'standard',
initialColorOpacity = DEFAULT_COLOR_OPACITY,
initialListingsMode = DEFAULT_LISTINGS_MODE,
initialTab,
initialLoading,
theme,
pendingInfoFeature,
onClearPendingInfoFeature,
onNavigateTo,
onExportStateChange,
onDashboardParamsChange,
onDashboardReadyChange,
screenshotMode,
ogMode,
isMobile = false,
initialTravelTime,
initialPostcode,
shareCode,
onSessionRejected,
user,
onLoginClick,
onRegisterClick,
onCheckoutRegisterClick,
deferTutorial = false,
onSaveSearch,
savingSearch,
editingSearch,
onCancelEdit,
onUpdateEdit,
onUpdateEditInPlace,
}: MapPageProps) {
const { t } = useTranslation();
const [selectedPOICategories, setSelectedPOICategories] =
useState<Set<string>>(initialPOICategories);
const [activeOverlays, setActiveOverlays] = useState<Set<OverlayId>>(
() => new Set(initialOverlays ?? [])
);
const [crimeTypes, setCrimeTypes] = useState<Set<string>>(
() => new Set(initialCrimeTypes ?? CRIME_TYPE_VALUES)
);
const [basemap, setBasemap] = useState<BasemapId>(initialBasemap);
const [colorOpacity, setColorOpacity] = useState(() =>
normalizeColorOpacity(initialColorOpacity)
);
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
// The POI, overlay and listings panes are mutually exclusive, so a single state
// tracks which one (if any) is open.
const [openMapPane, setOpenMapPane] = useState<'poi' | 'overlay' | 'listings' | null>(null);
const poiPaneOpen = openMapPane === 'poi';
const overlayPaneOpen = openMapPane === 'overlay';
const listingsPaneOpen = openMapPane === 'listings';
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
const [listingsMode, setListingsMode] = useState<ListingsMode>(initialListingsMode);
const [pendingInitialPostcode, setPendingInitialPostcode] = useState<string | null>(
initialPostcode ?? null
);
// Filter allowance scales with the account tier: anonymous → DEMO_MAX_FILTERS,
// registered free → REGISTERED_MAX_FILTERS, licensed/admin → unlimited. Hitting the
// cap opens the upgrade modal.
const isLoggedIn = !!user;
// Screenshot/OG renders are non-interactive previews of a (possibly shared) search.
// They go through the server's internal-request exemption, so they must render the
// full filter set rather than the capped demo view. Otherwise a 45-filter shared
// search is silently trimmed to the anonymous cap (3) in the generated preview image.
const filtersUnlimited =
screenshotMode === true ||
ogMode === true ||
user?.subscription === 'licensed' ||
user?.isAdmin === true;
const effectiveFilterCap = isLoggedIn ? REGISTERED_MAX_FILTERS : DEMO_MAX_FILTERS;
const filterLimit = filtersUnlimited ? null : effectiveFilterCap;
// On a shared link, anonymous visitors may view and adjust the shared filters but
// not add new ones. Keyed off login state (not paid status) so the "create a free
// account to build your own" upsell isn't a dead-end: once registered, the user can
// add filters up to their normal cap right there on the shared search. The
// `!filtersUnlimited` term also excludes the non-interactive screenshot/OG render
// (treated as unlimited above), which must show the shared filters in full.
const lockFilterAdds = !isLoggedIn && !filtersUnlimited && !!shareCode;
// A shared/bookmarked link can encode more filters than the viewer's tier allows.
// The initial set is then trimmed to the cap (the server 400s the full request), so
// the viewer would silently see a broader result than was shared. Count the effective
// filters (folded + known to the backend, matching what the server counts) so stale
// or unknown keys don't raise a false alarm; recomputed once features finish loading.
const initialEffectiveFilterCount = useMemo(
() =>
countEffectiveFilters(initialFilters, features) + (initialTravelTime?.entries?.length ?? 0),
[initialFilters, features, initialTravelTime]
);
// Gate on features being loaded: until then countEffectiveFilters can't drop unknown
// keys and would over-count (firing the popup for a set that actually fits the cap).
const sharedSearchTrimmed =
features.length > 0 && !filtersUnlimited && initialEffectiveFilterCount > effectiveFilterCap;
// Which upsell the modal frames: a shared-link context (lock, or a trimmed shared
// search), or a plain filter-cap hit.
const upgradeReason: 'filters' | 'shared' =
lockFilterAdds || sharedSearchTrimmed ? 'shared' : 'filters';
const [filterLimitHit, setFilterLimitHit] = useState(false);
const handleFilterLimitReached = useCallback(() => {
trackEvent('Upgrade Modal Shown');
setFilterLimitHit(true);
}, []);
// Pop the upgrade modal once when the shared/bookmarked search carried more filters
// than the viewer's tier allows, so trimmed filters are surfaced, not silently
// dropped. Fires when the signal first turns true (which may be after features finish
// loading), and only once so dismissing it sticks.
const overCapPopupShownRef = useRef(false);
useEffect(() => {
if (sharedSearchTrimmed && !overCapPopupShownRef.current) {
overCapPopupShownRef.current = true;
handleFilterLimitReached();
}
}, [sharedSearchTrimmed, handleFilterLimitReached]);
// Travel-time destinations count toward the same cap as feature filters (each one
// restricts which properties match). Cap an over-budget initial travel set (from a
// bookmarked/shared URL), preferring feature filters, so the first data request
// never exceeds the server cap.
const cappedInitialTravelTime = useMemo(() => {
const initialEntries = initialTravelTime?.entries ?? [];
if (filtersUnlimited || initialEntries.length === 0) return initialTravelTime;
const travelBudget = Math.max(0, effectiveFilterCap - Object.keys(initialFilters).length);
return initialEntries.length > travelBudget
? { ...initialTravelTime, entries: initialEntries.slice(0, travelBudget) }
: initialTravelTime;
}, [filtersUnlimited, effectiveFilterCap, initialFilters, initialTravelTime]);
const {
entries,
activeEntries,
handleAddEntry,
handleRemoveEntry,
handleSetDestination,
handleSetEntries,
handleTimeRangeChange,
handleToggleBest,
handleToggleNoChange,
handleToggleOneChange,
handleToggleNoBuses,
} = useTravelTime(cappedInitialTravelTime);
const {
filters,
activeFeature,
dragValue,
pinnedFeature,
enabledFeatures,
viewFeature,
viewSource,
filterRange,
handleAddFilter,
handleFilterChange,
handleRemoveFilter,
handleSetFilters,
handleDragStart,
handleDragChange,
handleDragEnd,
handleDragEndNoCommit,
handleTogglePin,
handleCancelPin,
} = useFilters({
initialFilters,
features,
filterLimit,
// Travel-time entries reserve slots from the same cap (see cappedInitialTravelTime).
reservedFilterSlots: filtersUnlimited ? 0 : entries.length,
lockAdds: lockFilterAdds,
onFilterLimitReached: handleFilterLimitReached,
});
const {
fetchAiFilters,
loading: aiFilterLoading,
error: aiFilterError,
errorType: aiFilterErrorType,
notes: aiFilterNotes,
summary: aiFilterSummary,
} = useAiFilters();
const mapFlyToRef = useRef<MapFlyTo | null>(null);
const areaPaneScrollTopRef = useRef(0);
const propertiesPaneScrollTopRef = useRef(0);
const {
mobileDrawerOpen,
mobileBottomSheetHeight,
setMobileBottomSheetHeight,
openMobileDrawer,
openMobileDrawerForLocationSearch,
clearPendingLocationSearchFlyTo,
queueCurrentLocationFlyTo,
handleMobileDrawerPanelRectChange,
handleMobileDrawerClose,
getMobileMapFlyToOptions,
} = useMobileDrawer(isMobile, mapFlyToRef);
const mapData = useMapData({
filters,
features,
viewFeature,
activeFeature,
pinnedFeature,
filterRange,
travelTimeEntries: entries,
shareCode,
onSessionRejected,
});
// Read the zoom through a ref inside handleAiFilterSubmit so panning/zooming
// doesn't recreate the callback (it sits in the Filters pane's dependency
// chain, which would otherwise re-render on every camera move).
const currentViewZoomRef = useRef<number | undefined>(undefined);
currentViewZoomRef.current = mapData.currentView?.zoom;
const handleAiFilterSubmit = useCallback(
async (query: string) => {
// On a shared link, non-paying users may adjust the shared filters but not build
// a new set. The AI box is a "build me a search" tool (and it also drives
// travel-time entries, which bypass the per-add lock), so block it outright here
// (mirroring handleAddTravelTimeEntry) and surface the upsell instead.
if (lockFilterAdds) {
handleFilterLimitReached();
return;
}
const context = {
filters,
// Send the resolved variant so the AI sees the actual server mode in use,
// not just the UI-side base mode. Lets the model refine no-change/no-buses.
travelTime: activeEntries.map((entry) => ({
mode: resolveTransitVariant(entry),
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 fetchAiFilters(query, hasContext ? context : undefined);
if (!result) return;
// Non-paying users share one cap across feature filters and travel-time
// filters, so clamp the AI's combined result (features first, then travel).
const cappedFilters = filtersUnlimited
? result.filters
: Object.fromEntries(Object.entries(result.filters).slice(0, effectiveFilterCap));
handleSetFilters(cappedFilters);
// Filter out variants the UI cannot represent (e.g. transit-one-change*)
// FIRST so the same filtered list drives both entry state and fly-to.
// Otherwise we'd fly to a destination for a mode the user can't see.
const representableAll = result.travelTimeFilters
.map((tt) => ({ tt, parsed: parseServerMode(tt.mode) }))
.filter((x): x is { tt: typeof x.tt; parsed: NonNullable<typeof x.parsed> } => !!x.parsed);
const travelBudget = filtersUnlimited
? representableAll.length
: Math.max(0, effectiveFilterCap - Object.keys(cappedFilters).length);
const representable = representableAll.slice(0, travelBudget);
handleSetEntries(
representable.map(({ tt, parsed }) => ({
mode: parsed.mode,
noChange: parsed.noChange,
oneChange: parsed.oneChange,
noBuses: parsed.noBuses,
slug: tt.slug,
label: tt.label,
timeRange: [tt.min ?? 0, Math.min(tt.max ?? MAX_TRAVEL_MINUTES, MAX_TRAVEL_MINUTES)] as [
number,
number,
],
useBest: false,
}))
);
// Move the camera to where the matches actually are: flying to the
// travel-time anchor often lands on a viewport with zero matches.
if (result.matchBounds) {
const target = boundsToCenterZoom(result.matchBounds);
mapFlyToRef.current?.(target.lat, target.lng, target.zoom, getMobileMapFlyToOptions());
return;
}
const firstTravelTime = representable[0]?.tt;
if (!firstTravelTime?.slug) return;
try {
const res = await fetch(
apiUrl('travel-destinations', new URLSearchParams({ mode: firstTravelTime.mode })),
authHeaders({})
);
if (!res.ok) return;
const data: { destinations: { slug: string; lat: number; lon: number }[] } =
await res.json();
const destination = data.destinations.find((item) => item.slug === firstTravelTime.slug);
if (destination) {
mapFlyToRef.current?.(
destination.lat,
destination.lon,
currentViewZoomRef.current ?? INITIAL_VIEW_STATE.zoom,
getMobileMapFlyToOptions()
);
}
} catch {
// Filters are already applied; destination panning is non-critical.
}
},
[
activeEntries,
effectiveFilterCap,
fetchAiFilters,
filters,
filtersUnlimited,
lockFilterAdds,
handleFilterLimitReached,
getMobileMapFlyToOptions,
handleSetEntries,
handleSetFilters,
]
);
// Travel-time entries share the non-paying user's filter cap, so block adding one
// when the combined feature + travel count is already at the limit, and block it
// outright on a shared link, where non-paying users can't add filters at all.
const handleAddTravelTimeEntry = useCallback(
(mode: TransportMode) => {
if (
!filtersUnlimited &&
(lockFilterAdds || Object.keys(filters).length + entries.length >= effectiveFilterCap)
) {
handleFilterLimitReached();
return;
}
handleAddEntry(mode);
},
[
filtersUnlimited,
lockFilterAdds,
effectiveFilterCap,
filters,
entries,
handleAddEntry,
handleFilterLimitReached,
]
);
const handleClearAll = useCallback(() => {
handleSetFilters({});
handleCancelPin();
handleSetEntries([]);
}, [handleSetFilters, handleCancelPin, handleSetEntries]);
const handleTravelTimeRemoveEntry = useCallback(
(index: number) => {
const entry = entries[index];
if (entry?.slug && pinnedFeature === travelFieldKey(entry)) {
handleCancelPin();
}
handleRemoveEntry(index);
},
[handleCancelPin, handleRemoveEntry, entries, pinnedFeature]
);
const handleTravelTimeDragEnd = useCallback(
(index: number) => {
const dragEndValue = handleDragEndNoCommit();
if (dragEndValue) handleTimeRangeChange(index, dragEndValue);
},
[handleDragEndNoCommit, handleTimeRangeChange]
);
const filterCounts = useFilterCounts(filters, features, mapData.bounds, entries, shareCode);
const license = useLicense();
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
const activeFilterNames = useMemo(() => new Set(Object.keys(filters)), [filters]);
const activeAmenityFeatureNames = useMemo(
() => getActiveAmenityFilterFeatureNames(filters),
[filters]
);
const areaStatsFields = useMemo(
() =>
groupFeaturesByCategory(features)
.filter((group) => isAreaGroupExpanded(group.name))
.flatMap((group) =>
group.features
.filter((feature) => {
if (group.name !== 'Amenities') return true;
if (isPoiFilterFeatureName(feature.name)) {
return activeAmenityFeatureNames.has(feature.name);
}
return activeFilterNames.has(feature.name);
})
.map((feature) => feature.name)
),
[activeAmenityFeatureNames, activeFilterNames, features, isAreaGroupExpanded]
);
const handleTravelTimeSetDestination = useCallback(
(index: number, slug: string, label: string, _lat: number, _lon: number) => {
handleSetDestination(index, slug, label);
},
[handleSetDestination]
);
const journeyDest = useJourneyDestination(entries);
const {
selectedHexagon,
properties,
propertiesTotal,
loadingProperties,
areaStats,
loadingAreaStats,
unfilteredAreaCount,
areaStatsUseFilters,
setAreaStatsUseFilters,
hoveredHexagon,
rightPaneTab,
setRightPaneTab,
handleHexagonClick,
handleHexagonHover,
handlePropertiesTabClick,
handleLoadMoreProperties,
handleCloseSelection,
loadCrimeRecords,
selectedPostcodeGeometry,
handleLocationSearch,
handleCurrentLocationSearch,
} = useHexagonSelection({
filters,
features,
hexagonData: mapData.committedHexagonData,
resolution: mapData.resolution,
usePostcodeView: mapData.usePostcodeView,
travelTimeEntries: entries,
areaStatsFields,
shareCode,
journeyDest,
});
const handleLocationSearchResult = useCallback(
(result: SearchedLocation | null) => {
if (result) {
const markerLat = result.markerLatitude;
const markerLng = result.markerLongitude;
if (markerLat != null && markerLng != null) {
setCurrentLocation({ lat: markerLat, lng: markerLng });
} else {
setCurrentLocation(null);
}
handleLocationSearch(
result.postcode,
result.geometry,
result.latitude,
result.longitude,
result.openProperties,
result.focusAddress
);
if (isMobile) {
openMobileDrawerForLocationSearch({
lat: markerLat ?? result.latitude,
lng: markerLng ?? result.longitude,
zoom: result.zoom,
});
}
} else {
setCurrentLocation(null);
clearPendingLocationSearchFlyTo();
handleCloseSelection();
}
},
[
clearPendingLocationSearchFlyTo,
handleCloseSelection,
handleLocationSearch,
isMobile,
openMobileDrawerForLocationSearch,
]
);
const handleCurrentLocationFound = useCallback(
(lat: number, lng: number) => {
if (isMobile) {
queueCurrentLocationFlyTo(lat, lng);
} else {
mapFlyToRef.current?.(lat, lng, 17);
}
setCurrentLocation({ lat, lng });
handleCurrentLocationSearch(lat, lng);
if (isMobile) openMobileDrawer();
},
[handleCurrentLocationSearch, isMobile, openMobileDrawer, queueCurrentLocationFlyTo]
);
const fetchedPois = usePOIData(mapData.bounds, selectedPOICategories);
// Disabling POIs (clearing every category) must remove all POI cards/markers
// immediately, not on the next fetch tick. Gate on the selection itself so a
// stale fetch result can never keep cards on screen.
const pois: POI[] = selectedPOICategories.size > 0 ? fetchedPois : EMPTY_POIS;
const overlaysZoomedIn = (mapData.currentView?.zoom ?? 0) >= POSTCODE_ZOOM_THRESHOLD;
const actualListingsFilterParam = useMemo(
() => buildFilterString(filters, features),
[filters, features]
);
const actualListingsTravelParam = useMemo(() => buildTravelParam(entries), [entries]);
// Listings are gated behind the per-user `can_see_listings` flag (off by
// default). Only users with the flag get the toggle button and the fetch;
// the backing API independently rejects anyone else with 403.
const canSeeListings = user?.canSeeListings ?? false;
const actualListingsEnabled = canSeeListings && listingsMode !== 'none';
const { listings: actualListings, loading: actualListingsLoading } = useActualListings(
actualListingsEnabled ? mapData.visibleBounds : null,
{
filterParam: actualListingsFilterParam,
travelParam: actualListingsTravelParam,
shareCode,
}
);
// The fetch returns every listing in view; the new/non-new split is decided
// client-side from each listing's Rightmove channel URL.
const visibleActualListings = useMemo(
() =>
actualListingsEnabled
? filterListingsByMode(actualListings, listingsMode)
: EMPTY_ACTUAL_LISTINGS,
[actualListingsEnabled, actualListings, listingsMode]
);
const handleListingsModeChange = useCallback(
(mode: ListingsMode) => {
if (!canSeeListings) return;
setListingsMode(mode);
},
[canSeeListings]
);
// Losing listings access (e.g. logging out) hides the button that dismisses the
// pane, so close the pane here to avoid leaving an orphaned card on screen.
useEffect(() => {
if (!canSeeListings) {
setOpenMapPane((pane) => (pane === 'listings' ? null : pane));
}
}, [canSeeListings]);
const selectedPostcodeParam =
selectedHexagon?.type === 'postcode'
? selectedHexagon.id
: (pendingInitialPostcode ?? undefined);
useUrlSync(
mapData.currentView,
filters,
features,
selectedPOICategories,
rightPaneTab,
entries,
shareCode,
activeOverlays,
basemap,
crimeTypes,
selectedPostcodeParam,
colorOpacity,
listingsMode
);
useInitialMapPageView(mapData, initialViewState, initialTab, setRightPaneTab);
useInitialPostcodeSelection({
initialPostcode,
isMobile,
flyTo: mapFlyToRef,
onLocationSearch: handleLocationSearch,
onOpenMobileDrawer: openMobileDrawerForLocationSearch,
onSettled: () => setPendingInitialPostcode(null),
});
useHorizontalSwipeNavigationGuard();
useMobileBackNavigationGuard(isMobile);
useScreenshotReadySignal({
screenshotMode,
loading: mapData.loading,
boundsReady: mapData.bounds != null,
dataLength: mapData.data.length,
postcodeDataLength: mapData.postcodeData.length,
usePostcodeView: mapData.usePostcodeView,
});
const handleMobileHexagonClick = useCallback(
(id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => {
handleHexagonClick(id, isPostcode, geometry);
if (id) {
openMobileDrawer();
}
},
[handleHexagonClick, openMobileDrawer]
);
const hexagonLocation = useHexagonLocation(
selectedHexagon,
mapData.postcodeData,
mapData.resolution,
areaStats
);
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial);
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
const densityLabel = t('mapLegend.historicalMatches');
const mobileLegendMeta = useMobileLegendMeta(viewFeature, features);
const mapViewFeature = useMapViewFeature(viewFeature);
const mobileDensityRange = useMobileDensityRange(mapData);
const { exportNotice, clearExportNotice } = useExportController({
bounds: mapData.bounds,
filters,
features,
travelTimeEntries: entries,
selectedOverlays: activeOverlays,
shareCode,
t,
onExportStateChange,
});
const shareAndSaveView = isMobile
? (mapData.currentVisibleView ?? mapData.currentView)
: mapData.currentView;
// Params for share/save/checkout-return/last-session, deliberately WITHOUT the
// selected postcode: the lat/lon/zoom already convey the location, so focusing
// a postcode adds nothing to a shared link and shouldn't be baked into a saved
// search. The live URL (useUrlSync above) still carries `pc` so a reload
// re-opens the selection.
const dashboardParams = useMemo(
() =>
stateToParams(
shareAndSaveView,
filters,
features,
selectedPOICategories,
rightPaneTab,
entries,
shareCode,
activeOverlays,
basemap,
crimeTypes,
undefined,
colorOpacity,
listingsMode
).toString(),
[
activeOverlays,
basemap,
crimeTypes,
colorOpacity,
entries,
features,
filters,
listingsMode,
rightPaneTab,
selectedPOICategories,
shareCode,
shareAndSaveView,
]
);
// dashboardParams changes on every camera move; read it through a ref so the
// save/update handlers (and the Filters pane depending on them) stay stable
// while panning. The ref always holds the params of the latest render.
const dashboardParamsRef = useRef(dashboardParams);
dashboardParamsRef.current = dashboardParams;
const handleSaveSearch = useCallback(
async (name: string) => {
await onSaveSearch?.(name, dashboardParamsRef.current);
},
[onSaveSearch]
);
const handleUpdateEditInPlaceWithParams = useCallback(async () => {
await onUpdateEditInPlace?.(dashboardParamsRef.current);
}, [onUpdateEditInPlace]);
const checkoutReturnPath = useMemo(
() => `/dashboard${dashboardParams ? `?${dashboardParams}` : ''}`,
[dashboardParams]
);
useEffect(() => {
onDashboardParamsChange?.(dashboardParams);
}, [dashboardParams, onDashboardParamsChange]);
const dashboardReady =
!initialLoading && !mapData.loading && mapData.bounds != null && mapData.currentView != null;
useEffect(() => {
onDashboardReadyChange?.(dashboardReady);
}, [dashboardReady, onDashboardReadyChange]);
useEffect(() => {
return () => {
onDashboardReadyChange?.(false);
};
}, [onDashboardReadyChange]);
// Clear the filter-cap upsell once it's no longer relevant: the user became
// licensed/admin, or dropped back under the cap by removing filters. On a shared
// link adds are blocked regardless of count, so don't auto-clear there; the user
// dismisses it themselves. Prevents a stale flag from resurfacing spuriously.
useEffect(() => {
if (
filtersUnlimited ||
(!lockFilterAdds && Object.keys(filters).length + entries.length < effectiveFilterCap)
) {
setFilterLimitHit(false);
}
}, [filtersUnlimited, lockFilterAdds, effectiveFilterCap, filters, entries]);
// When the cap tightens (logout / session invalidated / license lapse), useFilters
// re-clamps the feature filters, but travel-time entries, which count toward the same
// server cap, live here. Trim them to the cap too so the combined request can't exceed
// it and blank the map. Read via ref so this only fires on a cap change, not on every
// travel edit (those are already gated by handleAddTravelTimeEntry).
const entriesRef = useRef(entries);
entriesRef.current = entries;
useEffect(() => {
if (filtersUnlimited) return;
if (entriesRef.current.length > effectiveFilterCap) {
handleSetEntries(entriesRef.current.slice(0, effectiveFilterCap));
}
}, [effectiveFilterCap, filtersUnlimited, handleSetEntries]);
const handleUpgradeClick = useCallback(() => {
onNavigateTo('pricing');
}, [onNavigateTo]);
const handleTogglePoiPane = useCallback(() => {
setOpenMapPane((pane) => (pane === 'poi' ? null : 'poi'));
}, []);
const handleToggleOverlayPane = useCallback(() => {
setOpenMapPane((pane) => (pane === 'overlay' ? null : 'overlay'));
}, []);
const handleClosePoiPane = useCallback(() => {
setOpenMapPane((pane) => (pane === 'poi' ? null : pane));
}, []);
const handleCloseOverlayPane = useCallback(() => {
setOpenMapPane((pane) => (pane === 'overlay' ? null : pane));
}, []);
const handleToggleListingsPane = useCallback(() => {
setOpenMapPane((pane) => (pane === 'listings' ? null : 'listings'));
}, []);
const handleCloseListingsPane = useCallback(() => {
setOpenMapPane((pane) => (pane === 'listings' ? null : pane));
}, []);
const handleAreaTabClick = useCallback(() => {
setRightPaneTab('area');
}, [setRightPaneTab]);
const handleMobileDrawerTabChange = useCallback(
(tab: 'area' | 'properties') => {
if (tab === 'properties') {
handlePropertiesTabClick();
} else {
setRightPaneTab(tab);
}
},
[handlePropertiesTabClick, setRightPaneTab]
);
const renderAreaPane = useCallback(
() => (
<Suspense fallback={<PaneFallback />}>
<AreaPane
stats={areaStats}
globalFeatures={features}
loading={loadingAreaStats}
hexagonId={selectedHexagon?.id || null}
isPostcode={selectedHexagon?.type === 'postcode'}
hexagonLocation={hexagonLocation}
filters={filters}
unfilteredCount={unfilteredAreaCount}
statsUseFilters={areaStatsUseFilters}
onStatsUseFiltersChange={setAreaStatsUseFilters}
travelTimeEntries={activeEntries}
shareCode={shareCode}
isGroupExpanded={isAreaGroupExpanded}
onToggleGroup={toggleAreaGroup}
onLoadCrimeRecords={loadCrimeRecords}
scrollTopRef={areaPaneScrollTopRef}
scrollRestoreKey={
selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null
}
scrollSaveDisabled={loadingAreaStats && areaStats == null}
/>
</Suspense>
),
[
activeEntries,
areaStats,
areaStatsUseFilters,
features,
filters,
hexagonLocation,
isAreaGroupExpanded,
loadingAreaStats,
loadCrimeRecords,
selectedHexagon,
setAreaStatsUseFilters,
shareCode,
toggleAreaGroup,
unfilteredAreaCount,
]
);
const renderPropertiesPane = useCallback(
() => (
<Suspense fallback={<PaneFallback />}>
<PropertiesPane
properties={properties}
total={propertiesTotal}
loading={loadingProperties}
hexagonId={selectedHexagon?.id || null}
statsUseFilters={areaStatsUseFilters}
onStatsUseFiltersChange={setAreaStatsUseFilters}
filtersActive={Object.keys(filters).length + activeEntries.length > 0}
onLoadMore={handleLoadMoreProperties}
scrollTopRef={propertiesPaneScrollTopRef}
scrollRestoreKey={
selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null
}
scrollSaveDisabled={loadingProperties && properties.length === 0}
/>
</Suspense>
),
[
activeEntries,
areaStatsUseFilters,
filters,
handleLoadMoreProperties,
loadingProperties,
properties,
propertiesTotal,
selectedHexagon,
setAreaStatsUseFilters,
]
);
const poiPane = useMemo(
() => (
<Suspense fallback={<PaneFallback />}>
<POIPane
groups={poiCategoryGroups}
selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length}
onClose={handleClosePoiPane}
/>
</Suspense>
),
[handleClosePoiPane, poiCategoryGroups, pois.length, selectedPOICategories]
);
const overlayPane = useMemo(
() => (
<Suspense fallback={<PaneFallback />}>
<OverlayPane
selectedOverlays={activeOverlays}
onOverlaysChange={setActiveOverlays}
selectedCrimeTypes={crimeTypes}
onCrimeTypesChange={setCrimeTypes}
basemap={basemap}
onBasemapChange={setBasemap}
colorOpacity={colorOpacity}
onColorOpacityChange={setColorOpacity}
zoomedIn={overlaysZoomedIn}
onClose={handleCloseOverlayPane}
/>
</Suspense>
),
[activeOverlays, basemap, colorOpacity, crimeTypes, handleCloseOverlayPane, overlaysZoomedIn]
);
const listingsPane = useMemo(
() => (
<Suspense fallback={<PaneFallback />}>
<ListingPane
mode={listingsMode}
onModeChange={handleListingsModeChange}
count={visibleActualListings.length}
loading={actualListingsLoading}
onClose={handleCloseListingsPane}
/>
</Suspense>
),
[
listingsMode,
handleListingsModeChange,
visibleActualListings.length,
actualListingsLoading,
handleCloseListingsPane,
]
);
const filtersPane = useMemo(
() => (
<Suspense fallback={<PaneFallback />}>
<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={entries}
onTravelTimeAddEntry={handleAddTravelTimeEntry}
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
onTravelTimeSetDestination={handleTravelTimeSetDestination}
onTravelTimeRangeChange={handleTimeRangeChange}
onTravelTimeDragEnd={handleTravelTimeDragEnd}
onTravelTimeToggleBest={handleToggleBest}
onTravelTimeToggleNoChange={handleToggleNoChange}
onTravelTimeToggleOneChange={handleToggleOneChange}
onTravelTimeToggleNoBuses={handleToggleNoBuses}
aiFilterLoading={aiFilterLoading}
aiFilterError={aiFilterError}
aiFilterErrorType={aiFilterErrorType}
aiFilterNotes={aiFilterNotes}
aiFilterSummary={aiFilterSummary}
onAiFilterSubmit={handleAiFilterSubmit}
isLoggedIn={!!user}
onLoginRequired={onRegisterClick}
isLicensed={user?.subscription === 'licensed'}
onUpgradeClick={handleUpgradeClick}
onResetTutorial={!isMobile ? tutorial.resetTutorial : undefined}
filterImpacts={filterCounts.impacts}
onClearAll={handleClearAll}
onSaveSearch={onSaveSearch ? handleSaveSearch : undefined}
savingSearch={savingSearch}
editingSearchName={editingSearch?.name ?? null}
onUpdateSearch={
editingSearch && onUpdateEditInPlace ? handleUpdateEditInPlaceWithParams : undefined
}
onExitEditing={onCancelEdit}
destinationDropdownPortal={isMobile ? false : undefined}
/>
</Suspense>
),
[
activeFeature,
aiFilterError,
aiFilterErrorType,
aiFilterLoading,
aiFilterNotes,
aiFilterSummary,
dragValue,
editingSearch,
enabledFeatures,
entries,
features,
filterCounts.impacts,
filters,
handleAddTravelTimeEntry,
handleAddFilter,
handleAiFilterSubmit,
handleClearAll,
handleDragChange,
handleDragEnd,
handleDragStart,
handleFilterChange,
handleRemoveFilter,
handleSaveSearch,
handleTimeRangeChange,
handleToggleBest,
handleToggleNoBuses,
handleToggleNoChange,
handleToggleOneChange,
handleTogglePin,
handleTravelTimeDragEnd,
handleTravelTimeRemoveEntry,
handleTravelTimeSetDestination,
handleUpdateEditInPlaceWithParams,
handleUpgradeClick,
isMobile,
onCancelEdit,
onClearPendingInfoFeature,
onRegisterClick,
onSaveSearch,
onUpdateEditInPlace,
pendingInfoFeature,
pinnedFeature,
savingSearch,
tutorial.resetTutorial,
user,
]
);
const mobileLegend = useMemo(
() => (
<MobileMapLegend
mapViewFeature={mapViewFeature}
colorRange={mapData.colorRange}
viewSource={viewSource}
mobileLegendMeta={mobileLegendMeta}
densityLabel={densityLabel}
densityRange={mobileDensityRange}
theme={theme}
canResetPreviewScale={mapData.canResetPreviewScale}
onCancelPin={handleCancelPin}
onResetPreviewScale={mapData.handleResetPreviewScale}
/>
),
[
densityLabel,
handleCancelPin,
mapData.canResetPreviewScale,
mapData.colorRange,
mapData.handleResetPreviewScale,
mapViewFeature,
mobileDensityRange,
mobileLegendMeta,
theme,
viewSource,
]
);
const toasts = useMemo(
() => (
<ExportToast
notice={exportNotice}
closeLabel={t('common.close')}
onClose={clearExportNotice}
/>
),
[clearExportNotice, exportNotice, t]
);
if (screenshotMode) {
return (
<ScreenshotMapPage
mapData={mapData}
mapViewFeature={mapViewFeature}
filterRange={filterRange}
viewSource={viewSource}
features={features}
initialViewState={initialViewState}
theme={theme}
ogMode={ogMode}
travelTimeEntries={entries}
activeOverlays={activeOverlays}
activeCrimeTypes={crimeTypes}
basemap={basemap}
colorOpacity={colorOpacity}
/>
);
}
const editingBar =
editingSearch && isMobile ? (
<div className="flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700 bg-warm-50 dark:bg-navy-900">
<span
className="flex-1 min-w-0 truncate text-xs text-warm-700 dark:text-warm-200"
title={editingSearch.name}
>
<Trans
i18nKey="savedPage.isBeingUpdated"
values={{ name: editingSearch.name }}
components={{
strong: <strong className="font-semibold text-navy-950 dark:text-warm-100" />,
}}
/>
</span>
<button
onClick={onCancelEdit}
className="shrink-0 cursor-pointer px-2.5 py-1 rounded text-xs font-medium border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-200 hover:bg-warm-100 dark:hover:bg-navy-800"
>
{t('common.cancel')}
</button>
<button
onClick={() => onUpdateEdit?.(dashboardParams)}
disabled={savingSearch || !dashboardReady}
className="shrink-0 cursor-pointer px-2.5 py-1 rounded text-xs font-medium bg-teal-600 text-white hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
>
{savingSearch ? t('savedPage.updating') : t('common.update')}
</button>
</div>
) : null;
// The upgrade modal is shown when a non-paying user hits the filter cap (or tries
// to add filters on a shared link). Dismissing it just closes the modal (the
// filters they have stay applied). Anonymous users are nudged to register for free
// first (a higher filter allowance + save/share/export); paying is the way to
// unlimited. Registered users see the lifetime upgrade directly.
const showFilterUpgradeModal = filterLimitHit && !filtersUnlimited;
const upgradeModal = showFilterUpgradeModal ? (
<Suspense fallback={null}>
<UpgradeModal
isLoggedIn={isLoggedIn}
reason={upgradeReason}
onLoginClick={onLoginClick}
onRegisterClick={onRegisterClick}
onRegisterAndUpgrade={() =>
onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick()
}
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
onClose={() => setFilterLimitHit(false)}
/>
</Suspense>
) : null;
if (isMobile) {
return (
<MobileMapPage
initialLoading={initialLoading}
mapData={mapData}
pois={pois}
activeOverlays={activeOverlays}
activeCrimeTypes={crimeTypes}
basemap={basemap}
colorOpacity={colorOpacity}
mapViewFeature={mapViewFeature}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={handleCancelPin}
features={features}
selectedHexagonId={selectedHexagon?.id || null}
hoveredHexagonId={hoveredHexagon}
onHexagonClick={handleMobileHexagonClick}
onHexagonHover={handleHexagonHover}
initialViewState={initialViewState}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
onCurrentLocationFound={handleCurrentLocationFound}
currentLocation={currentLocation}
actualListings={visibleActualListings}
actualListingsEnabled={actualListingsEnabled}
actualListingsLoading={actualListingsLoading}
onToggleListingsPane={canSeeListings ? handleToggleListingsPane : undefined}
listingsPaneOpen={listingsPaneOpen}
listingsPane={listingsPane}
travelTimeEntries={entries}
bottomScreenInset={mobileBottomSheetHeight}
onBottomSheetCoveredHeightChange={setMobileBottomSheetHeight}
mobileDrawerOpen={mobileDrawerOpen}
onMobileDrawerClose={handleMobileDrawerClose}
onMobileDrawerPanelRectChange={handleMobileDrawerPanelRectChange}
rightPaneTab={rightPaneTab}
onMobileDrawerTabChange={handleMobileDrawerTabChange}
poiPaneOpen={poiPaneOpen}
onTogglePoiPane={handleTogglePoiPane}
poiButtonLabel={t('poiPane.pointsOfInterest')}
poiPane={poiPane}
overlayPaneOpen={overlayPaneOpen}
onToggleOverlayPane={handleToggleOverlayPane}
overlayPane={overlayPane}
filtersPane={filtersPane}
mobileLegend={mobileLegend}
renderAreaPane={renderAreaPane}
renderPropertiesPane={renderPropertiesPane}
toasts={toasts}
upgradeModal={upgradeModal}
editingBar={editingBar}
/>
);
}
return (
<DesktopMapPage
initialLoading={initialLoading}
tutorial={tutorial}
tutorialTheme={tutorialTheme}
leftPaneWidth={leftPaneWidth}
leftPaneHandlers={leftPaneHandlers}
filtersPane={filtersPane}
mapData={mapData}
pois={pois}
activeOverlays={activeOverlays}
activeCrimeTypes={crimeTypes}
basemap={basemap}
colorOpacity={colorOpacity}
mapViewFeature={mapViewFeature}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={handleCancelPin}
features={features}
selectedHexagonId={selectedHexagon?.id || null}
hoveredHexagonId={hoveredHexagon}
onHexagonClick={handleHexagonClick}
onHexagonHover={handleHexagonHover}
initialViewState={initialViewState}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
onCurrentLocationFound={handleCurrentLocationFound}
currentLocation={currentLocation}
actualListings={visibleActualListings}
actualListingsEnabled={actualListingsEnabled}
actualListingsLoading={actualListingsLoading}
onToggleListingsPane={canSeeListings ? handleToggleListingsPane : undefined}
listingsPaneOpen={listingsPaneOpen}
listingsPane={listingsPane}
travelTimeEntries={entries}
densityLabel={densityLabel}
totalCount={filterCounts.total ?? undefined}
poiPaneOpen={poiPaneOpen}
onTogglePoiPane={handleTogglePoiPane}
poiPane={poiPane}
overlayPaneOpen={overlayPaneOpen}
onToggleOverlayPane={handleToggleOverlayPane}
overlayPane={overlayPane}
showSelectionPane={!!selectedHexagon}
rightPaneWidth={rightPaneWidth}
rightPaneHandlers={rightPaneHandlers}
rightPaneTab={rightPaneTab}
onAreaTabClick={handleAreaTabClick}
onPropertiesTabClick={handlePropertiesTabClick}
onCloseSelection={handleCloseSelection}
renderAreaPane={renderAreaPane}
renderPropertiesPane={renderPropertiesPane}
toasts={toasts}
upgradeModal={upgradeModal}
/>
);
}