import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react'; import type { CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { Map as MapGL, useControl, ScaleControl } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre'; import { MapboxOverlay } from '@deck.gl/mapbox'; import 'maplibre-gl/dist/maplibre-gl.css'; import type { HexagonData, PostcodeFeature, PostcodeGeometry, ViewState, ViewChangeParams, POI, FeatureMeta, Bounds, MapFlyToOptions, ActualListing, } from '../../types'; import { zoomToResolution, getBoundsFromViewState, getBoundsWithBottomScreenInset, getMapStyle, getPoiIconUrl, getMapCenterForTargetScreenPoint, } from '../../lib/map-utils'; import { MAP_MIN_ZOOM, MAP_BOUNDS, POI_GROUP_COLORS } from '../../lib/consts'; import LocationSearch, { type SearchedLocation } from './LocationSearch'; import MapLegend from './MapLegend'; import HoverCard from './HoverCard'; import { LogoIcon } from '../ui/icons/LogoIcon'; import { CloseIcon } from '../ui/icons/CloseIcon'; import type { FeatureFilters } from '../../types'; import { useDeckLayers } from '../../hooks/useDeckLayers'; import { useTranslatedModes, type TravelTimeEntry } from '../../hooks/useTravelTime'; import { ts } from '../../i18n/server'; interface MapProps { data: HexagonData[]; postcodeData: PostcodeFeature[]; usePostcodeView: boolean; pois: POI[]; actualListings?: ActualListing[]; onViewChange: (params: ViewChangeParams) => void; viewFeature: string | null; colorRange: [number, number] | null; filterRange: [number, number] | null; viewSource: 'drag' | 'eye' | null; onCancelPin: () => void; onResetPreviewScale?: () => void; canResetPreviewScale?: boolean; features: FeatureMeta[]; selectedHexagonId: string | null; hoveredHexagonId: string | null; onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void; onHexagonHover: (h3: string | null, x?: number, y?: number) => void; initialViewState: ViewState; flyToRef?: React.MutableRefObject< ((lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void) | null >; theme?: 'light' | 'dark'; screenshotMode?: boolean; ogMode?: boolean; filters?: FeatureFilters; selectedPostcodeGeometry?: PostcodeGeometry | null; onLocationSearched?: (location: SearchedLocation | null) => void; onCurrentLocationFound?: (lat: number, lng: number) => void; currentLocation?: { lat: number; lng: number } | null; bounds?: Bounds | null; hideLegend?: boolean; hideLocationSearch?: boolean; hideTopCardsWhenNarrow?: boolean; travelTimeEntries?: TravelTimeEntry[]; densityLabel?: string; totalCount?: number; bottomScreenInset?: number; } const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = []; const EMPTY_ACTUAL_LISTINGS: ActualListing[] = []; function formatListingPrice(price: number): string { return `£${price.toLocaleString()}`; } function formatListingHeadline(listing: ActualListing, t: TFunction): string | null { const parts: string[] = []; if (listing.bedrooms != null) parts.push(t('common.bedsCount', { count: listing.bedrooms })); if (listing.bathrooms != null) parts.push(t('common.bathsCount', { count: listing.bathrooms })); if (listing.property_sub_type) parts.push(listing.property_sub_type); else if (listing.property_type) parts.push(listing.property_type); return parts.length > 0 ? parts.join(' · ') : null; } interface Dimensions { width: number; height: number; } const DESKTOP_TOP_CARD_WIDTH = 300; const DESKTOP_TOP_CARD_GAP = 8; const DESKTOP_TOP_CARD_HORIZONTAL_INSET = 24; const DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH = DESKTOP_TOP_CARD_WIDTH + DESKTOP_TOP_CARD_HORIZONTAL_INSET; const DESKTOP_TOP_CARDS_ROW_MIN_MAP_WIDTH = DESKTOP_TOP_CARD_WIDTH * 2 + DESKTOP_TOP_CARD_GAP + DESKTOP_TOP_CARD_HORIZONTAL_INSET; const DESKTOP_TOP_CARD_CLASS = 'w-[300px]'; const DESKTOP_LOCATION_SEARCH_INPUT_CLASS = 'px-2 py-2 text-sm w-full border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500'; type MapContainerStyle = CSSProperties & { '--map-mobile-bottom-inset'?: string; }; function resolveInset( pixelValue: number | undefined, ratioValue: number | undefined, size: number ) { return Math.max(0, (pixelValue ?? 0) + (ratioValue ?? 0) * size); } function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } function getMapRelativeVisibleAreaCenter(dimensions: Dimensions, options?: MapFlyToOptions) { const area = options?.visibleArea; const leftInset = resolveInset(area?.left, area?.leftRatio, dimensions.width); const rightInset = resolveInset(area?.right, area?.rightRatio, dimensions.width); const topInset = resolveInset(area?.top, area?.topRatio, dimensions.height); const bottomInset = resolveInset(area?.bottom, area?.bottomRatio, dimensions.height); const left = Math.min(dimensions.width, leftInset); const right = Math.max(left, dimensions.width - Math.min(dimensions.width, rightInset)); const top = Math.min(dimensions.height, topInset); const bottom = Math.max(top, dimensions.height - Math.min(dimensions.height, bottomInset)); return { x: (left + right) / 2, y: (top + bottom) / 2, }; } function getViewportRelativeVisibleAreaCenter( dimensions: Dimensions, container: HTMLDivElement | null, options?: MapFlyToOptions ) { const area = options?.visibleViewportArea; if (!area || !container) return null; const rect = container.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const viewportLeft = resolveInset(area.left, area.leftRatio, viewportWidth); const viewportRight = viewportWidth - resolveInset(area.right, area.rightRatio, viewportWidth); const viewportTop = resolveInset(area.top, area.topRatio, viewportHeight); const viewportBottom = viewportHeight - resolveInset(area.bottom, area.bottomRatio, viewportHeight); const left = clamp(viewportLeft - rect.left, 0, dimensions.width); const right = clamp(viewportRight - rect.left, left, dimensions.width); const top = clamp(viewportTop - rect.top, 0, dimensions.height); const bottom = clamp(viewportBottom - rect.top, top, dimensions.height); return { x: (left + right) / 2, y: (top + bottom) / 2, }; } interface DeckWithPrivateDraw { _drawLayers?: ( redrawReason: string, renderOptions?: { viewports?: unknown[]; [key: string]: unknown } ) => unknown; __propertyMapNullViewportPatch?: boolean; } function patchNullViewportDraw(overlay: MapboxOverlay) { const deck = (overlay as unknown as { _deck?: DeckWithPrivateDraw })._deck; if (!deck || deck.__propertyMapNullViewportPatch || typeof deck._drawLayers !== 'function') { return; } const drawLayers = deck._drawLayers.bind(deck); deck._drawLayers = (redrawReason, renderOptions) => { const viewports = renderOptions?.viewports; if (viewports) { // Split-route startup can hand deck.gl a transient null viewport before MapLibre has sized the map. const nonNullViewports = viewports.filter(Boolean); if (nonNullViewports.length === 0) return; if (nonNullViewports.length !== viewports.length) { return drawLayers(redrawReason, { ...renderOptions, viewports: nonNullViewports }); } } return drawLayers(redrawReason, renderOptions); }; deck.__propertyMapNullViewportPatch = true; } class SafeMapboxOverlay extends MapboxOverlay { onAdd(map: unknown) { const element = super.onAdd(map); patchNullViewportDraw(this); return element; } setProps(props: Parameters[0]) { super.setProps(props); patchNullViewportDraw(this); } } function getPoiGroupColor(group: string): [number, number, number] { const color = POI_GROUP_COLORS[group]; if (!color) { throw new Error(`Missing POI group color for '${group}'`); } return color; } function getRenderedViewState(map: MapRef | null): ViewState | null { if (!map) return null; const center = map.getCenter(); return { longitude: center.lng, latitude: center.lat, zoom: map.getZoom(), pitch: map.getPitch(), bearing: map.getBearing(), }; } function getRenderedVisibleCenter( map: MapRef | null, dimensions: Dimensions, bottomScreenInset: number ): Pick | null { if (!map || dimensions.width <= 0 || dimensions.height <= 0) return null; const visibleBottomInset = clamp(bottomScreenInset, 0, dimensions.height); const visibleCenterY = (dimensions.height - visibleBottomInset) / 2; const center = map.unproject([dimensions.width / 2, visibleCenterY]); return { longitude: center.lng, latitude: center.lat, }; } function DeckOverlay({ layers, getTooltip, }: { // eslint-disable-next-line @typescript-eslint/no-explicit-any layers: any[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any getTooltip: any; }) { const overlay = useControl(() => new SafeMapboxOverlay({ interleaved: true })); useEffect(() => { overlay.setProps({ layers: layers.filter(Boolean), getTooltip, }); }, [overlay, layers, getTooltip]); return null; } export default memo(function Map({ data, postcodeData, usePostcodeView, pois, actualListings = EMPTY_ACTUAL_LISTINGS, onViewChange, viewFeature, colorRange, filterRange, viewSource, onCancelPin, onResetPreviewScale, canResetPreviewScale = false, features, selectedHexagonId, hoveredHexagonId, onHexagonClick, onHexagonHover, initialViewState, flyToRef, theme = 'light', screenshotMode = false, ogMode = false, filters = {}, selectedPostcodeGeometry, onLocationSearched, onCurrentLocationFound, currentLocation, bounds: viewportBounds, hideLegend = false, hideLocationSearch = false, hideTopCardsWhenNarrow = false, travelTimeEntries = EMPTY_TRAVEL_ENTRIES, densityLabel: densityLabelProp, totalCount: totalCountProp, bottomScreenInset = 0, }: MapProps) { const containerRef = useRef(null); const mapRef = useRef(null); const { t } = useTranslation(); const modes = useTranslatedModes(); const densityLabel = densityLabelProp ?? t('mapLegend.numberOfProperties'); const [internalViewState, setInternalViewState] = useState(initialViewState); const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); // In screenshot mode, use the prop directly for instant updates (no async lag) const viewState = screenshotMode ? initialViewState : internalViewState; useEffect(() => { const container = containerRef.current; if (!container) return; let resizeTimer: ReturnType | null = null; let initialized = false; const observer = new ResizeObserver((entries) => { const { width, height } = entries[0].contentRect; if (width > 0 && height > 0) { if (!initialized) { initialized = true; setDimensions({ width, height }); } else { if (resizeTimer) clearTimeout(resizeTimer); resizeTimer = setTimeout(() => setDimensions({ width, height }), 150); } } }); observer.observe(container); return () => { observer.disconnect(); if (resizeTimer) clearTimeout(resizeTimer); }; }, []); useEffect(() => { if (dimensions.width === 0 || dimensions.height === 0) return; let frame = 0; const emit = () => { const renderedViewState = getRenderedViewState(mapRef.current); // mapRef can be null on the very first effect run if MapLibre hasn't // finished mounting; retry next frame so the initial bounds always reach // the data hook. if (!renderedViewState) { frame = window.requestAnimationFrame(emit); return; } // The bottom sheet can reveal covered map area without a pan/zoom event. const dataBoundsHeight = dimensions.height + Math.max(0, bottomScreenInset); const bounds = getBoundsFromViewState(renderedViewState, dimensions.width, dataBoundsHeight); const resolution = zoomToResolution(renderedViewState.zoom); const renderedVisibleCenter = getRenderedVisibleCenter(mapRef.current, dimensions, bottomScreenInset) ?? renderedViewState; onViewChange({ resolution, bounds, zoom: renderedViewState.zoom, latitude: renderedViewState.latitude, longitude: renderedViewState.longitude, visibleLatitude: renderedVisibleCenter.latitude, visibleLongitude: renderedVisibleCenter.longitude, }); }; frame = window.requestAnimationFrame(emit); return () => window.cancelAnimationFrame(frame); }, [viewState, dimensions, bottomScreenInset, onViewChange]); const handleMove = useCallback((evt: { viewState: ViewState }) => { setInternalViewState((prev) => { const next = evt.viewState; // Skip re-render when viewport values haven't changed (e.g. container resize // fires move events with identical lat/lng/zoom). Returning the same reference // tells React to bail out. if ( prev.latitude === next.latitude && prev.longitude === next.longitude && prev.zoom === next.zoom && prev.pitch === next.pitch && prev.bearing === next.bearing ) { return prev; } return next; }); }, []); const handleIdle = useCallback(() => { if (screenshotMode) window.__map_idle = true; }, [screenshotMode]); const handleFlyTo = useCallback( (lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => { setInternalViewState((prev) => { const targetPoint = getViewportRelativeVisibleAreaCenter(dimensions, containerRef.current, options) ?? getMapRelativeVisibleAreaCenter(dimensions, options); const center = getMapCenterForTargetScreenPoint( lat, lng, zoom, dimensions.width, dimensions.height, targetPoint.x, targetPoint.y ); return { ...prev, ...center, zoom }; }); }, [dimensions] ); if (flyToRef) flyToRef.current = handleFlyTo; const mapStyle = useMemo(() => getMapStyle(theme), [theme]); const maxBounds = useMemo( () => getBoundsWithBottomScreenInset(MAP_BOUNDS, MAP_MIN_ZOOM, bottomScreenInset), [bottomScreenInset] ); const mapContainerStyle = useMemo( () => (bottomScreenInset > 0 ? { '--map-mobile-bottom-inset': `${bottomScreenInset}px` } : {}), [bottomScreenInset] ); const hideDesktopTopCardsForWidth = hideTopCardsWhenNarrow && dimensions.width > 0 && dimensions.width < DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH; const stackDesktopTopCards = hideTopCardsWhenNarrow && dimensions.width >= DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH && dimensions.width < DESKTOP_TOP_CARDS_ROW_MIN_MAP_WIDTH; const showLocationSearch = !hideLocationSearch && !hideDesktopTopCardsForWidth; const showLegend = !hideLegend && !hideDesktopTopCardsForWidth; const desktopTopCardsLayoutClass = stackDesktopTopCards ? 'flex-col items-start' : 'items-start justify-between'; const { layers, popupInfo, clearPopupInfo, listingPopup, clearListingPopup, hoverPosition, countRange, postcodeCountRange, colorFeatureMeta, handleMouseLeave, } = useDeckLayers({ data, postcodeData, usePostcodeView, zoom: viewState.zoom, pois, actualListings, viewFeature, colorRange, filterRange, features, selectedHexagonId, hoveredHexagonId, onHexagonClick, onHexagonHover, theme, selectedPostcodeGeometry, currentLocation, bounds: viewportBounds, travelTimeEntries, }); return (
0 ? 'map-has-mobile-bottom-sheet' : ''}`} ref={containerRef} style={mapContainerStyle} onMouseLeave={handleMouseLeave} > {!screenshotMode && } {screenshotMode ? ( ogMode ? (
{/* Center: Logo card with hero text */}
{t('map.ogTitle')}
{/* Bottom bar */}
{t('map.ogPropertyPrices')} | {t('map.ogEnergyRatings')} | {t('map.ogSchools')} | {t('map.ogCrimeStats')} | {t('map.ogTransport')}
perfect-postcode.co.uk
) : null ) : ( <> {(showLocationSearch || showLegend) && (
{showLocationSearch && ( )} {showLegend && (viewFeature && colorRange ? ( viewFeature.startsWith('tt_') ? ( ) : colorFeatureMeta ? ( ) : null ) : ( ))}
)} {popupInfo && (
{popupInfo.isCluster ? (
{popupInfo.clusterCount}
{t('common.places')}
) : (
{popupInfo.name}
{ts(popupInfo.category)}
)}
)} {listingPopup && (
{listingPopup.listing.asking_price != null && (
{formatListingPrice(listingPopup.listing.asking_price)} {listingPopup.listing.price_qualifier ? ( {listingPopup.listing.price_qualifier} ) : null}
)} {formatListingHeadline(listingPopup.listing, t) && (
{formatListingHeadline(listingPopup.listing, t)}
)} {listingPopup.listing.address && (
{listingPopup.listing.address}
)} {listingPopup.listing.postcode && (
{listingPopup.listing.postcode}
)} {listingPopup.listing.floor_area_sqm != null && (
{Math.round(listingPopup.listing.floor_area_sqm)} sqm {listingPopup.listing.asking_price_per_sqm != null ? ` · £${Math.round(listingPopup.listing.asking_price_per_sqm).toLocaleString()}/sqm` : ''}
)} {listingPopup.listing.features.length > 0 && (
    {listingPopup.listing.features.slice(0, 3).map((feature, idx) => (
  • {feature}
  • ))}
)}
Open listing ↗
)} {hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && ( f.properties.postcode === hoveredHexagonId) ?.properties || null : data.find((d) => d.h3 === hoveredHexagonId) || null } filters={filters} features={features} /> )} )}
); });