This commit is contained in:
Andras Schmelczer 2026-05-06 22:40:46 +01:00
parent 28323f145e
commit 94f9c0d594
76 changed files with 3238 additions and 1230 deletions

View file

@ -1,17 +1,19 @@
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { cellToLatLng, cellToParent, latLngToCell } from 'h3-js';
import { cellToParent, latLngToCell } from 'h3-js';
import { trackEvent } from '../lib/analytics';
import type {
FeatureMeta,
FeatureFilters,
HexagonData,
Property,
PostcodeGeometry,
HexagonPropertiesResponse,
HexagonStatsResponse,
} from '../types';
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api';
const CURRENT_LOCATION_HEX_RESOLUTION = 12;
import { findOverlappingMatchingHexagon } from '../lib/h3-selection';
import { SMALLEST_VISIBLE_HEXAGON_RESOLUTION } from '../lib/consts';
import type { TravelTimeEntry } from './useTravelTime';
interface SelectedHexagon {
id: string;
@ -35,8 +37,11 @@ interface PostcodeLookupResponse {
interface UseHexagonSelectionOptions {
filters: FeatureFilters;
features: FeatureMeta[];
hexagonData: HexagonData[];
resolution: number;
usePostcodeView: boolean;
travelTimeEntries: TravelTimeEntry[];
shareCode?: string;
/** First transit destination — used to pick the best central_postcode for journey display. */
journeyDest?: JourneyDest | null;
}
@ -44,8 +49,11 @@ interface UseHexagonSelectionOptions {
export function useHexagonSelection({
filters,
features,
hexagonData,
resolution,
usePostcodeView,
travelTimeEntries,
shareCode,
journeyDest,
}: UseHexagonSelectionOptions) {
const [selectedHexagon, setSelectedHexagon] = useState<SelectedHexagon | null>(null);
@ -61,6 +69,40 @@ export function useHexagonSelection({
const [selectedPostcodeGeometry, setSelectedPostcodeGeometry] = useState<PostcodeGeometry | null>(
null
);
const areaRequestIdRef = useRef(0);
const propertiesRequestIdRef = useRef(0);
const invalidateAreaRequests = useCallback(() => {
areaRequestIdRef.current += 1;
return areaRequestIdRef.current;
}, []);
const invalidatePropertyRequests = useCallback(() => {
propertiesRequestIdRef.current += 1;
return propertiesRequestIdRef.current;
}, []);
const isCurrentAreaRequest = useCallback((requestId: number) => {
return areaRequestIdRef.current === requestId;
}, []);
const isCurrentPropertyRequest = useCallback((requestId: number) => {
return propertiesRequestIdRef.current === requestId;
}, []);
const travelParam = useMemo(() => {
const segments: string[] = [];
for (const entry of travelTimeEntries) {
if (!entry.slug) continue;
let segment = `${entry.mode}:${entry.slug}`;
if (entry.useBest) segment += ':best';
if (entry.timeRange) {
segment += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
}
segments.push(segment);
}
return segments.join('|');
}, [travelTimeEntries]);
const fetchHexagonStats = useCallback(
async (
@ -76,6 +118,8 @@ export function useHexagonSelection({
});
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);
if (fields) {
params.set('fields', fields.join(';;'));
}
@ -87,7 +131,7 @@ export function useHexagonSelection({
assertOk(response, 'hexagon-stats');
return (await response.json()) as HexagonStatsResponse;
},
[filters, features, journeyDest]
[filters, features, journeyDest, shareCode, travelParam]
);
const fetchPostcodeStats = useCallback(
@ -95,14 +139,20 @@ export function useHexagonSelection({
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 response = await fetch(apiUrl('postcode-stats', params), authHeaders({ signal }));
assertOk(response, 'postcode-stats');
return (await response.json()) as HexagonStatsResponse;
},
[filters, features]
[filters, features, shareCode, travelParam]
);
const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]);
const selectionQueryKey = useMemo(
() => [filterStr, travelParam, shareCode ?? ''].join('|'),
[filterStr, shareCode, travelParam]
);
const fetchUnfilteredAreaCount = useCallback(
async (selection: SelectedHexagon, signal?: AbortSignal) => {
@ -145,6 +195,7 @@ export function useHexagonSelection({
const fetchHexagonProperties = useCallback(
async (h3: string, res: number, offset = 0) => {
const requestId = invalidatePropertyRequests();
setLoadingProperties(true);
try {
const params = new URLSearchParams({
@ -156,10 +207,13 @@ export function useHexagonSelection({
const filterStr = buildFilterString(filters, features);
if (filterStr) params.append('filters', filterStr);
if (travelParam) params.set('travel', travelParam);
if (shareCode) params.set('share', shareCode);
const response = await fetch(apiUrl('hexagon-properties', params), authHeaders());
assertOk(response, 'hexagon-properties');
const data: HexagonPropertiesResponse = await response.json();
if (!isCurrentPropertyRequest(requestId)) return;
if (offset === 0) {
setProperties(data.properties);
@ -171,14 +225,22 @@ export function useHexagonSelection({
} catch (err) {
logNonAbortError('Failed to fetch properties', err);
} finally {
setLoadingProperties(false);
if (isCurrentPropertyRequest(requestId)) setLoadingProperties(false);
}
},
[filters, features]
[
filters,
features,
invalidatePropertyRequests,
isCurrentPropertyRequest,
shareCode,
travelParam,
]
);
const fetchPostcodeProperties = useCallback(
async (postcode: string, offset = 0, focusAddress?: string) => {
const requestId = invalidatePropertyRequests();
setLoadingProperties(true);
try {
const params = new URLSearchParams({
@ -192,10 +254,13 @@ export function useHexagonSelection({
const filterStr = buildFilterString(filters, features);
if (filterStr) params.append('filters', filterStr);
if (travelParam) params.set('travel', travelParam);
if (shareCode) params.set('share', shareCode);
const response = await fetch(apiUrl('postcode-properties', params), authHeaders());
assertOk(response, 'postcode-properties');
const data: HexagonPropertiesResponse = await response.json();
if (!isCurrentPropertyRequest(requestId)) return;
if (offset === 0) {
setProperties(data.properties);
@ -207,15 +272,24 @@ export function useHexagonSelection({
} catch (err) {
logNonAbortError('Failed to fetch postcode properties', err);
} finally {
setLoadingProperties(false);
if (isCurrentPropertyRequest(requestId)) setLoadingProperties(false);
}
},
[filters, features]
[
filters,
features,
invalidatePropertyRequests,
isCurrentPropertyRequest,
shareCode,
travelParam,
]
);
const handleHexagonClick = useCallback(
(id: string, isPostcode = false, geometry?: PostcodeGeometry) => {
if (selectedHexagon?.id === id) {
invalidateAreaRequests();
invalidatePropertyRequests();
setSelectedHexagon(null);
setProperties([]);
setAreaStats(null);
@ -224,6 +298,8 @@ export function useHexagonSelection({
} else {
const type: SelectedHexagon['type'] = isPostcode ? 'postcode' : 'hexagon';
const selection = { id, type, resolution };
const requestId = invalidateAreaRequests();
invalidatePropertyRequests();
trackEvent('Hexagon Click', { type });
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(isPostcode && geometry ? geometry : null);
@ -237,24 +313,39 @@ export function useHexagonSelection({
setLoadingAreaStats(true);
fetchPostcodeStats(id)
.then((stats) => {
if (!isCurrentAreaRequest(requestId)) return;
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
})
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
.finally(() => setLoadingAreaStats(false));
.finally(() => {
if (isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
});
} else {
setLoadingAreaStats(true);
fetchHexagonStats(id, resolution)
.then((stats) => {
if (!isCurrentAreaRequest(requestId)) return;
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
})
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
.finally(() => setLoadingAreaStats(false));
.finally(() => {
if (isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
});
}
}
},
[selectedHexagon, resolution, fetchHexagonStats, fetchPostcodeStats, refreshUnfilteredAreaCount]
[
selectedHexagon,
resolution,
fetchHexagonStats,
fetchPostcodeStats,
invalidateAreaRequests,
invalidatePropertyRequests,
isCurrentAreaRequest,
refreshUnfilteredAreaCount,
]
);
const handleHexagonHover = useCallback((h3: string | null) => {
@ -301,12 +392,14 @@ export function useHexagonSelection({
}, [selectedHexagon, propertiesOffset, fetchHexagonProperties, fetchPostcodeProperties]);
const handleCloseSelection = useCallback(() => {
invalidateAreaRequests();
invalidatePropertyRequests();
setSelectedHexagon(null);
setProperties([]);
setAreaStats(null);
setUnfilteredAreaCount(null);
setSelectedPostcodeGeometry(null);
}, []);
}, [invalidateAreaRequests, invalidatePropertyRequests]);
// Keep the selected area aligned with the active map view as zoom changes.
useEffect(() => {
@ -324,6 +417,18 @@ export function useHexagonSelection({
selection.resolution !== resolution);
if (!shouldSync) return;
const zoomingIntoHexagon =
!usePostcodeView &&
selection.type === 'hexagon' &&
!selection.lockedResolution &&
selection.resolution < resolution;
const overlappingHexagon = zoomingIntoHexagon
? findOverlappingMatchingHexagon(selection.id, hexagonData, resolution)
: null;
if (zoomingIntoHexagon && !overlappingHexagon) return;
const requestId = invalidateAreaRequests();
invalidatePropertyRequests();
let cancelled = false;
const controller = new AbortController();
@ -361,14 +466,15 @@ export function useHexagonSelection({
const nextId =
resolution < selection.resolution
? cellToParent(selection.id, resolution)
: latLngToCell(...cellToLatLng(selection.id), resolution);
: overlappingHexagon?.h3;
if (!nextId) return;
nextSelection = { id: nextId, type: 'hexagon', resolution };
nextStats = await fetchHexagonStats(nextId, resolution, controller.signal);
} else {
return;
}
if (cancelled || !nextSelection || !nextStats) return;
if (cancelled || !isCurrentAreaRequest(requestId) || !nextSelection || !nextStats) return;
setSelectedHexagon(nextSelection);
setSelectedPostcodeGeometry(nextGeometry);
setAreaStats(nextStats);
@ -387,7 +493,7 @@ export function useHexagonSelection({
if (!cancelled) logNonAbortError('Failed to sync selected area with map view', error);
})
.finally(() => {
if (!cancelled) setLoadingAreaStats(false);
if (!cancelled && isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
});
return () => {
@ -396,6 +502,7 @@ export function useHexagonSelection({
};
}, [
selectedHexagon,
hexagonData,
resolution,
usePostcodeView,
areaStats?.central_postcode,
@ -404,16 +511,19 @@ export function useHexagonSelection({
fetchPostcodeLookup,
fetchHexagonProperties,
fetchPostcodeProperties,
invalidateAreaRequests,
invalidatePropertyRequests,
isCurrentAreaRequest,
refreshUnfilteredAreaCount,
rightPaneTab,
]);
// Re-fetch stats when filters change while a hexagon is selected
const prevFilterStr = useRef(filterStr);
// Re-fetch stats when filters or travel constraints change while an area is selected
const prevSelectionQueryKey = useRef(selectionQueryKey);
useEffect(() => {
if (prevFilterStr.current === filterStr) return;
prevFilterStr.current = filterStr;
if (prevSelectionQueryKey.current === selectionQueryKey) return;
prevSelectionQueryKey.current = selectionQueryKey;
if (!selectedHexagon) return;
@ -424,6 +534,7 @@ export function useHexagonSelection({
setLoadingAreaStats(true);
let cancelled = false;
const requestId = invalidateAreaRequests();
const fetchStats =
selectedHexagon.type === 'postcode'
@ -432,7 +543,7 @@ export function useHexagonSelection({
fetchStats
.then((stats) => {
if (cancelled) return;
if (cancelled || !isCurrentAreaRequest(requestId)) return;
setAreaStats(stats);
refreshUnfilteredAreaCount(selectedHexagon, stats.count);
// Re-fetch properties if the properties tab is active and the filtered area still has matches.
@ -445,24 +556,26 @@ export function useHexagonSelection({
}
})
.catch((error) => {
if (cancelled) return;
if (cancelled || !isCurrentAreaRequest(requestId)) return;
logNonAbortError('Failed to refresh stats', error);
})
.finally(() => {
if (!cancelled) setLoadingAreaStats(false);
if (!cancelled && isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
});
return () => {
cancelled = true;
};
}, [
filterStr,
selectionQueryKey,
selectedHexagon,
fetchHexagonStats,
fetchPostcodeStats,
rightPaneTab,
fetchHexagonProperties,
fetchPostcodeProperties,
invalidateAreaRequests,
isCurrentAreaRequest,
refreshUnfilteredAreaCount,
]);
@ -475,6 +588,8 @@ export function useHexagonSelection({
openProperties = false,
focusAddress?: string
) => {
const requestId = invalidateAreaRequests();
invalidatePropertyRequests();
trackEvent(openProperties ? 'Address Search' : 'Postcode Search');
setProperties([]);
setPropertiesTotal(0);
@ -486,6 +601,7 @@ export function useHexagonSelection({
// First try the postcode; if it has no properties, fall back to hexagons
fetchPostcodeStats(postcode)
.then(async (stats) => {
if (!isCurrentAreaRequest(requestId)) return;
if (stats.count > 0) {
const selection = { id: postcode, type: 'postcode' as const, resolution };
setSelectedHexagon(selection);
@ -515,6 +631,7 @@ export function useHexagonSelection({
for (const res of resolutions) {
const h3 = latLngToCell(lat, lng, res);
const hexStats = await fetchHexagonStats(h3, res);
if (!isCurrentAreaRequest(requestId)) return;
if (hexStats.count > 1) {
const selection = { id: h3, type: 'hexagon' as const, resolution: res };
setSelectedHexagon(selection);
@ -529,6 +646,7 @@ export function useHexagonSelection({
// Even the coarsest hexagon has ≤1 property — show whatever the finest has
const h3 = latLngToCell(lat, lng, 9);
const fallbackStats = await fetchHexagonStats(h3, 9);
if (!isCurrentAreaRequest(requestId)) return;
const selection = { id: h3, type: 'hexagon' as const, resolution: 9 };
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(null);
@ -537,24 +655,31 @@ export function useHexagonSelection({
setRightPaneTab('area');
})
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
.finally(() => setLoadingAreaStats(false));
.finally(() => {
if (isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
});
},
[
resolution,
fetchPostcodeStats,
fetchHexagonStats,
fetchPostcodeProperties,
invalidateAreaRequests,
invalidatePropertyRequests,
isCurrentAreaRequest,
refreshUnfilteredAreaCount,
]
);
const handleCurrentLocationSearch = useCallback(
(lat: number, lng: number) => {
const h3 = latLngToCell(lat, lng, CURRENT_LOCATION_HEX_RESOLUTION);
const requestId = invalidateAreaRequests();
invalidatePropertyRequests();
const h3 = latLngToCell(lat, lng, SMALLEST_VISIBLE_HEXAGON_RESOLUTION);
const selection = {
id: h3,
type: 'hexagon' as const,
resolution: CURRENT_LOCATION_HEX_RESOLUTION,
resolution: SMALLEST_VISIBLE_HEXAGON_RESOLUTION,
lockedResolution: true,
};
@ -568,15 +693,24 @@ export function useHexagonSelection({
setRightPaneTab('area');
setLoadingAreaStats(true);
fetchHexagonStats(h3, CURRENT_LOCATION_HEX_RESOLUTION)
fetchHexagonStats(h3, SMALLEST_VISIBLE_HEXAGON_RESOLUTION)
.then((stats) => {
if (!isCurrentAreaRequest(requestId)) return;
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
})
.catch((error) => logNonAbortError('Failed to fetch current location hex stats', error))
.finally(() => setLoadingAreaStats(false));
.finally(() => {
if (isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
});
},
[fetchHexagonStats, refreshUnfilteredAreaCount]
[
fetchHexagonStats,
invalidateAreaRequests,
invalidatePropertyRequests,
isCurrentAreaRequest,
refreshUnfilteredAreaCount,
]
);
return {