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 { Layer, Map as MapGL, Source, 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, SchoolMetadata, } from '../../types'; import { zoomToResolution, getVisibleBoundsFromViewState, getBoundsWithBottomScreenInset, getMapStyle, getMapDataBeforeId, getPoiIconUrl, getMapCenterForTargetScreenPoint, } from '../../lib/map-utils'; import { MAP_MIN_ZOOM, MAP_BOUNDS, POI_GROUP_COLORS, POSTCODE_ZOOM_THRESHOLD, POI_AUTO_CARD_ZOOM_THRESHOLD, } 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'; import { type OverlayId, OVERLAY_MIN_ZOOM } from '../../lib/overlays'; import { CRIME_TYPE_VALUES } from '../../lib/crime-types'; import type { BasemapId } from '../../lib/basemaps'; interface MapProps { data: HexagonData[]; postcodeData: PostcodeFeature[]; usePostcodeView: boolean; pois: POI[]; activeOverlays?: Set; activeCrimeTypes?: Set; basemap?: BasemapId; colorOpacity?: number; 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[] = []; const EMPTY_OVERLAYS = new Set(); const ALL_CRIME_TYPES = new Set(CRIME_TYPE_VALUES); 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; } function ListingPopupSingleContent({ listing, t }: { listing: ActualListing; t: TFunction }) { return ( {listing.asking_price != null && (
{formatListingPrice(listing.asking_price)} {listing.price_qualifier ? ( {listing.price_qualifier} ) : null}
)} {formatListingHeadline(listing, t) && (
{formatListingHeadline(listing, t)}
)} {listing.address && (
{listing.address}
)} {listing.postcode && (
{listing.postcode}
)} {listing.floor_area_sqm != null && (
{Math.round(listing.floor_area_sqm)} sqm {listing.asking_price_per_sqm != null ? ` · £${Math.round(listing.asking_price_per_sqm).toLocaleString()}/sqm` : ''}
)} {listing.features.length > 0 && (
    {listing.features.slice(0, 3).map((feature, idx) => (
  • {feature}
  • ))}
)}
Open listing ↗
); } function ListingClusterPopupContent({ count, listings, t, }: { count: number; listings: ActualListing[]; t: TFunction; }) { const visibleCount = listings.length; return (
{count.toLocaleString()} listings
{visibleCount > 0 ? `Showing ${visibleCount.toLocaleString()} of ${count.toLocaleString()}` : 'Grouped near this map position'}
{visibleCount > 0 && ( )}
); } interface PoiPopupCardData { name: string; category: string; icon_category?: string; group: string; emoji: string; school?: SchoolMetadata; } 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; } /** Best-effort web URL from a free-text website field — GIAS stores some with * "http://", some without, and some as bare hostnames. */ function normalizeSchoolWebsiteUrl(raw: string): string | null { const trimmed = raw.trim(); if (!trimmed) return null; if (/^https?:\/\//i.test(trimmed)) return trimmed; if (/^[\w.-]+\.[a-z]{2,}/i.test(trimmed)) return `http://${trimmed}`; return null; } function renderSchoolMetadata(school: SchoolMetadata) { // First line collects the headline classification (phase, type, religious // character) so the popup is scannable even when most fields are absent. const headline: string[] = []; if (school.phase) headline.push(school.phase); if (school.type) headline.push(school.type); const pupilsLine = school.pupils !== undefined && school.capacity !== undefined ? `${school.pupils.toLocaleString()} / ${school.capacity.toLocaleString()} pupils` : school.pupils !== undefined ? `${school.pupils.toLocaleString()} pupils` : school.capacity !== undefined ? `Capacity ${school.capacity.toLocaleString()}` : null; const websiteUrl = school.website ? normalizeSchoolWebsiteUrl(school.website) : null; return (
{headline.length > 0 && ( <>
Type
{headline.join(' · ')}
)} {school.age_range && ( <>
Ages
{school.age_range}
)} {school.gender && school.gender !== 'Mixed' && ( <>
Gender
{school.gender}
)} {pupilsLine && ( <>
Pupils
{pupilsLine}
)} {school.fsm_percent !== undefined && ( <>
Free meal
{school.fsm_percent.toFixed(1)}%
)} {school.ofsted_rating && ( <>
Ofsted
{school.ofsted_rating}
)} {school.sixth_form === 'Has a sixth form' && ( <>
Sixth form
Yes
)} {school.religious_character && school.religious_character !== 'Does not apply' && school.religious_character !== 'None' && ( <>
Religion
{school.religious_character}
)} {school.admissions_policy && ( <>
Admissions
{school.admissions_policy}
)} {school.trust && ( <>
Trust
{school.trust}
)} {(school.address || school.postcode) && ( <>
Address
{[school.address, school.postcode].filter(Boolean).join(', ')}
)} {school.local_authority && ( <>
LA
{school.local_authority}
)} {school.head_name && ( <>
Head
{school.head_name}
)} {websiteUrl && ( <>
Website
{websiteUrl.replace(/^https?:\/\//, '')}
)}
); } function PoiPopupCardContent({ poi }: { poi: PoiPopupCardData }) { return (
{poi.name}
{ts(poi.category)}
{poi.school && renderSchoolMetadata(poi.school)}
); } 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; } function overlayTileUrl(path: string): string { return `${window.location.origin}/api/overlays/${path}/{z}/{x}/{y}`; } function OverlayTileLayers({ activeOverlays, activeCrimeTypes, zoom, }: { activeOverlays: Set; activeCrimeTypes: Set; zoom: number; }) { if (zoom < POSTCODE_ZOOM_THRESHOLD || activeOverlays.size === 0) return null; const showNoise = activeOverlays.has('noise'); const showCrime = activeOverlays.has('crime-hotspots'); const showTrees = activeOverlays.has('trees-outside-woodlands'); const showPropertyBorders = activeOverlays.has('property-borders'); // Restrict the heatmap to the selected crime types. When every type is // selected we omit the filter entirely so all features contribute. const crimeFilter = activeCrimeTypes.size >= CRIME_TYPE_VALUES.length ? undefined : ['in', ['get', 'crime_type'], ['literal', Array.from(activeCrimeTypes)]]; return ( <> {showNoise && ( )} {showCrime && ( )} {showTrees && ( )} {showPropertyBorders && ( )} ); } export default memo(function Map({ data, postcodeData, usePostcodeView, pois, activeOverlays = EMPTY_OVERLAYS, activeCrimeTypes = ALL_CRIME_TYPES, basemap = 'standard', colorOpacity = 1, 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 }); const [mapReady, setMapReady] = useState(false); // 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; } const bounds = getVisibleBoundsFromViewState( renderedViewState, dimensions.width, dimensions.height, bottomScreenInset ); const visibleBounds = bounds; const resolution = zoomToResolution(renderedViewState.zoom); const renderedVisibleCenter = getRenderedVisibleCenter(mapRef.current, dimensions, bottomScreenInset) ?? renderedViewState; onViewChange({ resolution, bounds, visibleBounds, 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 handleLoad = useCallback(() => { setMapReady(true); }, []); 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, basemap), [theme, basemap]); const mapDataBeforeId = useMemo(() => getMapDataBeforeId(basemap), [basemap]); 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, visiblePois, 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, mapDataBeforeId, colorOpacity, }); const showAutoPoiCards = !screenshotMode && viewState.zoom >= POI_AUTO_CARD_ZOOM_THRESHOLD; const autoPoiCards = useMemo(() => { const map = mapRef.current; if (!showAutoPoiCards || !mapReady || !map || dimensions.width <= 0 || dimensions.height <= 0) { return []; } return visiblePois.flatMap((poi) => { const point = map.project([poi.lng, poi.lat]); if ( !Number.isFinite(point.x) || !Number.isFinite(point.y) || point.x < 0 || point.x > dimensions.width || point.y < 0 || point.y > dimensions.height ) { return []; } return [{ poi, x: point.x, y: point.y }]; }); // viewState isn't read directly but drives map.project — recompute when the camera moves. // eslint-disable-next-line react-hooks/exhaustive-deps }, [showAutoPoiCards, mapReady, visiblePois, dimensions, viewState]); return (
0 ? 'map-has-mobile-bottom-sheet' : ''}`} ref={containerRef} style={mapContainerStyle} onMouseLeave={handleMouseLeave} > {!screenshotMode && } {basemap === 'satellite' && (
0 ? bottomScreenInset + 8 : 34 }} > Sentinel-2 cloudless by EOX, contains modified Copernicus Sentinel data 2024
)} {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 ) : ( ))}
)} {autoPoiCards.map(({ poi, x, y }) => (
))} {popupInfo && (!showAutoPoiCards || popupInfo.isCluster) && (
{popupInfo.isCluster ? (
{popupInfo.clusterCount}
{t('common.places')}
) : ( )}
)} {listingPopup && (
{listingPopup.mode === 'single' ? ( ) : ( )}
)} {hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && ( f.properties.postcode === hoveredHexagonId) ?.properties || null : data.find((d) => d.h3 === hoveredHexagonId) || null } filters={filters} features={features} /> )} )}
); });