220 lines
7.3 KiB
TypeScript
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 };
|
|
}
|