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 } 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'; 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; 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; bounds?: Bounds | null; hideLegend?: boolean; travelTimeEntries?: TravelTimeEntry[]; densityLabel?: string; } const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = []; interface Dimensions { width: number; height: number; } 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 MapboxOverlay({ interleaved: true })); const prevLayersRef = useRef(layers); const prevTooltipRef = useRef(getTooltip); if (layers !== prevLayersRef.current || getTooltip !== prevTooltipRef.current) { prevLayersRef.current = layers; prevTooltipRef.current = getTooltip; overlay.setProps({ layers, getTooltip }); } return null; } export default memo(function Map({ data, postcodeData, usePostcodeView, pois, onViewChange, viewFeature, colorRange, filterRange, viewSource, onCancelPin, features, selectedHexagonId, hoveredHexagonId, onHexagonClick, onHexagonHover, initialViewState, flyToRef, theme = 'light', screenshotMode = false, ogMode = false, filters = {}, selectedPostcodeGeometry, onLocationSearched, bounds: viewportBounds, hideLegend = false, travelTimeEntries = EMPTY_TRAVEL_ENTRIES, densityLabel = 'Number of properties', }: MapProps) { const containerRef = useRef(null); const { t } = useTranslation(); const modes = useTranslatedModes(); 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; const observer = new ResizeObserver((entries) => { const { width, height } = entries[0].contentRect; if (width > 0 && height > 0) { setDimensions({ width, height }); } }); observer.observe(container); return () => observer.disconnect(); }, []); 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(evt.viewState); }, []); 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, 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.emoji}
{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} /> )} )}
); });