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 { H3HexagonLayer } from '@deck.gl/geo-layers'; import { GeoJsonLayer, IconLayer, TextLayer } from '@deck.gl/layers'; import type { PickingInfo } from '@deck.gl/core'; import 'maplibre-gl/dist/maplibre-gl.css'; import type { HexagonData, PostcodeFeature, PostcodeProperties, ViewState, ViewChangeParams, POI, FeatureMeta, } from '../../types'; import { GRADIENT, normalizedToColor, countToColor, zoomToResolution, getBoundsFromViewState, emojiToTwemojiUrl, getMapStyle, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK, } from '../../lib/map-utils'; import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS } from '../../lib/consts'; import PostcodeSearch, { type SearchedPostcode } from './PostcodeSearch'; import MapLegend from './MapLegend'; import HoverCard from './HoverCard'; import type { FeatureFilters } from '../../types'; /** Convert POI id (e.g. "n12345") to OpenStreetMap URL */ function osmIdToUrl(id: string): string | null { const match = id.match(/^([nwr])(\d+)$/); if (!match) return null; const typeMap: Record = { n: 'node', w: 'way', r: 'relation' }; return `https://www.openstreetmap.org/${typeMap[match[1]]}/${match[2]}`; } 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) => void; onHexagonHover: (h3: string | null, x?: number, y?: number) => void; initialViewState?: ViewState; theme?: 'light' | 'dark'; screenshotMode?: boolean; ogMode?: boolean; filters?: FeatureFilters; searchedPostcode?: SearchedPostcode | null; onPostcodeSearched?: (postcode: SearchedPostcode | null) => void; } 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 = {}, searchedPostcode, onPostcodeSearched, }: MapProps) { const containerRef = useRef(null); const [viewState, setViewState] = useState(initialViewState || INITIAL_VIEW_STATE); const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null); 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 themeRef = useRef(theme); themeRef.current = theme; 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 [popupInfo, setPopupInfo] = useState<{ x: number; y: number; name: string; category: string; id: string; } | null>(null); const handlePoiHover = useCallback((info: PickingInfo) => { if (info.object && info.x !== undefined && info.y !== undefined) { setPopupInfo({ x: info.x, y: info.y, name: info.object.name, category: info.object.category, id: info.object.id, }); } else { setPopupInfo(null); } }, []); const countRange = useMemo(() => { if (data.length === 0) return { min: 0, max: 1 }; let min = Infinity; let max = -Infinity; for (const d of data) { const c = d.count as number; if (c < min) min = c; if (c > max) max = c; } if (min === max) return { min, max: min + 1 }; return { min, max }; }, [data]); const colorFeatureMeta = useMemo( () => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null), [viewFeature, features] ); const viewFeatureRef = useRef(viewFeature); viewFeatureRef.current = viewFeature; const colorRangeRef = useRef(colorRange); colorRangeRef.current = colorRange; const filterRangeRef = useRef(filterRange); filterRangeRef.current = filterRange; const colorFeatureMetaRef = useRef(colorFeatureMeta); colorFeatureMetaRef.current = colorFeatureMeta; const countRangeRef = useRef(countRange); countRangeRef.current = countRange; const selectedHexagonIdRef = useRef(selectedHexagonId); selectedHexagonIdRef.current = selectedHexagonId; const hoveredHexagonIdRef = useRef(hoveredHexagonId); hoveredHexagonIdRef.current = hoveredHexagonId; const onHexagonClickRef = useRef(onHexagonClick); onHexagonClickRef.current = onHexagonClick; const handleHexagonClick = useCallback((info: PickingInfo) => { if (info.object && 'h3' in info.object) { onHexagonClickRef.current(info.object.h3); } }, []); const onHexagonHoverRef = useRef(onHexagonHover); onHexagonHoverRef.current = onHexagonHover; const handleHexagonHover = useCallback((info: PickingInfo) => { if (info.object && 'h3' in info.object && info.x !== undefined && info.y !== undefined) { setHoverPosition({ x: info.x, y: info.y }); onHexagonHoverRef.current(info.object.h3, info.x, info.y); } else { setHoverPosition(null); onHexagonHoverRef.current(null); } }, []); const handlePoiHoverRef = useRef(handlePoiHover); handlePoiHoverRef.current = handlePoiHover; const stablePoiHover = useCallback((info: PickingInfo) => { handlePoiHoverRef.current(info); }, []); // Compute count range for postcodes (similar to hexagons) const postcodeCountRange = useMemo(() => { if (postcodeData.length === 0) return { min: 0, max: 1 }; let min = Infinity; let max = -Infinity; for (const d of postcodeData) { const c = d.properties.count; if (c < min) min = c; if (c > max) max = c; } if (min === max) return { min, max: min + 1 }; return { min, max }; }, [postcodeData]); const postcodeCountRangeRef = useRef(postcodeCountRange); postcodeCountRangeRef.current = postcodeCountRange; // Track selected/hovered postcode for styling const [selectedPostcode, setSelectedPostcode] = useState(null); const [hoveredPostcode, setHoveredPostcode] = useState(null); const selectedPostcodeRef = useRef(selectedPostcode); selectedPostcodeRef.current = selectedPostcode; const hoveredPostcodeRef = useRef(hoveredPostcode); hoveredPostcodeRef.current = hoveredPostcode; // eslint-disable-next-line @typescript-eslint/no-explicit-any const handlePostcodeClick = useCallback((info: PickingInfo) => { const pc = info.object?.properties?.postcode; if (pc) { setSelectedPostcode((prev) => (prev === pc ? null : pc)); onHexagonClickRef.current(pc, true); } }, []); // eslint-disable-next-line @typescript-eslint/no-explicit-any const handlePostcodeHoverCallback = useCallback((info: PickingInfo) => { const pc = info.object?.properties?.postcode; if (pc && info.x !== undefined && info.y !== undefined) { setHoveredPostcode(pc); setHoverPosition({ x: info.x, y: info.y }); onHexagonHoverRef.current(pc, info.x, info.y); } else { setHoveredPostcode(null); setHoverPosition(null); onHexagonHoverRef.current(null); } }, []); const isDark = theme === 'dark'; const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT; const densityGradientRef = useRef(densityGradient); densityGradientRef.current = densityGradient; const isDarkRef = useRef(isDark); isDarkRef.current = isDark; const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}|${theme}`; const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}|${hoveredPostcode}|${theme}`; const hexLayer = useMemo( () => new H3HexagonLayer({ id: 'h3-hexagons', data, getHexagon: (d) => d.h3, getFillColor: (d) => { const vf = viewFeatureRef.current; const clr = colorRangeRef.current; const fr = filterRangeRef.current; const cfm = colorFeatureMetaRef.current; const dark = isDarkRef.current; if (vf && clr && cfm) { const val = d[`min_${vf}`]; if (val == null) return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number]; if (fr) { const minVal = d[`min_${vf}`] as number; const maxVal = d[`max_${vf}`] as number; if (maxVal < fr[0] || minVal > fr[1]) { return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number]; } } const range = clr[1] - clr[0]; if (range === 0) return [...GRADIENT[0].color, 255] as [number, number, number, number]; const t = ((val as number) - clr[0]) / range; const rgb = normalizedToColor(Math.max(0, Math.min(1, t))); return [...rgb, 255] as [number, number, number, number]; } const cr = countRangeRef.current; const c = d.count as number; const t = (c - cr.min) / (cr.max - cr.min); return [...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current), 255] as [ number, number, number, number, ]; }, getLineColor: (d) => { if (d.h3 === selectedHexagonIdRef.current) return [255, 255, 255, 255] as [number, number, number, number]; if (d.h3 === hoveredHexagonIdRef.current) return [29, 228, 195, 200] as [number, number, number, number]; return [0, 0, 0, 0] as [number, number, number, number]; }, getLineWidth: (d) => { if (d.h3 === selectedHexagonIdRef.current) return 3; if (d.h3 === hoveredHexagonIdRef.current) return 2; return 0; }, lineWidthUnits: 'pixels', updateTriggers: { getFillColor: [colorTrigger], getLineColor: [colorTrigger], getLineWidth: [colorTrigger], }, extruded: false, pickable: true, opacity: 1, highPrecision: true, onClick: handleHexagonClick, onHover: handleHexagonHover, // @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps beforeId: 'landuse_park', }), [data, colorTrigger, handleHexagonClick, handleHexagonHover] ); const postcodeLayer = useMemo( () => new GeoJsonLayer({ id: 'postcode-polygons', data: postcodeData as PostcodeFeature[], getFillColor: (f) => { const d = f.properties; const vf = viewFeatureRef.current; const clr = colorRangeRef.current; const fr = filterRangeRef.current; const cfm = colorFeatureMetaRef.current; const dark = isDarkRef.current; if (vf && clr && cfm) { const val = d[`min_${vf}`]; if (val == null) return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number]; if (fr) { const minVal = d[`min_${vf}`] as number; const maxVal = d[`max_${vf}`] as number; if (maxVal < fr[0] || minVal > fr[1]) { return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number]; } } const range = clr[1] - clr[0]; if (range === 0) return [...GRADIENT[0].color, 255] as [number, number, number, number]; const t = ((val as number) - clr[0]) / range; const rgb = normalizedToColor(Math.max(0, Math.min(1, t))); return [...rgb, 255] as [number, number, number, number]; } const cr = postcodeCountRangeRef.current; const c = d.count; const t = (c - cr.min) / (cr.max - cr.min); return [...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current), 255] as [ number, number, number, number, ]; }, getLineColor: (f) => { const pc = f.properties.postcode; const dark = isDarkRef.current; if (pc === selectedPostcodeRef.current) return [255, 255, 255, 255] as [number, number, number, number]; if (pc === hoveredPostcodeRef.current) return [29, 228, 195, 200] as [number, number, number, number]; return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [number, number, number, number]; }, getLineWidth: (f) => { const pc = f.properties.postcode; if (pc === selectedPostcodeRef.current) return 3; if (pc === hoveredPostcodeRef.current) return 2; return 1; }, lineWidthUnits: 'pixels', updateTriggers: { getFillColor: [postcodeColorTrigger], getLineColor: [postcodeColorTrigger], getLineWidth: [postcodeColorTrigger], }, extruded: false, pickable: true, onClick: handlePostcodeClick, onHover: handlePostcodeHoverCallback, }), [postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback] ); const postcodeLabelsLayer = useMemo( () => new TextLayer({ id: 'postcode-labels', data: postcodeData, getPosition: (f) => f.properties.centroid, getText: (f) => f.properties.postcode, getSize: 12, getColor: theme === 'dark' ? [255, 255, 255, 240] : [40, 40, 40, 220], getTextAnchor: 'middle', getAlignmentBaseline: 'center', fontFamily: 'Inter, system-ui, sans-serif', fontWeight: 600, outlineWidth: 2, outlineColor: theme === 'dark' ? [30, 30, 30, 200] : [255, 255, 255, 200], sizeUnits: 'pixels', sizeMinPixels: 10, sizeMaxPixels: 14, billboard: false, pickable: false, }), [postcodeData, theme] ); const poiLayer = useMemo( () => new IconLayer({ id: 'poi-icons', data: pois, getPosition: (d) => [d.lng, d.lat], getIcon: (d) => ({ url: emojiToTwemojiUrl(d.emoji), width: 72, height: 72, }), getSize: 24, sizeMinPixels: 20, sizeMaxPixels: 40, pickable: true, onHover: stablePoiHover, }), [pois, stablePoiHover] ); // Check if the searched postcode has data (passes current filters) const searchedPostcodeHasData = useMemo(() => { if (!searchedPostcode) return false; return postcodeData.some((f) => f.properties.postcode === searchedPostcode.postcode); }, [searchedPostcode, postcodeData]); // Highlight layer for searched postcode const searchedPostcodeHighlightLayer = useMemo(() => { if (!searchedPostcode) return null; const hasData = searchedPostcodeHasData; const feature = { type: 'Feature' as const, geometry: searchedPostcode.geometry, properties: {}, }; return new GeoJsonLayer({ id: 'searched-postcode-highlight', data: [feature], getFillColor: hasData ? [29, 228, 195, 40] // teal tint when has data : [255, 180, 0, 30], // orange tint when filtered out getLineColor: hasData ? [29, 228, 195, 255] // solid teal when has data : [255, 180, 0, 200], // orange when filtered out (no matching properties) getLineWidth: hasData ? 4 : 3, lineWidthUnits: 'pixels', stroked: true, filled: true, pickable: false, }); }, [searchedPostcode, searchedPostcodeHasData]); const layers = useMemo(() => { const baseLayers = usePostcodeView ? [postcodeLayer, postcodeLabelsLayer, poiLayer] : [hexLayer, poiLayer]; if (searchedPostcodeHighlightLayer) { return [...baseLayers, searchedPostcodeHighlightLayer]; } return baseLayers; }, [usePostcodeView, hexLayer, postcodeLayer, postcodeLabelsLayer, poiLayer, searchedPostcodeHighlightLayer]); const handleMouseLeave = useCallback(() => { setHoverPosition(null); setHoveredPostcode(null); setPopupInfo(null); onHexagonHoverRef.current(null); }, []); return (
{screenshotMode ? ( ogMode ? (

Your perfect postcodes

) : null ) : ( <> {viewSource === 'eye' && viewFeature && (
Previewing “{viewFeature}”
)} {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} /> )} )}
); });