perfect-postcode/frontend/src/hooks/useHexagonSelection.ts
Andras Schmelczer 2f149503bb
Some checks failed
Build and publish Docker image / build-and-push (push) Failing after 7m0s
CI / Check (push) Failing after 7m9s
all is well
2026-05-17 17:20:19 +01:00

748 lines
25 KiB
TypeScript

import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
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';
import { findOverlappingSelectableHexagon } from '../lib/h3-selection';
import { SMALLEST_VISIBLE_HEXAGON_RESOLUTION } from '../lib/consts';
import type { TravelTimeEntry } from './useTravelTime';
import { buildTravelParam } from '../lib/travel-params';
interface SelectedHexagon {
id: string;
type: 'hexagon' | 'postcode';
resolution: number;
lockedResolution?: boolean;
}
interface JourneyDest {
mode: string;
slug: string;
}
interface PostcodeLookupResponse {
postcode: string;
latitude: number;
longitude: number;
geometry: PostcodeGeometry;
}
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;
}
export function useHexagonSelection({
filters,
features,
hexagonData,
resolution,
usePostcodeView,
travelTimeEntries,
shareCode,
journeyDest,
}: UseHexagonSelectionOptions) {
const [selectedHexagon, setSelectedHexagon] = useState<SelectedHexagon | null>(null);
const [properties, setProperties] = useState<Property[]>([]);
const [propertiesTotal, setPropertiesTotal] = useState(0);
const [propertiesOffset, setPropertiesOffset] = useState(0);
const [loadingProperties, setLoadingProperties] = useState(false);
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
const [unfilteredAreaCount, setUnfilteredAreaCount] = useState<number | null>(null);
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
);
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(() => buildTravelParam(travelTimeEntries), [travelTimeEntries]);
const fetchHexagonStats = useCallback(
async (
h3: string,
res: number,
signal?: AbortSignal,
fields?: string[],
includeFilters = true
) => {
const params = new URLSearchParams({
h3,
resolution: res.toString(),
});
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(';;'));
}
if (journeyDest) {
params.set('journey_mode', journeyDest.mode);
params.set('journey_slug', journeyDest.slug);
}
const response = await fetch(apiUrl('hexagon-stats', params), authHeaders({ signal }));
assertOk(response, 'hexagon-stats');
return (await response.json()) as HexagonStatsResponse;
},
[filters, features, journeyDest, shareCode, travelParam]
);
const fetchPostcodeStats = useCallback(
async (postcode: string, signal?: AbortSignal, includeFilters = true) => {
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, 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(
() =>
[
areaStatsUseFilters ? 'filtered' : 'all',
areaStatsUseFilters ? filterStr : '',
areaStatsUseFilters ? travelParam : '',
journeyKey,
shareCode ?? '',
].join('|'),
[areaStatsUseFilters, filterStr, journeyKey, shareCode, travelParam]
);
const fetchUnfilteredAreaCount = useCallback(
async (selection: SelectedHexagon, requestId: number, signal?: AbortSignal) => {
if (!hasStatsFilters) {
if (isCurrentAreaRequest(requestId)) setUnfilteredAreaCount(null);
return;
}
const stats =
selection.type === 'postcode'
? await fetchPostcodeStats(selection.id, signal, false)
: await fetchHexagonStats(selection.id, selection.resolution, signal, undefined, false);
if (isCurrentAreaRequest(requestId)) setUnfilteredAreaCount(stats.count);
},
[fetchHexagonStats, fetchPostcodeStats, hasStatsFilters, isCurrentAreaRequest]
);
const refreshUnfilteredAreaCount = useCallback(
(
selection: SelectedHexagon,
statsCount: number,
includeFilters: boolean,
requestId: number,
signal?: AbortSignal
) => {
if (!includeFilters || !hasStatsFilters || statsCount > 0) {
if (isCurrentAreaRequest(requestId)) setUnfilteredAreaCount(null);
return;
}
fetchUnfilteredAreaCount(selection, requestId, signal).catch((error) =>
logNonAbortError('Failed to fetch unfiltered area count', error)
);
},
[fetchUnfilteredAreaCount, hasStatsFilters, isCurrentAreaRequest]
);
const fetchPostcodeLookup = useCallback(async (postcode: string, signal?: AbortSignal) => {
const response = await fetch(
`/api/postcode/${encodeURIComponent(postcode)}`,
authHeaders({ signal })
);
assertOk(response, 'postcode lookup');
return (await response.json()) as PostcodeLookupResponse;
}, []);
const fetchHexagonProperties = useCallback(
async (h3: string, res: number, offset = 0) => {
const requestId = invalidatePropertyRequests();
setLoadingProperties(true);
try {
const params = new URLSearchParams({
h3,
resolution: res.toString(),
offset: offset.toString(),
});
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);
} else {
setProperties((prev) => [...prev, ...data.properties]);
}
setPropertiesTotal(data.total);
setPropertiesOffset(offset + data.properties.length);
} catch (err) {
logNonAbortError('Failed to fetch properties', err);
} finally {
if (isCurrentPropertyRequest(requestId)) setLoadingProperties(false);
}
},
[
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({
postcode,
offset: offset.toString(),
});
if (focusAddress && offset === 0) {
params.set('focus_address', focusAddress);
}
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);
} else {
setProperties((prev) => [...prev, ...data.properties]);
}
setPropertiesTotal(data.total);
setPropertiesOffset(offset + data.properties.length);
} catch (err) {
logNonAbortError('Failed to fetch postcode properties', err);
} finally {
if (isCurrentPropertyRequest(requestId)) setLoadingProperties(false);
}
},
[
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);
setUnfilteredAreaCount(null);
setSelectedPostcodeGeometry(null);
} 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);
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
setAreaStats(null);
setUnfilteredAreaCount(null);
setRightPaneTab('area');
if (isPostcode) {
setLoadingAreaStats(true);
fetchPostcodeStats(id, undefined, areaStatsUseFilters)
.then((stats) => {
if (!isCurrentAreaRequest(requestId)) return;
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters, requestId);
})
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
.finally(() => {
if (isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
});
} else {
setLoadingAreaStats(true);
fetchHexagonStats(id, resolution, undefined, undefined, areaStatsUseFilters)
.then((stats) => {
if (!isCurrentAreaRequest(requestId)) return;
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters, requestId);
})
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
.finally(() => {
if (isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
});
}
}
},
[
selectedHexagon,
resolution,
areaStatsUseFilters,
fetchHexagonStats,
fetchPostcodeStats,
invalidateAreaRequests,
invalidatePropertyRequests,
isCurrentAreaRequest,
refreshUnfilteredAreaCount,
]
);
const handleHexagonHover = useCallback((h3: string | null) => {
setHoveredHexagon(h3);
}, []);
const handleViewPropertiesFromArea = useCallback(() => {
if (!selectedHexagon) return;
trackEvent('View Properties');
setRightPaneTab('properties');
setPropertiesOffset(0);
if (selectedHexagon.type === 'postcode') {
fetchPostcodeProperties(selectedHexagon.id, 0);
} else {
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
}
}, [selectedHexagon, fetchHexagonProperties, fetchPostcodeProperties]);
const handlePropertiesTabClick = useCallback(() => {
setRightPaneTab('properties');
if (selectedHexagon && properties.length === 0 && !loadingProperties) {
setPropertiesOffset(0);
if (selectedHexagon.type === 'postcode') {
fetchPostcodeProperties(selectedHexagon.id, 0);
} else {
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
}
}
}, [
selectedHexagon,
properties.length,
loadingProperties,
fetchHexagonProperties,
fetchPostcodeProperties,
]);
const handleLoadMoreProperties = useCallback(() => {
if (!selectedHexagon) return;
if (selectedHexagon.type === 'postcode') {
fetchPostcodeProperties(selectedHexagon.id, propertiesOffset);
} else {
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, propertiesOffset);
}
}, [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(() => {
if (!selectedHexagon) return;
const selection = selectedHexagon;
const shouldSync =
(usePostcodeView &&
selection.type === 'hexagon' &&
!selection.lockedResolution &&
areaStats?.central_postcode != null) ||
(!usePostcodeView && selection.type === 'postcode' && !selection.lockedResolution) ||
(!usePostcodeView &&
selection.type === 'hexagon' &&
!selection.lockedResolution &&
selection.resolution !== resolution);
if (!shouldSync) return;
const zoomingIntoHexagon =
!usePostcodeView &&
selection.type === 'hexagon' &&
!selection.lockedResolution &&
selection.resolution < resolution;
const overlappingHexagon = zoomingIntoHexagon
? findOverlappingSelectableHexagon(selection.id, hexagonData, resolution)
: null;
if (zoomingIntoHexagon && !overlappingHexagon) return;
const requestId = invalidateAreaRequests();
invalidatePropertyRequests();
let cancelled = false;
const controller = new AbortController();
const refreshProperties = (selection: SelectedHexagon) => {
if (rightPaneTab !== 'properties') return;
if (selection.type === 'postcode') {
fetchPostcodeProperties(selection.id, 0);
} else {
fetchHexagonProperties(selection.id, selection.resolution, 0);
}
};
async function syncSelection() {
let nextSelection: SelectedHexagon | null = null;
let nextGeometry: PostcodeGeometry | null = null;
let nextStats: HexagonStatsResponse | null = null;
if (usePostcodeView && selection.type === 'hexagon' && !selection.lockedResolution) {
if (!areaStats?.central_postcode) return;
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,
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,
undefined,
areaStatsUseFilters
);
} else if (
!usePostcodeView &&
selection.type === 'hexagon' &&
!selection.lockedResolution &&
selection.resolution !== resolution
) {
const nextId =
resolution < selection.resolution
? cellToParent(selection.id, resolution)
: overlappingHexagon?.h3;
if (!nextId) return;
nextSelection = { id: nextId, type: 'hexagon', resolution };
nextStats = await fetchHexagonStats(
nextId,
resolution,
controller.signal,
undefined,
areaStatsUseFilters
);
} else {
return;
}
if (cancelled || !isCurrentAreaRequest(requestId) || !nextSelection || !nextStats) return;
setSelectedHexagon(nextSelection);
setSelectedPostcodeGeometry(nextGeometry);
setAreaStats(nextStats);
refreshUnfilteredAreaCount(
nextSelection,
nextStats.count,
areaStatsUseFilters,
requestId,
controller.signal
);
refreshProperties(nextSelection);
}
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
setUnfilteredAreaCount(null);
setLoadingAreaStats(true);
syncSelection()
.catch((error) => {
if (!cancelled) logNonAbortError('Failed to sync selected area with map view', error);
})
.finally(() => {
if (!cancelled && isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
});
return () => {
cancelled = true;
controller.abort();
};
}, [
selectedHexagon,
hexagonData,
resolution,
usePostcodeView,
areaStatsUseFilters,
areaStats?.central_postcode,
fetchHexagonStats,
fetchPostcodeStats,
fetchPostcodeLookup,
fetchHexagonProperties,
fetchPostcodeProperties,
invalidateAreaRequests,
invalidatePropertyRequests,
isCurrentAreaRequest,
refreshUnfilteredAreaCount,
rightPaneTab,
]);
// Re-fetch stats when filters or travel constraints change while an area is selected
const prevAreaStatsQueryKey = useRef(areaStatsQueryKey);
useEffect(() => {
if (prevAreaStatsQueryKey.current === areaStatsQueryKey) return;
prevAreaStatsQueryKey.current = areaStatsQueryKey;
if (!selectedHexagon) return;
// Clear stale properties
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
invalidatePropertyRequests();
setAreaStats(null);
setUnfilteredAreaCount(null);
setLoadingAreaStats(true);
let cancelled = false;
const requestId = invalidateAreaRequests();
const fetchStats =
selectedHexagon.type === 'postcode'
? 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, 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) {
if (selectedHexagon.type === 'postcode') {
fetchPostcodeProperties(selectedHexagon.id, 0);
} else {
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
}
}
})
.catch((error) => {
if (cancelled || !isCurrentAreaRequest(requestId)) return;
logNonAbortError('Failed to refresh stats', error);
})
.finally(() => {
if (!cancelled && isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
});
return () => {
cancelled = true;
};
}, [
areaStatsQueryKey,
selectedHexagon,
fetchHexagonStats,
fetchPostcodeStats,
areaStatsUseFilters,
rightPaneTab,
fetchHexagonProperties,
fetchPostcodeProperties,
invalidateAreaRequests,
invalidatePropertyRequests,
isCurrentAreaRequest,
refreshUnfilteredAreaCount,
]);
const handleLocationSearch = useCallback(
(
postcode: string,
geometry: PostcodeGeometry,
_lat?: number,
_lng?: number,
openProperties = false,
focusAddress?: string
) => {
const requestId = invalidateAreaRequests();
invalidatePropertyRequests();
const selection = {
id: postcode,
type: 'postcode' as const,
resolution,
lockedResolution: true,
};
trackEvent(openProperties ? 'Address Search' : 'Postcode Search');
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(geometry);
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
setAreaStats(null);
setUnfilteredAreaCount(null);
setRightPaneTab(openProperties ? 'properties' : 'area');
setLoadingAreaStats(true);
fetchPostcodeStats(postcode, undefined, areaStatsUseFilters)
.then((stats) => {
if (!isCurrentAreaRequest(requestId)) return;
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters, requestId);
if (openProperties && stats.count > 0) {
fetchPostcodeProperties(postcode, 0, focusAddress);
}
})
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
.finally(() => {
if (isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
});
},
[
resolution,
areaStatsUseFilters,
fetchPostcodeStats,
fetchPostcodeProperties,
invalidateAreaRequests,
invalidatePropertyRequests,
isCurrentAreaRequest,
refreshUnfilteredAreaCount,
]
);
const handleCurrentLocationSearch = useCallback(
(lat: number, lng: number) => {
const requestId = invalidateAreaRequests();
invalidatePropertyRequests();
const h3 = latLngToCell(lat, lng, SMALLEST_VISIBLE_HEXAGON_RESOLUTION);
const selection = {
id: h3,
type: 'hexagon' as const,
resolution: SMALLEST_VISIBLE_HEXAGON_RESOLUTION,
lockedResolution: true,
};
trackEvent('Current Location Search');
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(null);
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
setAreaStats(null);
setUnfilteredAreaCount(null);
setRightPaneTab('area');
setLoadingAreaStats(true);
fetchHexagonStats(
h3,
SMALLEST_VISIBLE_HEXAGON_RESOLUTION,
undefined,
undefined,
areaStatsUseFilters
)
.then((stats) => {
if (!isCurrentAreaRequest(requestId)) return;
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters, requestId);
})
.catch((error) => logNonAbortError('Failed to fetch current location hex stats', error))
.finally(() => {
if (isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
});
},
[
areaStatsUseFilters,
fetchHexagonStats,
invalidateAreaRequests,
invalidatePropertyRequests,
isCurrentAreaRequest,
refreshUnfilteredAreaCount,
]
);
return {
selectedHexagon,
properties,
propertiesTotal,
loadingProperties,
areaStats,
loadingAreaStats,
unfilteredAreaCount,
areaStatsUseFilters,
setAreaStatsUseFilters,
hoveredHexagon,
rightPaneTab,
setRightPaneTab,
handleHexagonClick,
handleHexagonHover,
handleViewPropertiesFromArea,
handlePropertiesTabClick,
handleLoadMoreProperties,
handleCloseSelection,
selectedPostcodeGeometry,
handleLocationSearch,
handleCurrentLocationSearch,
};
}