Fix crime & add actual listings
This commit is contained in:
parent
017902b8e6
commit
ebe7bbb51d
34 changed files with 2014 additions and 172754 deletions
205
frontend/src/hooks/useListingLayers.ts
Normal file
205
frontend/src/hooks/useListingLayers.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
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);
|
||||
|
||||
const visibleListings = useMemo(() => {
|
||||
if (listings.length === 0) return listings;
|
||||
if (usePostcodeView) {
|
||||
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)));
|
||||
}
|
||||
const allowed = new Set<string>();
|
||||
for (const cell of hexagonData) {
|
||||
if (cell.count > 0) allowed.add(cell.h3);
|
||||
}
|
||||
if (allowed.size === 0) return [];
|
||||
return listings.filter((listing) => {
|
||||
try {
|
||||
return allowed.has(latLngToCell(listing.lat, listing.lon, resolution));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}, [listings, hexagonData, postcodeData, resolution, usePostcodeView]);
|
||||
|
||||
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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue