import { useCallback, useMemo, useRef, useState } from 'react'; import type { Layer, PickingInfo } from '@deck.gl/core'; import { ScatterplotLayer, TextLayer } from '@deck.gl/layers'; import { getResolution, latLngToCell } from 'h3-js'; import type { ActualListing, HexagonData, PostcodeFeature } from '../types'; import { trackEvent } from '../lib/analytics'; const PRICE_LABEL_MIN_ZOOM = 14; const ADDRESS_LABEL_MIN_ZOOM = 16; export interface ListingPopupInfo { x: number; y: number; listing: ActualListing; } interface UseListingLayersProps { listings: ActualListing[]; zoom: number; isDark: boolean; hexagonData: HexagonData[]; postcodeData: PostcodeFeature[]; usePostcodeView: boolean; } function normalizePostcode(value: string | undefined | null): string { if (!value) return ''; return value.replace(/\s+/g, '').toUpperCase(); } function formatShortPrice(price: number): string { if (price >= 1_000_000) return `£${(price / 1_000_000).toFixed(price >= 10_000_000 ? 0 : 1)}M`; if (price >= 1_000) return `£${Math.round(price / 1_000)}k`; return `£${price}`; } export function useListingLayers({ listings, zoom, isDark, hexagonData, postcodeData, usePostcodeView, }: UseListingLayersProps) { const [popupInfo, setPopupInfo] = useState(null); // Split into two memos so the inactive view's data changes don't invalidate // the active filtered list. (e.g. in postcode view, hexagonData updates must // not retrigger filtering / downstream layer rebuilds.) const postcodeFilteredListings = useMemo(() => { if (!usePostcodeView || listings.length === 0) return null; const allowed = new Set(); for (const feature of postcodeData) { if (feature.properties.count > 0) { allowed.add(normalizePostcode(feature.properties.postcode)); } } if (allowed.size === 0) return []; return listings.filter((listing) => allowed.has(normalizePostcode(listing.postcode))); }, [listings, postcodeData, usePostcodeView]); const hexFilteredListings = useMemo(() => { if (usePostcodeView || listings.length === 0) return null; const allowed = new Set(); let cellResolution: number | null = null; for (const cell of hexagonData) { if (cell.count > 0) { allowed.add(cell.h3); if (cellResolution == null) cellResolution = getResolution(cell.h3); } } if (allowed.size === 0 || cellResolution == null) return []; const resolutionForLookup = cellResolution; return listings.filter((listing) => { try { return allowed.has(latLngToCell(listing.lat, listing.lon, resolutionForLookup)); } catch { return false; } }); }, [listings, hexagonData, usePostcodeView]); const visibleListings = useMemo(() => { if (listings.length === 0) return listings; return (usePostcodeView ? postcodeFilteredListings : hexFilteredListings) ?? []; }, [listings, usePostcodeView, postcodeFilteredListings, hexFilteredListings]); const handleHover = useCallback((info: PickingInfo) => { if (info.object && info.x !== undefined && info.y !== undefined) { setPopupInfo({ x: info.x, y: info.y, listing: info.object }); } else { setPopupInfo(null); } }, []); const handleClick = useCallback((info: PickingInfo) => { const url = info.object?.listing_url; if (!url) return; trackEvent('Actual Listing Click', { url }); window.open(url, '_blank', 'noopener,noreferrer'); }, []); const handleHoverRef = useRef(handleHover); handleHoverRef.current = handleHover; const stableHover = useCallback( (info: PickingInfo) => handleHoverRef.current(info), [] ); const handleClickRef = useRef(handleClick); handleClickRef.current = handleClick; const stableClick = useCallback( (info: PickingInfo) => handleClickRef.current(info), [] ); const pinShadowLayer = useMemo( () => new ScatterplotLayer({ id: 'actual-listing-shadow', data: visibleListings, getPosition: (d) => [d.lon, d.lat], getRadius: 8, radiusUnits: 'pixels', getFillColor: isDark ? [0, 0, 0, 80] : [0, 0, 0, 40], pickable: false, }), [visibleListings, isDark] ); const pinLayer = useMemo( () => new ScatterplotLayer({ id: 'actual-listing-pin', data: visibleListings, getPosition: (d) => [d.lon, d.lat], getRadius: 7, radiusUnits: 'pixels', getFillColor: [231, 76, 60, 240], getLineColor: [255, 255, 255, 255], getLineWidth: 1.5, lineWidthUnits: 'pixels', stroked: true, pickable: true, autoHighlight: true, highlightColor: [29, 228, 195, 220], onHover: stableHover, onClick: stableClick, }), [visibleListings, stableHover, stableClick] ); const priceLabelLayer = useMemo(() => { if (zoom < PRICE_LABEL_MIN_ZOOM) return null; const labeled = visibleListings.filter((l) => l.asking_price && l.asking_price > 0); return new TextLayer({ id: 'actual-listing-price', data: labeled, getPosition: (d) => [d.lon, d.lat], getText: (d) => formatShortPrice(d.asking_price ?? 0), getSize: 12, getPixelOffset: [0, -16], getColor: isDark ? [255, 255, 255, 240] : [30, 30, 30, 240], fontFamily: 'Inter, system-ui, sans-serif', fontWeight: 700, getTextAnchor: 'middle', getAlignmentBaseline: 'bottom', outlineWidth: 3, outlineColor: isDark ? [10, 10, 10, 220] : [255, 255, 255, 230], fontSettings: { sdf: true }, sizeUnits: 'pixels', sizeMinPixels: 10, sizeMaxPixels: 14, pickable: false, }); }, [visibleListings, zoom, isDark]); const detailLabelLayer = useMemo(() => { if (zoom < ADDRESS_LABEL_MIN_ZOOM) return null; const labeled = visibleListings.filter((l) => l.address || l.bedrooms != null); return new TextLayer({ id: 'actual-listing-detail', data: labeled, getPosition: (d) => [d.lon, d.lat], getText: (d) => { const parts: string[] = []; if (d.bedrooms != null) parts.push(`${d.bedrooms} bed`); if (d.property_sub_type) parts.push(d.property_sub_type); else if (d.property_type) parts.push(d.property_type); return parts.join(' · '); }, getSize: 10, getPixelOffset: [0, 14], getColor: isDark ? [220, 220, 220, 230] : [60, 60, 60, 230], fontFamily: 'Inter, system-ui, sans-serif', fontWeight: 500, getTextAnchor: 'middle', getAlignmentBaseline: 'top', outlineWidth: 3, outlineColor: isDark ? [10, 10, 10, 220] : [255, 255, 255, 230], fontSettings: { sdf: true }, sizeUnits: 'pixels', sizeMinPixels: 9, sizeMaxPixels: 12, pickable: false, }); }, [visibleListings, zoom, isDark]); const listingLayers = useMemo(() => { const layers: Layer[] = [pinShadowLayer, pinLayer]; if (priceLabelLayer) layers.push(priceLabelLayer); if (detailLabelLayer) layers.push(detailLabelLayer); return layers; }, [pinShadowLayer, pinLayer, priceLabelLayer, detailLabelLayer]); const clearListingPopup = useCallback(() => setPopupInfo(null), []); return { listingLayers, listingPopup: popupInfo, clearListingPopup }; }