This commit is contained in:
Andras Schmelczer 2026-05-11 21:38:26 +01:00
parent 9248e26af2
commit f2a2651b8a
95 changed files with 3993 additions and 1471 deletions

View file

@ -11,7 +11,7 @@ import type {
HexagonStatsResponse,
} from '../types';
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api';
import { findOverlappingMatchingHexagon } from '../lib/h3-selection';
import { findOverlappingSelectableHexagon } from '../lib/h3-selection';
import { SMALLEST_VISIBLE_HEXAGON_RESOLUTION } from '../lib/consts';
import type { TravelTimeEntry } from './useTravelTime';
@ -66,6 +66,7 @@ export function useHexagonSelection({
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
const [rightPaneTab, setRightPaneTab] = useState<'properties' | 'area'>('area');
const [areaStatsUseFilters, setAreaStatsUseFilters] = useState(true);
const [selectedPostcodeGeometry, setSelectedPostcodeGeometry] = useState<PostcodeGeometry | null>(
null
);
@ -149,14 +150,23 @@ export function useHexagonSelection({
);
const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]);
const selectionQueryKey = useMemo(
() => [filterStr, travelParam, shareCode ?? ''].join('|'),
[filterStr, shareCode, travelParam]
const hasStatsFilters = filterStr.length > 0 || travelParam.length > 0;
const journeyKey = journeyDest ? `${journeyDest.mode}:${journeyDest.slug}` : '';
const areaStatsQueryKey = useMemo(
() =>
[
areaStatsUseFilters ? 'filtered' : 'all',
areaStatsUseFilters ? filterStr : '',
areaStatsUseFilters ? travelParam : '',
journeyKey,
shareCode ?? '',
].join('|'),
[areaStatsUseFilters, filterStr, journeyKey, shareCode, travelParam]
);
const fetchUnfilteredAreaCount = useCallback(
async (selection: SelectedHexagon, signal?: AbortSignal) => {
if (!filterStr) {
if (!hasStatsFilters) {
setUnfilteredAreaCount(null);
return;
}
@ -167,12 +177,17 @@ export function useHexagonSelection({
: await fetchHexagonStats(selection.id, selection.resolution, signal, undefined, false);
setUnfilteredAreaCount(stats.count);
},
[filterStr, fetchHexagonStats, fetchPostcodeStats]
[fetchHexagonStats, fetchPostcodeStats, hasStatsFilters]
);
const refreshUnfilteredAreaCount = useCallback(
(selection: SelectedHexagon, filteredCount: number, signal?: AbortSignal) => {
if (!filterStr || filteredCount > 0) {
(
selection: SelectedHexagon,
statsCount: number,
includeFilters: boolean,
signal?: AbortSignal
) => {
if (!includeFilters || !hasStatsFilters || statsCount > 0) {
setUnfilteredAreaCount(null);
return;
}
@ -181,7 +196,7 @@ export function useHexagonSelection({
logNonAbortError('Failed to fetch unfiltered area count', error)
);
},
[filterStr, fetchUnfilteredAreaCount]
[fetchUnfilteredAreaCount, hasStatsFilters]
);
const fetchPostcodeLookup = useCallback(async (postcode: string, signal?: AbortSignal) => {
@ -311,11 +326,11 @@ export function useHexagonSelection({
if (isPostcode) {
setLoadingAreaStats(true);
fetchPostcodeStats(id)
fetchPostcodeStats(id, undefined, areaStatsUseFilters)
.then((stats) => {
if (!isCurrentAreaRequest(requestId)) return;
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
})
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
.finally(() => {
@ -323,11 +338,11 @@ export function useHexagonSelection({
});
} else {
setLoadingAreaStats(true);
fetchHexagonStats(id, resolution)
fetchHexagonStats(id, resolution, undefined, undefined, areaStatsUseFilters)
.then((stats) => {
if (!isCurrentAreaRequest(requestId)) return;
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
})
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
.finally(() => {
@ -339,6 +354,7 @@ export function useHexagonSelection({
[
selectedHexagon,
resolution,
areaStatsUseFilters,
fetchHexagonStats,
fetchPostcodeStats,
invalidateAreaRequests,
@ -423,7 +439,7 @@ export function useHexagonSelection({
!selection.lockedResolution &&
selection.resolution < resolution;
const overlappingHexagon = zoomingIntoHexagon
? findOverlappingMatchingHexagon(selection.id, hexagonData, resolution)
? findOverlappingSelectableHexagon(selection.id, hexagonData, resolution)
: null;
if (zoomingIntoHexagon && !overlappingHexagon) return;
@ -451,12 +467,22 @@ export function useHexagonSelection({
const lookup = await fetchPostcodeLookup(areaStats.central_postcode, controller.signal);
nextSelection = { id: lookup.postcode, type: 'postcode', resolution };
nextGeometry = lookup.geometry;
nextStats = await fetchPostcodeStats(lookup.postcode, controller.signal);
nextStats = await fetchPostcodeStats(
lookup.postcode,
controller.signal,
areaStatsUseFilters
);
} else if (!usePostcodeView && selection.type === 'postcode') {
const lookup = await fetchPostcodeLookup(selection.id, controller.signal);
const nextId = latLngToCell(lookup.latitude, lookup.longitude, resolution);
nextSelection = { id: nextId, type: 'hexagon', resolution };
nextStats = await fetchHexagonStats(nextId, resolution, controller.signal);
nextStats = await fetchHexagonStats(
nextId,
resolution,
controller.signal,
undefined,
areaStatsUseFilters
);
} else if (
!usePostcodeView &&
selection.type === 'hexagon' &&
@ -469,7 +495,13 @@ export function useHexagonSelection({
: overlappingHexagon?.h3;
if (!nextId) return;
nextSelection = { id: nextId, type: 'hexagon', resolution };
nextStats = await fetchHexagonStats(nextId, resolution, controller.signal);
nextStats = await fetchHexagonStats(
nextId,
resolution,
controller.signal,
undefined,
areaStatsUseFilters
);
} else {
return;
}
@ -478,7 +510,12 @@ export function useHexagonSelection({
setSelectedHexagon(nextSelection);
setSelectedPostcodeGeometry(nextGeometry);
setAreaStats(nextStats);
refreshUnfilteredAreaCount(nextSelection, nextStats.count, controller.signal);
refreshUnfilteredAreaCount(
nextSelection,
nextStats.count,
areaStatsUseFilters,
controller.signal
);
refreshProperties(nextSelection);
}
@ -505,6 +542,7 @@ export function useHexagonSelection({
hexagonData,
resolution,
usePostcodeView,
areaStatsUseFilters,
areaStats?.central_postcode,
fetchHexagonStats,
fetchPostcodeStats,
@ -519,11 +557,11 @@ export function useHexagonSelection({
]);
// Re-fetch stats when filters or travel constraints change while an area is selected
const prevSelectionQueryKey = useRef(selectionQueryKey);
const prevAreaStatsQueryKey = useRef(areaStatsQueryKey);
useEffect(() => {
if (prevSelectionQueryKey.current === selectionQueryKey) return;
prevSelectionQueryKey.current = selectionQueryKey;
if (prevAreaStatsQueryKey.current === areaStatsQueryKey) return;
prevAreaStatsQueryKey.current = areaStatsQueryKey;
if (!selectedHexagon) return;
@ -538,16 +576,22 @@ export function useHexagonSelection({
const fetchStats =
selectedHexagon.type === 'postcode'
? fetchPostcodeStats(selectedHexagon.id)
: fetchHexagonStats(selectedHexagon.id, selectedHexagon.resolution);
? fetchPostcodeStats(selectedHexagon.id, undefined, areaStatsUseFilters)
: fetchHexagonStats(
selectedHexagon.id,
selectedHexagon.resolution,
undefined,
undefined,
areaStatsUseFilters
);
fetchStats
.then((stats) => {
if (cancelled || !isCurrentAreaRequest(requestId)) return;
setAreaStats(stats);
refreshUnfilteredAreaCount(selectedHexagon, stats.count);
refreshUnfilteredAreaCount(selectedHexagon, stats.count, areaStatsUseFilters);
// Re-fetch properties if the properties tab is active and the filtered area still has matches.
if (rightPaneTab === 'properties' && stats.count > 0) {
if (areaStatsUseFilters && rightPaneTab === 'properties' && stats.count > 0) {
if (selectedHexagon.type === 'postcode') {
fetchPostcodeProperties(selectedHexagon.id, 0);
} else {
@ -567,10 +611,11 @@ export function useHexagonSelection({
cancelled = true;
};
}, [
selectionQueryKey,
areaStatsQueryKey,
selectedHexagon,
fetchHexagonStats,
fetchPostcodeStats,
areaStatsUseFilters,
rightPaneTab,
fetchHexagonProperties,
fetchPostcodeProperties,
@ -598,8 +643,9 @@ export function useHexagonSelection({
setRightPaneTab(openProperties ? 'properties' : 'area');
setLoadingAreaStats(true);
// First try the postcode; if it has no properties, fall back to hexagons
fetchPostcodeStats(postcode)
// First try the postcode; if it only has no matches because of active filters,
// keep the searched postcode selected instead of widening to nearby hexagons.
fetchPostcodeStats(postcode, undefined, areaStatsUseFilters)
.then(async (stats) => {
if (!isCurrentAreaRequest(requestId)) return;
if (stats.count > 0) {
@ -607,13 +653,27 @@ export function useHexagonSelection({
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(geometry);
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
if (openProperties) {
fetchPostcodeProperties(postcode, 0, focusAddress);
}
return;
}
if (areaStatsUseFilters && hasStatsFilters) {
const unfilteredStats = await fetchPostcodeStats(postcode, undefined, false);
if (!isCurrentAreaRequest(requestId)) return;
if (unfilteredStats.count > 0) {
const selection = { id: postcode, type: 'postcode' as const, resolution };
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(geometry);
setAreaStats(stats);
setUnfilteredAreaCount(unfilteredStats.count);
setRightPaneTab(openProperties ? 'properties' : 'area');
return;
}
}
// No properties in this postcode — fall back to hexagons
if (lat == null || lng == null) {
// No coordinates available, show empty postcode anyway
@ -621,7 +681,7 @@ export function useHexagonSelection({
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(geometry);
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
setRightPaneTab('area');
return;
}
@ -630,14 +690,20 @@ export function useHexagonSelection({
const resolutions = [9, 8, 7, 6, 5];
for (const res of resolutions) {
const h3 = latLngToCell(lat, lng, res);
const hexStats = await fetchHexagonStats(h3, res);
const hexStats = await fetchHexagonStats(
h3,
res,
undefined,
undefined,
areaStatsUseFilters
);
if (!isCurrentAreaRequest(requestId)) return;
if (hexStats.count > 1) {
const selection = { id: h3, type: 'hexagon' as const, resolution: res };
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(null);
setAreaStats(hexStats);
refreshUnfilteredAreaCount(selection, hexStats.count);
refreshUnfilteredAreaCount(selection, hexStats.count, areaStatsUseFilters);
setRightPaneTab('area');
return;
}
@ -645,13 +711,19 @@ 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);
const fallbackStats = await fetchHexagonStats(
h3,
9,
undefined,
undefined,
areaStatsUseFilters
);
if (!isCurrentAreaRequest(requestId)) return;
const selection = { id: h3, type: 'hexagon' as const, resolution: 9 };
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(null);
setAreaStats(fallbackStats);
refreshUnfilteredAreaCount(selection, fallbackStats.count);
refreshUnfilteredAreaCount(selection, fallbackStats.count, areaStatsUseFilters);
setRightPaneTab('area');
})
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
@ -661,6 +733,8 @@ export function useHexagonSelection({
},
[
resolution,
areaStatsUseFilters,
hasStatsFilters,
fetchPostcodeStats,
fetchHexagonStats,
fetchPostcodeProperties,
@ -693,11 +767,17 @@ export function useHexagonSelection({
setRightPaneTab('area');
setLoadingAreaStats(true);
fetchHexagonStats(h3, SMALLEST_VISIBLE_HEXAGON_RESOLUTION)
fetchHexagonStats(
h3,
SMALLEST_VISIBLE_HEXAGON_RESOLUTION,
undefined,
undefined,
areaStatsUseFilters
)
.then((stats) => {
if (!isCurrentAreaRequest(requestId)) return;
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
})
.catch((error) => logNonAbortError('Failed to fetch current location hex stats', error))
.finally(() => {
@ -705,6 +785,7 @@ export function useHexagonSelection({
});
},
[
areaStatsUseFilters,
fetchHexagonStats,
invalidateAreaRequests,
invalidatePropertyRequests,
@ -721,6 +802,8 @@ export function useHexagonSelection({
areaStats,
loadingAreaStats,
unfilteredAreaCount,
areaStatsUseFilters,
setAreaStatsUseFilters,
hoveredHexagon,
rightPaneTab,
setRightPaneTab,