Fix crime & add actual listings
Some checks failed
CI / Check (push) Failing after 4m1s
Build and publish Docker image / build-and-push (push) Failing after 4m10s

This commit is contained in:
Andras Schmelczer 2026-05-17 11:12:25 +01:00
parent 017902b8e6
commit ebe7bbb51d
34 changed files with 2014 additions and 172754 deletions

View file

@ -0,0 +1,56 @@
import { useEffect, useRef, useState } from 'react';
import type { ActualListing, ActualListingsResponse, Bounds } from '../types';
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
const DEBOUNCE_MS = 200;
export function useActualListings(bounds: Bounds | null) {
const [listings, setListings] = useState<ActualListing[]>([]);
const [truncated, setTruncated] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const requestIdRef = useRef(0);
useEffect(() => {
requestIdRef.current += 1;
const requestId = requestIdRef.current;
if (!bounds) {
abortControllerRef.current?.abort();
if (listings.length !== 0) setListings([]);
if (truncated) setTruncated(false);
return;
}
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
try {
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const params = new URLSearchParams({ bounds: boundsStr });
const res = await fetch(
apiUrl('actual-listings', params),
authHeaders({ signal: abortControllerRef.current.signal })
);
if (!res.ok) throw new Error(`Actual listings fetch failed: HTTP ${res.status}`);
const json: ActualListingsResponse = await res.json();
if (requestIdRef.current !== requestId) return;
setListings(json.listings || []);
setTruncated(Boolean(json.truncated));
} catch (err) {
logNonAbortError('Failed to fetch actual listings', err);
}
}, DEBOUNCE_MS);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
abortControllerRef.current?.abort();
};
// listings/truncated intentionally excluded — they're internal state, not inputs.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bounds]);
return { listings, truncated };
}

View file

@ -11,6 +11,7 @@ import type {
POI,
FeatureMeta,
Bounds,
ActualListing,
} from '../types';
import {
DENSITY_GRADIENT,
@ -21,6 +22,7 @@ import {
import { getFeatureFillColor } from '../lib/map-utils';
import type { TravelTimeEntry } from './useTravelTime';
import { usePoiLayers } from './usePoiLayers';
import { useListingLayers } from './useListingLayers';
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
import { PieHexExtension } from '../lib/PieHexExtension';
@ -30,6 +32,7 @@ interface UseDeckLayersProps {
usePostcodeView: boolean;
zoom: number;
pois: POI[];
actualListings: ActualListing[];
viewFeature: string | null;
colorRange: [number, number] | null;
filterRange: [number, number] | null;
@ -71,6 +74,7 @@ export function useDeckLayers({
usePostcodeView,
zoom,
pois,
actualListings,
viewFeature,
colorRange,
filterRange,
@ -101,6 +105,15 @@ export function useDeckLayers({
const isDark = theme === 'dark';
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
const { poiLayers, popupInfo, clearPopupInfo } = usePoiLayers({ pois, zoom, isDark });
const { listingLayers, listingPopup, clearListingPopup } = useListingLayers({
listings: actualListings,
zoom,
isDark,
hexagonData: data,
postcodeData,
resolution: usePostcodeView ? 0 : Math.round(zoom),
usePostcodeView,
});
// --- Refs for deck.gl accessors ---
const viewFeatureRef = useRef(viewFeature);
@ -606,6 +619,7 @@ export function useDeckLayers({
}
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
if (currentLocationLayer) baseLayers.push(currentLocationLayer);
if (listingLayers.length > 0) baseLayers.push(...listingLayers);
return baseLayers;
}, [
usePostcodeView,
@ -616,19 +630,23 @@ export function useDeckLayers({
poiLayers,
marchingAntsLayer,
currentLocationLayer,
listingLayers,
]);
const handleMouseLeave = useCallback(() => {
setHoverPosition(null);
setHoveredPostcode(null);
clearPopupInfo();
clearListingPopup();
onHexagonHoverRef.current(null);
}, [clearPopupInfo]);
}, [clearPopupInfo, clearListingPopup]);
return {
layers,
popupInfo,
clearPopupInfo,
listingPopup,
clearListingPopup,
hoverPosition,
countRange,
postcodeCountRange,

View 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 };
}