import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react'; import { useTranslation } from 'react-i18next'; import { Map as MapGL, useControl, ScaleControl } 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, } from '../../types'; import { zoomToResolution, getBoundsFromViewState, getMapStyle, getPoiIconUrl, } from '../../lib/map-utils'; import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS, POI_GROUP_COLORS, POI_DEFAULT_COLOR, } 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[]; 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) => 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; travelTimeEntries?: TravelTimeEntry[]; densityLabel?: string; totalCount?: number; } const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = []; interface Dimensions { width: number; height: number; } 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 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, 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, travelTimeEntries = EMPTY_TRAVEL_ENTRIES, densityLabel: densityLabelProp, totalCount: totalCountProp, }: MapProps) { const containerRef = useRef(null); const { t } = useTranslation(); const modes = useTranslatedModes(); const densityLabel = densityLabelProp ?? t('mapLegend.numberOfProperties'); const [internalViewState, setInternalViewState] = useState( initialViewState || INITIAL_VIEW_STATE ); 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 ? 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; const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height); const resolution = zoomToResolution(viewState.zoom); onViewChange({ resolution, bounds, zoom: viewState.zoom, latitude: viewState.latitude, longitude: viewState.longitude, }); }, [viewState, dimensions, 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 handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => { setInternalViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom })); }, []); if (flyToRef) flyToRef.current = handleFlyTo; const mapStyle = useMemo(() => getMapStyle(theme), [theme]); const { layers, popupInfo, clearPopupInfo, hoverPosition, countRange, postcodeCountRange, colorFeatureMeta, handleMouseLeave, } = useDeckLayers({ data, postcodeData, usePostcodeView, zoom: viewState.zoom, pois, viewFeature, colorRange, filterRange, features, selectedHexagonId, hoveredHexagonId, onHexagonClick, onHexagonHover, theme, selectedPostcodeGeometry, currentLocation, bounds: viewportBounds, travelTimeEntries, }); return (
{ window.__map_idle = true; } : undefined } mapStyle={mapStyle} style={{ width: '100%', height: '100%' }} attributionControl={false} dragRotate={false} touchZoomRotate={true} touchPitch={false} keyboard={true} pitchWithRotate={false} minZoom={MAP_MIN_ZOOM} maxBounds={MAP_BOUNDS} > {!screenshotMode && } {screenshotMode ? ( ogMode ? (
{/* Center: Logo card with hero text */}
Your perfect postcode
{/* Bottom bar */}
Property prices | Energy ratings | Schools | Crime stats | Transport
perfect-postcode.co.uk
) : null ) : ( <>
{!hideLegend && (viewFeature && colorRange ? ( viewFeature.startsWith('tt_') ? ( ) : colorFeatureMeta ? ( ) : null ) : ( ))}
{popupInfo && (
{popupInfo.isCluster ? (
{popupInfo.clusterCount}
places
) : (
{popupInfo.name}
{popupInfo.category}
)}
)} {hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && ( f.properties.postcode === hoveredHexagonId) ?.properties || null : data.find((d) => d.h3 === hoveredHexagonId) || null } filters={filters} features={features} /> )} )}
); });