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>(initialPOICategories); const [activeOverlays, setActiveOverlays] = useState>( () => new Set(initialOverlays ?? []) ); const [crimeTypes, setCrimeTypes] = useState>( () => new Set(initialCrimeTypes ?? CRIME_TYPE_VALUES) ); const [basemap, setBasemap] = useState(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(initialListingsMode); const [pendingInitialPostcode, setPendingInitialPostcode] = useState( 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 4–5-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(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(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 } => !!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( () => ( }> ), [ activeEntries, areaStats, areaStatsUseFilters, features, filters, hexagonLocation, isAreaGroupExpanded, loadingAreaStats, loadCrimeRecords, selectedHexagon, setAreaStatsUseFilters, shareCode, toggleAreaGroup, unfilteredAreaCount, ] ); const renderPropertiesPane = useCallback( () => ( }> 0} onLoadMore={handleLoadMoreProperties} scrollTopRef={propertiesPaneScrollTopRef} scrollRestoreKey={ selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null } scrollSaveDisabled={loadingProperties && properties.length === 0} /> ), [ activeEntries, areaStatsUseFilters, filters, handleLoadMoreProperties, loadingProperties, properties, propertiesTotal, selectedHexagon, setAreaStatsUseFilters, ] ); const poiPane = useMemo( () => ( }> ), [handleClosePoiPane, poiCategoryGroups, pois.length, selectedPOICategories] ); const overlayPane = useMemo( () => ( }> ), [activeOverlays, basemap, colorOpacity, crimeTypes, handleCloseOverlayPane, overlaysZoomedIn] ); const listingsPane = useMemo( () => ( }> ), [ listingsMode, handleListingsModeChange, visibleActualListings.length, actualListingsLoading, handleCloseListingsPane, ] ); const filtersPane = useMemo( () => ( }> ), [ 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( () => ( ), [ densityLabel, handleCancelPin, mapData.canResetPreviewScale, mapData.colorRange, mapData.handleResetPreviewScale, mapViewFeature, mobileDensityRange, mobileLegendMeta, theme, viewSource, ] ); const toasts = useMemo( () => ( ), [clearExportNotice, exportNotice, t] ); if (screenshotMode) { return ( ); } const editingBar = editingSearch && isMobile ? (
, }} />
) : 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 ? ( onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick() } onStartCheckout={() => license.startCheckout(checkoutReturnPath)} onClose={() => setFilterLimitHit(false)} /> ) : null; if (isMobile) { return ( ); } return ( ); }