import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react'; import { Map as MapGL, useControl } 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, } from '../../types'; import { zoomToResolution, getBoundsFromViewState, getMapStyle } from '../../lib/map-utils'; import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS } from '../../lib/consts'; import LocationSearch, { type SearchedLocation } from './LocationSearch'; import MapLegend from './MapLegend'; import HoverCard from './HoverCard'; import type { FeatureFilters } from '../../types'; import { useDeckLayers, osmIdToUrl } from '../../hooks/useDeckLayers'; import { MODE_LABELS, type TravelTimeEntry, travelFieldKey } 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; theme?: 'light' | 'dark'; screenshotMode?: boolean; ogMode?: boolean; filters?: FeatureFilters; selectedPostcodeGeometry?: PostcodeGeometry | null; onLocationSearched?: (location: SearchedLocation | null) => void; bounds?: Bounds | null; hideLegend?: boolean; travelTimeEntries?: TravelTimeEntry[]; travelTimeColorRanges?: Map; } const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = []; const EMPTY_TRAVEL_RANGES = new globalThis.Map(); 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, theme = 'light', screenshotMode = false, ogMode = false, filters = {}, selectedPostcodeGeometry, onLocationSearched, bounds: viewportBounds, hideLegend = false, travelTimeEntries = EMPTY_TRAVEL_ENTRIES, travelTimeColorRanges = EMPTY_TRAVEL_RANGES, }: MapProps) { const containerRef = useRef(null); const [viewState, setViewState] = useState(initialViewState || INITIAL_VIEW_STATE); const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); 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; // Send exact viewport bounds - server will filter to only return // hexagons/postcodes that intersect this precise AABB 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 }) => { setViewState(evt.viewState); }, []); const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => { setViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom })); }, []); const handleMapLoad = useCallback( (_evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => { // Road opacity is set in getMapStyle }, [] ); const mapStyle = useMemo(() => getMapStyle(theme), [theme]); const { layers, popupInfo, hoverPosition, countRange, postcodeCountRange, colorFeatureMeta, handleMouseLeave, primaryTravelIndex, } = useDeckLayers({ data, postcodeData, usePostcodeView, pois, viewFeature, colorRange, filterRange, features, selectedHexagonId, hoveredHexagonId, onHexagonClick, onHexagonHover, theme, selectedPostcodeGeometry, bounds: viewportBounds, travelTimeEntries, travelTimeColorRanges, }); return (
{screenshotMode ? ( ogMode ? (

Your perfect postcode

) : null ) : ( <> {!hideLegend && (primaryTravelIndex >= 0 && travelTimeColorRanges.get(primaryTravelIndex) ? ( ) : viewFeature && colorRange && colorFeatureMeta ? ( ) : ( ))} {popupInfo && (
{popupInfo.name}
{popupInfo.category}
{osmIdToUrl(popupInfo.id) && ( View on OSM )}
)} {hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && ( f.properties.postcode === hoveredHexagonId) ?.properties || null : data.find((d) => d.h3 === hoveredHexagonId) || null } filters={filters} /> )} )}
); });