This commit is contained in:
Andras Schmelczer 2026-05-28 21:48:35 +01:00
parent 39ef5c6646
commit c995f12f8b
78 changed files with 4830 additions and 1619 deletions

View file

@ -46,6 +46,7 @@ interface UseDeckLayersProps {
currentLocation?: { lat: number; lng: number } | null;
bounds?: Bounds | null;
travelTimeEntries?: TravelTimeEntry[];
mapDataBeforeId: string;
}
/** Normalize a distribution count array to [0..1] ratios, padded to 10 values. */
@ -88,6 +89,7 @@ export function useDeckLayers({
currentLocation,
bounds: viewportBounds,
travelTimeEntries = [],
mapDataBeforeId,
}: UseDeckLayersProps) {
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
const [hoveredPostcode, setHoveredPostcode] = useState<string | null>(null);
@ -419,10 +421,10 @@ export function useDeckLayers({
highPrecision: true,
onClick: handleHexagonClick,
onHover: handleHexagonHover,
beforeId: 'landuse_park',
beforeId: mapDataBeforeId,
...pieProps,
});
}, [data, colorTrigger, handleHexagonClick, handleHexagonHover]);
}, [data, colorTrigger, handleHexagonClick, handleHexagonHover, mapDataBeforeId]);
const postcodeLayer = useMemo(() => {
const isEnum = enumCountRef.current > 0;
@ -578,9 +580,15 @@ export function useDeckLayers({
onClick: handlePostcodeClick,
onHover: handlePostcodeHoverCallback,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: 'landuse_park',
beforeId: mapDataBeforeId,
});
}, [postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]);
}, [
postcodeData,
postcodeColorTrigger,
handlePostcodeClick,
handlePostcodeHoverCallback,
mapDataBeforeId,
]);
const labeledPostcodeData = useMemo(
() => postcodeData.filter((feature) => feature.properties.count > 0),

View file

@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useHexagonSelection } from './useHexagonSelection';
import type { FeatureMeta, HexagonStatsResponse, PostcodeGeometry } from '../types';
import type { TravelTimeEntry } from './useTravelTime';
vi.mock('../lib/pocketbase', () => ({
default: { authStore: { isValid: false, token: '' } },
@ -41,9 +42,24 @@ function jsonResponse(body: unknown): Response {
});
}
async function flushPromises() {
await Promise.resolve();
await Promise.resolve();
}
describe('useHexagonSelection', () => {
const requests: string[] = [];
const features: FeatureMeta[] = [{ name: 'Price', type: 'numeric', min: 0, max: 100 }];
const features: FeatureMeta[] = [
{ name: 'Price', type: 'numeric', min: 0, max: 100 },
{ name: 'Last known price', type: 'numeric', min: 0, max: 1_000_000 },
{ name: 'Estimated current price', type: 'numeric', min: 0, max: 1_000_000 },
{ name: 'Price per sqm', type: 'numeric', min: 0, max: 20_000 },
{ name: 'Est. price per sqm', type: 'numeric', min: 0, max: 20_000 },
{ name: 'Total floor area (sqm)', type: 'numeric', min: 0, max: 500 },
{ name: 'Number of bedrooms & living rooms', type: 'numeric', min: 0, max: 12 },
{ name: 'Construction year', type: 'numeric', min: 0, max: 2026 },
{ name: 'Date of last transaction', type: 'numeric', min: 0, max: 2026 },
];
beforeEach(() => {
requests.length = 0;
@ -64,6 +80,18 @@ describe('useHexagonSelection', () => {
return Promise.resolve(jsonResponse(stats(12)));
}
if (url.pathname === '/api/postcode-properties') {
return Promise.resolve(
jsonResponse({ properties: [], total: 0, offset: 0, truncated: false })
);
}
if (url.pathname === '/api/hexagon-properties') {
return Promise.resolve(
jsonResponse({ properties: [], total: 0, offset: 0, truncated: false })
);
}
return Promise.resolve(new Response(null, { status: 404 }));
})
);
@ -201,4 +229,203 @@ describe('useHexagonSelection', () => {
expect(requests.some((url) => url.startsWith('/api/postcode/'))).toBe(false);
expect(requests.some((url) => url.startsWith('/api/hexagon-stats'))).toBe(false);
});
it('passes area stat field projections to stats requests', async () => {
const { result } = renderHook(() =>
useHexagonSelection({
filters: {},
features,
hexagonData: [],
resolution: 9,
usePostcodeView: false,
travelTimeEntries: [],
areaStatsFields: ['Price'],
})
);
act(() => {
result.current.handleHexagonClick('89195da49abffff');
});
await waitFor(() => {
expect(result.current.areaStats?.count).toBe(12);
});
const statsRequest = requests.find((url) => url.startsWith('/api/hexagon-stats'));
expect(statsRequest).toBeDefined();
expect(new URL(statsRequest!, 'http://localhost').searchParams.get('fields')).toBe('Price');
});
it('keeps existing area stats visible while area field projections refetch', async () => {
const pendingStatsRequests: Array<{ resolve: (response: Response) => void }> = [];
vi.stubGlobal(
'fetch',
vi.fn((input: string | URL | Request) => {
const url = new URL(String(input), 'http://localhost');
requests.push(`${url.pathname}${url.search}`);
if (url.pathname === '/api/hexagon-stats') {
return new Promise<Response>((resolve) => {
pendingStatsRequests.push({ resolve });
});
}
return Promise.resolve(new Response(null, { status: 404 }));
})
);
const { result, rerender } = renderHook(
({ areaStatsFields }: { areaStatsFields: string[] }) =>
useHexagonSelection({
filters: {},
features,
hexagonData: [],
resolution: 9,
usePostcodeView: false,
travelTimeEntries: [],
areaStatsFields,
}),
{ initialProps: { areaStatsFields: [] as string[] } }
);
act(() => {
result.current.handleHexagonClick('89195da49abffff');
});
await waitFor(() => {
expect(pendingStatsRequests).toHaveLength(1);
});
await act(async () => {
pendingStatsRequests[0].resolve(jsonResponse(stats(12)));
await flushPromises();
});
await waitFor(() => {
expect(result.current.areaStats?.count).toBe(12);
expect(result.current.loadingAreaStats).toBe(false);
});
act(() => {
rerender({ areaStatsFields: ['Price'] });
});
await waitFor(() => {
expect(pendingStatsRequests).toHaveLength(2);
});
expect(result.current.loadingAreaStats).toBe(true);
expect(result.current.areaStats?.count).toBe(12);
const refetchRequest = requests.filter((url) => url.startsWith('/api/hexagon-stats'))[1];
expect(new URL(refetchRequest, 'http://localhost').searchParams.get('fields')).toBe('Price');
await act(async () => {
pendingStatsRequests[1].resolve(jsonResponse(stats(12)));
await flushPromises();
});
await waitFor(() => {
expect(result.current.loadingAreaStats).toBe(false);
expect(result.current.areaStats?.count).toBe(12);
});
});
it('passes property card field projections to property requests', async () => {
const { result } = renderHook(() =>
useHexagonSelection({
filters: {},
features,
hexagonData: [],
resolution: 9,
usePostcodeView: true,
travelTimeEntries: [],
})
);
act(() => {
result.current.handleLocationSearch('SW1A 1AA', postcodeGeometry, 51.505, -0.115);
});
await waitFor(() => {
expect(result.current.areaStats?.count).toBe(4);
});
act(() => {
result.current.handlePropertiesTabClick();
});
await waitFor(() => {
expect(requests.some((url) => url.startsWith('/api/postcode-properties'))).toBe(true);
});
const propertiesRequest = requests.find((url) => url.startsWith('/api/postcode-properties'));
const fieldsParam = new URL(propertiesRequest!, 'http://localhost').searchParams.get('fields');
expect(fieldsParam).toContain('Last known price');
expect(fieldsParam).toContain('Date of last transaction');
expect(fieldsParam).not.toContain('Distance to nearest amenity');
});
it('refetches property requests when stats basis switches to all properties', async () => {
const propertyFilters = { Price: [0, 50] as [number, number] };
const travelTimeEntries: TravelTimeEntry[] = [
{
mode: 'transit',
slug: 'kings-cross',
label: 'Kings Cross',
timeRange: [0, 30],
useBest: false,
},
];
const { result } = renderHook(() =>
useHexagonSelection({
filters: propertyFilters,
features,
hexagonData: [],
resolution: 9,
usePostcodeView: true,
travelTimeEntries,
})
);
act(() => {
result.current.handleLocationSearch('SW1A 1AA', postcodeGeometry, 51.505, -0.115);
});
await waitFor(() => {
expect(result.current.areaStats?.count).toBe(0);
});
act(() => {
result.current.handlePropertiesTabClick();
});
await waitFor(() => {
expect(requests.filter((url) => url.startsWith('/api/postcode-properties')).length).toBe(1);
});
const filteredPropertiesRequest = requests.find((url) =>
url.startsWith('/api/postcode-properties')
);
const filteredParams = new URL(filteredPropertiesRequest!, 'http://localhost').searchParams;
expect(filteredParams.has('filters')).toBe(true);
expect(filteredParams.has('travel')).toBe(true);
act(() => {
result.current.setAreaStatsUseFilters(false);
});
await waitFor(() => {
expect(result.current.areaStats?.count).toBe(4);
});
await waitFor(() => {
expect(requests.filter((url) => url.startsWith('/api/postcode-properties')).length).toBe(2);
});
const propertyRequests = requests.filter((url) => url.startsWith('/api/postcode-properties'));
const allPropertiesRequest = propertyRequests[propertyRequests.length - 1];
const allPropertiesParams = new URL(allPropertiesRequest, 'http://localhost').searchParams;
expect(allPropertiesParams.has('filters')).toBe(false);
expect(allPropertiesParams.has('travel')).toBe(false);
});
});

View file

@ -42,11 +42,23 @@ interface UseHexagonSelectionOptions {
resolution: number;
usePostcodeView: boolean;
travelTimeEntries: TravelTimeEntry[];
areaStatsFields?: string[];
shareCode?: string;
/** First transit destination — used to pick the best central_postcode for journey display. */
journeyDest?: JourneyDest | null;
}
const PROPERTY_PANE_FIELDS = [
'Last known price',
'Estimated current price',
'Price per sqm',
'Est. price per sqm',
'Total floor area (sqm)',
'Number of bedrooms & living rooms',
'Construction year',
'Date of last transaction',
];
export function useHexagonSelection({
filters,
features,
@ -54,6 +66,7 @@ export function useHexagonSelection({
resolution,
usePostcodeView,
travelTimeEntries,
areaStatsFields,
shareCode,
journeyDest,
}: UseHexagonSelectionOptions) {
@ -93,6 +106,11 @@ export function useHexagonSelection({
}, []);
const travelParam = useMemo(() => buildTravelParam(travelTimeEntries), [travelTimeEntries]);
const areaStatsFieldsKey = useMemo(() => areaStatsFields?.join(';;') ?? '', [areaStatsFields]);
const propertyPaneFieldsParam = useMemo(() => {
const availableFields = new Set(features.map((feature) => feature.name));
return PROPERTY_PANE_FIELDS.filter((field) => availableFields.has(field)).join(';;');
}, [features]);
const fetchHexagonStats = useCallback(
async (
@ -110,8 +128,9 @@ export function useHexagonSelection({
if (filterStr) params.append('filters', filterStr);
if (includeFilters && travelParam) params.set('travel', travelParam);
if (shareCode) params.set('share', shareCode);
if (fields) {
params.set('fields', fields.join(';;'));
const requestedFields = fields ?? areaStatsFields;
if (requestedFields) {
params.set('fields', requestedFields.join(';;'));
}
if (journeyDest) {
params.set('journey_mode', journeyDest.mode);
@ -121,27 +140,34 @@ export function useHexagonSelection({
assertOk(response, 'hexagon-stats');
return (await response.json()) as HexagonStatsResponse;
},
[filters, features, journeyDest, shareCode, travelParam]
[areaStatsFields, filters, features, journeyDest, shareCode, travelParam]
);
const fetchPostcodeStats = useCallback(
async (postcode: string, signal?: AbortSignal, includeFilters = true) => {
async (
postcode: string,
signal?: AbortSignal,
includeFilters = true,
fields?: string[]
) => {
const params = new URLSearchParams({ postcode });
const filterStr = includeFilters ? buildFilterString(filters, features) : '';
if (filterStr) params.append('filters', filterStr);
if (includeFilters && travelParam) params.set('travel', travelParam);
if (shareCode) params.set('share', shareCode);
const requestedFields = fields ?? areaStatsFields;
if (requestedFields) params.set('fields', requestedFields.join(';;'));
const response = await fetch(apiUrl('postcode-stats', params), authHeaders({ signal }));
assertOk(response, 'postcode-stats');
return (await response.json()) as HexagonStatsResponse;
},
[filters, features, shareCode, travelParam]
[areaStatsFields, filters, features, shareCode, travelParam]
);
const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]);
const hasStatsFilters = filterStr.length > 0 || travelParam.length > 0;
const journeyKey = journeyDest ? `${journeyDest.mode}:${journeyDest.slug}` : '';
const areaStatsQueryKey = useMemo(
const areaStatsDataKey = useMemo(
() =>
[
areaStatsUseFilters ? 'filtered' : 'all',
@ -152,6 +178,10 @@ export function useHexagonSelection({
].join('|'),
[areaStatsUseFilters, filterStr, journeyKey, shareCode, travelParam]
);
const areaStatsQueryKey = useMemo(
() => [areaStatsDataKey, areaStatsFieldsKey].join('|'),
[areaStatsDataKey, areaStatsFieldsKey]
);
const fetchUnfilteredAreaCount = useCallback(
async (selection: SelectedHexagon, requestId: number, signal?: AbortSignal) => {
@ -162,8 +192,8 @@ export function useHexagonSelection({
const stats =
selection.type === 'postcode'
? await fetchPostcodeStats(selection.id, signal, false)
: await fetchHexagonStats(selection.id, selection.resolution, signal, undefined, false);
? await fetchPostcodeStats(selection.id, signal, false, [])
: await fetchHexagonStats(selection.id, selection.resolution, signal, [], false);
if (isCurrentAreaRequest(requestId)) setUnfilteredAreaCount(stats.count);
},
[fetchHexagonStats, fetchPostcodeStats, hasStatsFilters, isCurrentAreaRequest]
@ -209,9 +239,10 @@ export function useHexagonSelection({
offset: offset.toString(),
});
const filterStr = buildFilterString(filters, features);
const filterStr = areaStatsUseFilters ? buildFilterString(filters, features) : '';
if (filterStr) params.append('filters', filterStr);
if (travelParam) params.set('travel', travelParam);
if (areaStatsUseFilters && travelParam) params.set('travel', travelParam);
params.set('fields', propertyPaneFieldsParam);
if (shareCode) params.set('share', shareCode);
const response = await fetch(apiUrl('hexagon-properties', params), authHeaders());
@ -235,8 +266,10 @@ export function useHexagonSelection({
[
filters,
features,
areaStatsUseFilters,
invalidatePropertyRequests,
isCurrentPropertyRequest,
propertyPaneFieldsParam,
shareCode,
travelParam,
]
@ -255,9 +288,10 @@ export function useHexagonSelection({
params.set('focus_address', focusAddress);
}
const filterStr = buildFilterString(filters, features);
const filterStr = areaStatsUseFilters ? buildFilterString(filters, features) : '';
if (filterStr) params.append('filters', filterStr);
if (travelParam) params.set('travel', travelParam);
if (areaStatsUseFilters && travelParam) params.set('travel', travelParam);
params.set('fields', propertyPaneFieldsParam);
if (shareCode) params.set('share', shareCode);
const response = await fetch(apiUrl('postcode-properties', params), authHeaders());
@ -281,8 +315,10 @@ export function useHexagonSelection({
[
filters,
features,
areaStatsUseFilters,
invalidatePropertyRequests,
isCurrentPropertyRequest,
propertyPaneFieldsParam,
shareCode,
travelParam,
]
@ -546,25 +582,34 @@ export function useHexagonSelection({
rightPaneTab,
]);
// Re-fetch stats when filters or travel constraints change while an area is selected
const prevAreaStatsQueryKey = useRef(areaStatsQueryKey);
// Re-fetch stats when the selected stats basis or requested field projection changes.
const prevAreaStatsQueryRef = useRef({
dataKey: areaStatsDataKey,
queryKey: areaStatsQueryKey,
});
useEffect(() => {
if (prevAreaStatsQueryKey.current === areaStatsQueryKey) return;
prevAreaStatsQueryKey.current = areaStatsQueryKey;
const previousQuery = prevAreaStatsQueryRef.current;
if (previousQuery.queryKey === areaStatsQueryKey) return;
prevAreaStatsQueryRef.current = {
dataKey: areaStatsDataKey,
queryKey: areaStatsQueryKey,
};
if (!selectedHexagon) return;
const fieldProjectionOnlyChanged = previousQuery.dataKey === areaStatsDataKey;
// Clear stale properties
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
invalidatePropertyRequests();
setAreaStats(null);
setUnfilteredAreaCount(null);
if (!fieldProjectionOnlyChanged) {
// Clear stale properties
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
invalidatePropertyRequests();
setAreaStats(null);
setUnfilteredAreaCount(null);
}
setLoadingAreaStats(true);
let cancelled = false;
const requestId = invalidateAreaRequests();
const fetchStats =
@ -580,11 +625,11 @@ export function useHexagonSelection({
fetchStats
.then((stats) => {
if (cancelled || !isCurrentAreaRequest(requestId)) return;
if (!isCurrentAreaRequest(requestId)) return;
setAreaStats(stats);
refreshUnfilteredAreaCount(selectedHexagon, stats.count, areaStatsUseFilters, requestId);
// Re-fetch properties if the properties tab is active and the filtered area still has matches.
if (areaStatsUseFilters && rightPaneTab === 'properties' && stats.count > 0) {
// Re-fetch properties if the properties tab is active and the selected basis has matches.
if (!fieldProjectionOnlyChanged && rightPaneTab === 'properties' && stats.count > 0) {
if (selectedHexagon.type === 'postcode') {
fetchPostcodeProperties(selectedHexagon.id, 0);
} else {
@ -593,17 +638,14 @@ export function useHexagonSelection({
}
})
.catch((error) => {
if (cancelled || !isCurrentAreaRequest(requestId)) return;
if (!isCurrentAreaRequest(requestId)) return;
logNonAbortError('Failed to refresh stats', error);
})
.finally(() => {
if (!cancelled && isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
if (isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
});
return () => {
cancelled = true;
};
}, [
areaStatsDataKey,
areaStatsQueryKey,
selectedHexagon,
fetchHexagonStats,

View file

@ -1,42 +1,211 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Layer, PickingInfo } from '@deck.gl/core';
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import { PathLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import Supercluster from 'supercluster';
import type { ActualListing } from '../types';
import { trackEvent } from '../lib/analytics';
const PRICE_LABEL_MIN_ZOOM = 14;
const ADDRESS_LABEL_MIN_ZOOM = 16;
const LISTING_CLUSTER_RADIUS = 18;
const LISTING_CLUSTER_MAX_ZOOM = 24;
const LISTING_CLUSTER_POPUP_LIMIT = 30;
const LISTING_SPIDERFY_LIMIT = 12;
const TILE_SIZE = 512;
export interface ListingPopupInfo {
interface SingleListingPopupInfo {
mode: 'single';
x: number;
y: number;
listing: ActualListing;
locked?: boolean;
}
interface ListingClusterPopupInfo {
mode: 'cluster';
x: number;
y: number;
count: number;
listings: ActualListing[];
locked?: boolean;
}
export type ListingPopupInfo = SingleListingPopupInfo | ListingClusterPopupInfo;
interface UseListingLayersProps {
listings: ActualListing[];
zoom: number;
isDark: boolean;
}
interface ListingClusterPoint {
lng: number;
lat: number;
count: number;
clusterId: number;
}
interface ExpandedListingMarker {
listing: ActualListing;
lng: number;
lat: number;
anchorLng: number;
anchorLat: number;
}
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}`;
}
function formatClusterCount(count: number): string {
if (count >= 1_000) return `${(count / 1_000).toFixed(count >= 10_000 ? 0 : 1)}k`;
return String(count);
}
function compareListingsForDisplay(left: ActualListing, right: ActualListing): number {
const dateCompare = (right.listing_date_iso ?? '').localeCompare(left.listing_date_iso ?? '');
if (dateCompare !== 0) return dateCompare;
return (right.asking_price ?? 0) - (left.asking_price ?? 0);
}
function getClusterListings(
index: Supercluster<ActualListing>,
clusterId: number,
limit: number
): ActualListing[] {
return index
.getLeaves(clusterId, limit, 0)
.map((feature) => feature.properties)
.sort(compareListingsForDisplay);
}
function offsetLngLat(
lng: number,
lat: number,
dxPixels: number,
dyPixels: number,
zoom: number
): [number, number] {
const worldSize = TILE_SIZE * Math.pow(2, zoom);
const lngPerPixel = 360 / worldSize;
const cosLat = Math.max(0.25, Math.cos((lat * Math.PI) / 180));
const latPerPixel = lngPerPixel / cosLat;
return [lng + dxPixels * lngPerPixel, lat - dyPixels * latPerPixel];
}
function spiderfyPosition(
lng: number,
lat: number,
index: number,
total: number,
zoom: number
): [number, number] {
if (total <= 1) return [lng, lat];
const radius = total <= 6 ? 24 : 32;
const angle = -Math.PI / 2 + (index / total) * Math.PI * 2;
return offsetLngLat(lng, lat, Math.cos(angle) * radius, Math.sin(angle) * radius, zoom);
}
export function useListingLayers({ listings, zoom, isDark }: UseListingLayersProps) {
const [popupInfo, setPopupInfo] = useState<ListingPopupInfo | null>(null);
const [selectedCluster, setSelectedCluster] = useState<ListingClusterPoint | null>(null);
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);
useEffect(() => {
setSelectedCluster(null);
setPopupInfo(null);
}, [listings]);
const clusterIndex = useMemo(() => {
if (listings.length === 0) return null;
const index = new Supercluster<ActualListing>({
radius: LISTING_CLUSTER_RADIUS,
maxZoom: LISTING_CLUSTER_MAX_ZOOM,
});
const features: Supercluster.PointFeature<ActualListing>[] = listings
.filter((listing) => Number.isFinite(listing.lat) && Number.isFinite(listing.lon))
.map((listing) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [listing.lon, listing.lat] },
properties: listing,
}));
index.load(features);
return index;
}, [listings]);
const clusterIndexRef = useRef(clusterIndex);
clusterIndexRef.current = clusterIndex;
const clusterZoom = Math.min(Math.floor(zoom), LISTING_CLUSTER_MAX_ZOOM);
const { visibleListings, clusters } = useMemo(() => {
if (!clusterIndex) {
return {
visibleListings: [] as ActualListing[],
clusters: [] as ListingClusterPoint[],
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const features = clusterIndex.getClusters([-180, -85, 180, 85], clusterZoom) as any[];
const individual: ActualListing[] = [];
const clusterPoints: ListingClusterPoint[] = [];
for (const feature of features) {
if (feature.properties.cluster) {
clusterPoints.push({
lng: feature.geometry.coordinates[0],
lat: feature.geometry.coordinates[1],
count: feature.properties.point_count,
clusterId: feature.properties.cluster_id,
});
} else {
individual.push(feature.properties as ActualListing);
}
}
return { visibleListings: individual, clusters: clusterPoints };
}, [clusterIndex, clusterZoom]);
const expandedListings = useMemo(() => {
if (!selectedCluster || !clusterIndex) return [];
const leaves = getClusterListings(
clusterIndex,
selectedCluster.clusterId,
LISTING_SPIDERFY_LIMIT
);
return leaves.map((listing, index) => {
const [lng, lat] = spiderfyPosition(
selectedCluster.lng,
selectedCluster.lat,
index,
leaves.length,
zoom
);
return {
listing,
lng,
lat,
anchorLng: selectedCluster.lng,
anchorLat: selectedCluster.lat,
};
});
}, [clusterIndex, selectedCluster, zoom]);
const clearUnlockedPopup = useCallback(() => {
setPopupInfo((current) => (current?.locked ? current : null));
}, []);
const handleHover = useCallback(
(info: PickingInfo<ActualListing>) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
setPopupInfo({ mode: 'single', x: info.x, y: info.y, listing: info.object });
} else {
clearUnlockedPopup();
}
},
[clearUnlockedPopup]
);
const handleClick = useCallback((info: PickingInfo<ActualListing>) => {
const url = info.object?.listing_url;
if (!url) return;
@ -58,25 +227,115 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
[]
);
const handleExpandedHover = useCallback(
(info: PickingInfo<ExpandedListingMarker>) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
setPopupInfo({ mode: 'single', x: info.x, y: info.y, listing: info.object.listing });
} else {
clearUnlockedPopup();
}
},
[clearUnlockedPopup]
);
const handleExpandedClick = useCallback((info: PickingInfo<ExpandedListingMarker>) => {
const url = info.object?.listing.listing_url;
if (!url) return;
trackEvent('Actual Listing Click', { url, source: 'cluster_expanded' });
window.open(url, '_blank', 'noopener,noreferrer');
}, []);
const handleExpandedHoverRef = useRef(handleExpandedHover);
handleExpandedHoverRef.current = handleExpandedHover;
const stableExpandedHover = useCallback(
(info: PickingInfo<ExpandedListingMarker>) => handleExpandedHoverRef.current(info),
[]
);
const handleExpandedClickRef = useRef(handleExpandedClick);
handleExpandedClickRef.current = handleExpandedClick;
const stableExpandedClick = useCallback(
(info: PickingInfo<ExpandedListingMarker>) => handleExpandedClickRef.current(info),
[]
);
const handleClusterHover = useCallback(
(info: PickingInfo<ListingClusterPoint>) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
const cluster = info.object;
setPopupInfo((current) =>
current?.locked
? current
: {
mode: 'cluster',
x: info.x,
y: info.y,
count: cluster.count,
listings: [],
}
);
} else {
clearUnlockedPopup();
}
},
[clearUnlockedPopup]
);
const handleClusterClick = useCallback((info: PickingInfo<ListingClusterPoint>) => {
if (!info.object || info.x === undefined || info.y === undefined) return;
const index = clusterIndexRef.current;
if (!index) return;
const cluster = info.object;
const clusterListings = getClusterListings(
index,
cluster.clusterId,
LISTING_CLUSTER_POPUP_LIMIT
);
setSelectedCluster(cluster);
setPopupInfo({
mode: 'cluster',
x: info.x,
y: info.y,
count: cluster.count,
listings: clusterListings,
locked: true,
});
trackEvent('Actual Listing Cluster Click', { count: cluster.count });
}, []);
const handleClusterHoverRef = useRef(handleClusterHover);
handleClusterHoverRef.current = handleClusterHover;
const stableClusterHover = useCallback(
(info: PickingInfo<ListingClusterPoint>) => handleClusterHoverRef.current(info),
[]
);
const handleClusterClickRef = useRef(handleClusterClick);
handleClusterClickRef.current = handleClusterClick;
const stableClusterClick = useCallback(
(info: PickingInfo<ListingClusterPoint>) => handleClusterClickRef.current(info),
[]
);
const pinShadowLayer = useMemo(
() =>
new ScatterplotLayer<ActualListing>({
id: 'actual-listing-shadow',
data: listings,
data: visibleListings,
getPosition: (d) => [d.lon, d.lat],
getRadius: 8,
radiusUnits: 'pixels',
getFillColor: isDark ? [0, 0, 0, 80] : [0, 0, 0, 40],
pickable: false,
}),
[listings, isDark]
[visibleListings, isDark]
);
const pinLayer = useMemo(
() =>
new ScatterplotLayer<ActualListing>({
id: 'actual-listing-pin',
data: listings,
data: visibleListings,
getPosition: (d) => [d.lon, d.lat],
getRadius: 7,
radiusUnits: 'pixels',
@ -91,12 +350,108 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
onHover: stableHover,
onClick: stableClick,
}),
[listings, stableHover, stableClick]
[visibleListings, stableHover, stableClick]
);
const clusterShadowLayer = useMemo(
() =>
new ScatterplotLayer<ListingClusterPoint>({
id: 'actual-listing-cluster-shadow',
data: clusters,
getPosition: (d) => [d.lng, d.lat],
getRadius: (d) => Math.min(32, 13 + Math.sqrt(d.count) * 1.8),
radiusUnits: 'pixels',
getFillColor: isDark ? [0, 0, 0, 90] : [0, 0, 0, 45],
pickable: false,
}),
[clusters, isDark]
);
const clusterLayer = useMemo(
() =>
new ScatterplotLayer<ListingClusterPoint>({
id: 'actual-listing-cluster',
data: clusters,
getPosition: (d) => [d.lng, d.lat],
getRadius: (d) => Math.min(30, 12 + Math.sqrt(d.count) * 1.8),
radiusUnits: 'pixels',
getFillColor: isDark ? [185, 28, 28, 230] : [220, 38, 38, 230],
getLineColor: [255, 255, 255, isDark ? 90 : 180],
getLineWidth: 2,
lineWidthUnits: 'pixels',
stroked: true,
pickable: true,
autoHighlight: true,
highlightColor: [29, 228, 195, 220],
onHover: stableClusterHover,
onClick: stableClusterClick,
}),
[clusters, isDark, stableClusterHover, stableClusterClick]
);
const clusterTextLayer = useMemo(
() =>
new TextLayer<ListingClusterPoint>({
id: 'actual-listing-cluster-text',
data: clusters,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => formatClusterCount(d.count),
getSize: 12,
getColor: [255, 255, 255, 255],
fontFamily: 'Inter, system-ui, sans-serif',
fontWeight: 800,
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
sizeUnits: 'pixels',
sizeMinPixels: 10,
sizeMaxPixels: 13,
pickable: false,
}),
[clusters]
);
const expandedConnectorLayer = useMemo(
() =>
new PathLayer<ExpandedListingMarker>({
id: 'actual-listing-expanded-lines',
data: expandedListings,
getPath: (d) => [
[d.anchorLng, d.anchorLat],
[d.lng, d.lat],
],
getColor: isDark ? [255, 255, 255, 80] : [80, 60, 50, 110],
getWidth: 1,
widthUnits: 'pixels',
pickable: false,
}),
[expandedListings, isDark]
);
const expandedPinLayer = useMemo(
() =>
new ScatterplotLayer<ExpandedListingMarker>({
id: 'actual-listing-expanded-pin',
data: expandedListings,
getPosition: (d) => [d.lng, d.lat],
getRadius: 6,
radiusUnits: 'pixels',
getFillColor: [231, 76, 60, 245],
getLineColor: [255, 255, 255, 255],
getLineWidth: 1.5,
lineWidthUnits: 'pixels',
stroked: true,
pickable: true,
autoHighlight: true,
highlightColor: [29, 228, 195, 220],
onHover: stableExpandedHover,
onClick: stableExpandedClick,
}),
[expandedListings, stableExpandedHover, stableExpandedClick]
);
const priceLabelLayer = useMemo(() => {
if (zoom < PRICE_LABEL_MIN_ZOOM) return null;
const labeled = listings.filter((l) => l.asking_price && l.asking_price > 0);
const labeled = visibleListings.filter((l) => l.asking_price && l.asking_price > 0);
return new TextLayer<ActualListing>({
id: 'actual-listing-price',
data: labeled,
@ -117,11 +472,11 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
sizeMaxPixels: 14,
pickable: false,
});
}, [listings, zoom, isDark]);
}, [visibleListings, zoom, isDark]);
const detailLabelLayer = useMemo(() => {
if (zoom < ADDRESS_LABEL_MIN_ZOOM) return null;
const labeled = listings.filter((l) => l.address || l.bedrooms != null);
const labeled = visibleListings.filter((l) => l.address || l.bedrooms != null);
return new TextLayer<ActualListing>({
id: 'actual-listing-detail',
data: labeled,
@ -148,16 +503,39 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
sizeMaxPixels: 12,
pickable: false,
});
}, [listings, zoom, isDark]);
}, [visibleListings, zoom, isDark]);
const listingLayers = useMemo(() => {
const layers: Layer[] = [pinShadowLayer, pinLayer];
const layers: Layer[] = [
clusterShadowLayer,
clusterLayer,
clusterTextLayer,
pinShadowLayer,
pinLayer,
];
if (expandedListings.length > 0) {
layers.push(expandedConnectorLayer, expandedPinLayer);
}
if (priceLabelLayer) layers.push(priceLabelLayer);
if (detailLabelLayer) layers.push(detailLabelLayer);
return layers;
}, [pinShadowLayer, pinLayer, priceLabelLayer, detailLabelLayer]);
}, [
clusterShadowLayer,
clusterLayer,
clusterTextLayer,
pinShadowLayer,
pinLayer,
expandedListings.length,
expandedConnectorLayer,
expandedPinLayer,
priceLabelLayer,
detailLabelLayer,
]);
const clearListingPopup = useCallback(() => setPopupInfo(null), []);
const clearListingPopup = useCallback(() => {
setPopupInfo(null);
setSelectedCluster(null);
}, []);
return { listingLayers, listingPopup: popupInfo, clearListingPopup };
}

View file

@ -20,6 +20,7 @@ function viewChange(bounds: Bounds): ViewChangeParams {
return {
resolution: 8,
bounds,
visibleBounds: bounds,
zoom: 10,
latitude: (bounds.south + bounds.north) / 2,
longitude: (bounds.west + bounds.east) / 2,

View file

@ -84,6 +84,7 @@ export function useMapData({
const [postcodeData, setPostcodeData] = useState<PostcodeFeature[]>([]);
const [resolution, setResolution] = useState<number>(8);
const [bounds, setBounds] = useState<Bounds | null>(null);
const [visibleBounds, setVisibleBounds] = useState<Bounds | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [zoom, setZoom] = useState<number>(10);
const [currentView, setCurrentView] = useState<{
@ -685,6 +686,7 @@ export function useMapData({
({
resolution: newRes,
bounds: newBounds,
visibleBounds: newVisibleBounds,
zoom: newZoom,
latitude,
longitude,
@ -697,6 +699,7 @@ export function useMapData({
setResolution(newRes);
setBounds(newBounds);
}
setVisibleBounds(newVisibleBounds);
setZoom(newZoom);
setCurrentView({ latitude, longitude, zoom: newZoom });
setCurrentVisibleView({
@ -729,6 +732,7 @@ export function useMapData({
postcodeData: effectivePostcodeData,
resolution,
bounds,
visibleBounds,
loading: isLoading,
zoom,
currentView,

View file

@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
import type { FeatureMeta, FeatureFilters } from '../types';
import { stateToParams } from '../lib/url-state';
import type { OverlayId } from '../lib/overlays';
import type { BasemapId } from '../lib/basemaps';
import type { TravelTimeEntry } from './useTravelTime';
const URL_DEBOUNCE_MS = 300;
@ -14,7 +15,8 @@ export function useUrlSync(
rightPaneTab: 'properties' | 'area',
travelTimeEntries?: TravelTimeEntry[],
share?: string,
selectedOverlays?: Set<OverlayId>
selectedOverlays?: Set<OverlayId>,
basemap?: BasemapId
) {
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -31,7 +33,8 @@ export function useUrlSync(
rightPaneTab,
travelTimeEntries,
share,
selectedOverlays
selectedOverlays,
basemap
);
const search = params.toString();
const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname;
@ -50,5 +53,6 @@ export function useUrlSync(
travelTimeEntries,
share,
selectedOverlays,
basemap,
]);
}