perfect-postcode/frontend/src/hooks/useListingLayers.ts
2026-05-17 13:52:11 +01:00

220 lines
7.3 KiB
TypeScript

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<ListingPopupInfo | null>(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<string>();
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<string>();
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<ActualListing>) => {
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<ActualListing>) => {
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<ActualListing>) => handleHoverRef.current(info),
[]
);
const handleClickRef = useRef(handleClick);
handleClickRef.current = handleClick;
const stableClick = useCallback(
(info: PickingInfo<ActualListing>) => handleClickRef.current(info),
[]
);
const pinShadowLayer = useMemo(
() =>
new ScatterplotLayer<ActualListing>({
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<ActualListing>({
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<ActualListing>({
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<ActualListing>({
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 };
}