Fix crime & add actual listings
This commit is contained in:
parent
017902b8e6
commit
ebe7bbb51d
34 changed files with 2014 additions and 172754 deletions
56
frontend/src/hooks/useActualListings.ts
Normal file
56
frontend/src/hooks/useActualListings.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
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