All good
This commit is contained in:
parent
6ea544a0f6
commit
6cc7288126
45 changed files with 929 additions and 1043 deletions
|
|
@ -7,11 +7,12 @@ const DEBOUNCE_MS = 200;
|
|||
interface UseActualListingsOptions {
|
||||
filterParam?: string;
|
||||
travelParam?: string;
|
||||
shareCode?: string;
|
||||
}
|
||||
|
||||
export function useActualListings(
|
||||
bounds: Bounds | null,
|
||||
{ filterParam = '', travelParam = '' }: UseActualListingsOptions = {}
|
||||
{ filterParam = '', travelParam = '', shareCode = '' }: UseActualListingsOptions = {}
|
||||
) {
|
||||
const [listings, setListings] = useState<ActualListing[]>([]);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
|
@ -38,11 +39,15 @@ export function useActualListings(
|
|||
const params = new URLSearchParams({ bounds: boundsStr });
|
||||
if (filterParam) params.set('filters', filterParam);
|
||||
if (travelParam) params.set('travel', travelParam);
|
||||
if (shareCode) params.set('share', shareCode);
|
||||
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}`);
|
||||
if (!res.ok) {
|
||||
if (requestIdRef.current === requestId) setListings([]);
|
||||
throw new Error(`Actual listings fetch failed: HTTP ${res.status}`);
|
||||
}
|
||||
const json: ActualListingsResponse = await res.json();
|
||||
if (requestIdRef.current !== requestId) return;
|
||||
setListings(json.listings || []);
|
||||
|
|
@ -57,7 +62,7 @@ export function useActualListings(
|
|||
};
|
||||
// listings intentionally excluded — it's internal state, not an input.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bounds, filterParam, travelParam]);
|
||||
}, [bounds, filterParam, travelParam, shareCode]);
|
||||
|
||||
return { listings };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,9 +109,6 @@ export function useDeckLayers({
|
|||
listings: actualListings,
|
||||
zoom,
|
||||
isDark,
|
||||
hexagonData: data,
|
||||
postcodeData,
|
||||
usePostcodeView,
|
||||
});
|
||||
|
||||
// --- Refs for deck.gl accessors ---
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
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 type { ActualListing } from '../types';
|
||||
import { trackEvent } from '../lib/analytics';
|
||||
|
||||
const PRICE_LABEL_MIN_ZOOM = 14;
|
||||
|
|
@ -19,14 +18,6 @@ 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 {
|
||||
|
|
@ -35,57 +26,9 @@ function formatShortPrice(price: number): string {
|
|||
return `£${price}`;
|
||||
}
|
||||
|
||||
export function useListingLayers({
|
||||
listings,
|
||||
zoom,
|
||||
isDark,
|
||||
hexagonData,
|
||||
postcodeData,
|
||||
usePostcodeView,
|
||||
}: UseListingLayersProps) {
|
||||
export function useListingLayers({ listings, zoom, isDark }: 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 });
|
||||
|
|
@ -119,21 +62,21 @@ export function useListingLayers({
|
|||
() =>
|
||||
new ScatterplotLayer<ActualListing>({
|
||||
id: 'actual-listing-shadow',
|
||||
data: visibleListings,
|
||||
data: listings,
|
||||
getPosition: (d) => [d.lon, d.lat],
|
||||
getRadius: 8,
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: isDark ? [0, 0, 0, 80] : [0, 0, 0, 40],
|
||||
pickable: false,
|
||||
}),
|
||||
[visibleListings, isDark]
|
||||
[listings, isDark]
|
||||
);
|
||||
|
||||
const pinLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer<ActualListing>({
|
||||
id: 'actual-listing-pin',
|
||||
data: visibleListings,
|
||||
data: listings,
|
||||
getPosition: (d) => [d.lon, d.lat],
|
||||
getRadius: 7,
|
||||
radiusUnits: 'pixels',
|
||||
|
|
@ -148,12 +91,12 @@ export function useListingLayers({
|
|||
onHover: stableHover,
|
||||
onClick: stableClick,
|
||||
}),
|
||||
[visibleListings, stableHover, stableClick]
|
||||
[listings, 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);
|
||||
const labeled = listings.filter((l) => l.asking_price && l.asking_price > 0);
|
||||
return new TextLayer<ActualListing>({
|
||||
id: 'actual-listing-price',
|
||||
data: labeled,
|
||||
|
|
@ -174,11 +117,11 @@ export function useListingLayers({
|
|||
sizeMaxPixels: 14,
|
||||
pickable: false,
|
||||
});
|
||||
}, [visibleListings, zoom, isDark]);
|
||||
}, [listings, zoom, isDark]);
|
||||
|
||||
const detailLabelLayer = useMemo(() => {
|
||||
if (zoom < ADDRESS_LABEL_MIN_ZOOM) return null;
|
||||
const labeled = visibleListings.filter((l) => l.address || l.bedrooms != null);
|
||||
const labeled = listings.filter((l) => l.address || l.bedrooms != null);
|
||||
return new TextLayer<ActualListing>({
|
||||
id: 'actual-listing-detail',
|
||||
data: labeled,
|
||||
|
|
@ -205,7 +148,7 @@ export function useListingLayers({
|
|||
sizeMaxPixels: 12,
|
||||
pickable: false,
|
||||
});
|
||||
}, [visibleListings, zoom, isDark]);
|
||||
}, [listings, zoom, isDark]);
|
||||
|
||||
const listingLayers = useMemo(() => {
|
||||
const layers: Layer[] = [pinShadowLayer, pinLayer];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue