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

@ -17,6 +17,7 @@ import {
isAbortError,
} from '../lib/api';
import { getSchoolBackendFeatureName } from '../lib/school-filter';
import { getSpecificCrimeFeatureName } from '../lib/crime-filter';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
import { type TravelTimeEntry } from './useTravelTime';
@ -75,19 +76,26 @@ export function useMapData({
const dragFeatureRef = useRef<string | null>(null);
const dragAbortRef = useRef<AbortController | null>(null);
const activeFeatureRef = useRef<string | null>(null);
const latestDataRequestKeyRef = useRef<string>('');
const latestDragRequestKeyRef = useRef<string>('');
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const prevBoundsRef = useRef<string>('');
const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD;
const getBackendFeatureName = useCallback(
(name: string) =>
getSchoolBackendFeatureName(name) ?? getSpecificCrimeFeatureName(name) ?? name,
[]
);
const dataViewFeature = useMemo(
() => (viewFeature ? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature) : null),
[viewFeature]
() => (viewFeature ? getBackendFeatureName(viewFeature) : null),
[getBackendFeatureName, viewFeature]
);
const pinnedDataViewFeature = useMemo(
() => (pinnedFeature ? (getSchoolBackendFeatureName(pinnedFeature) ?? pinnedFeature) : null),
[pinnedFeature]
() => (pinnedFeature ? getBackendFeatureName(pinnedFeature) : null),
[getBackendFeatureName, pinnedFeature]
);
// Determine if the current viewFeature is an enum (for enum_dist param)
@ -166,6 +174,14 @@ export function useMapData({
activeFeatureRef.current = activeFeature;
}, [activeFeature]);
useEffect(() => {
if (activeFeature) return;
latestDragRequestKeyRef.current = '';
dragFeatureRef.current = null;
setDragHexData(null);
setDragPostcodeData(null);
}, [activeFeature]);
// Drag prefetch: when activeFeature starts, fetch data excluding that filter.
// For regular filters: excludes the filter from the filter string.
// For travel time: excludes the time range from that entry's travel param segment.
@ -178,11 +194,23 @@ export function useMapData({
const filtersStr = buildFilterString(filters, features, activeFeature);
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const isTravelTimeDrag = activeFeature.startsWith('tt_');
const dataActiveFeature = getSchoolBackendFeatureName(activeFeature) ?? activeFeature;
const dataActiveFeature = getBackendFeatureName(activeFeature);
const dragTravelParam = isTravelTimeDrag ? buildTravelParam(activeFeature) : travelParam;
// Travel time fields are computed from the travel param, not regular feature columns.
// Sending a tt_* name as fields would cause a 400 (unknown field). Use empty string instead.
const fieldsParam = isTravelTimeDrag ? '' : dataActiveFeature;
const requestKey = [
usePostcodeView ? 'postcodes' : 'hexagons',
activeFeature,
resolution,
boundsStr,
filtersStr,
fieldsParam,
dragTravelParam,
viewFeatureIsEnum && dataViewFeature ? dataViewFeature : '',
shareCode ?? '',
].join('|');
latestDragRequestKeyRef.current = requestKey;
if (usePostcodeView) {
const params = new URLSearchParams({ bounds: boundsStr });
@ -195,6 +223,7 @@ export function useMapData({
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json())
.then((json: { features: PostcodeFeature[] }) => {
if (latestDragRequestKeyRef.current !== requestKey) return;
setDragPostcodeData(json.features);
setDragHexData(null);
dragFeatureRef.current = activeFeature;
@ -214,6 +243,7 @@ export function useMapData({
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json())
.then((json: ApiResponse) => {
if (latestDragRequestKeyRef.current !== requestKey) return;
setDragHexData(json.features);
setDragPostcodeData(null);
dragFeatureRef.current = activeFeature;
@ -226,6 +256,9 @@ export function useMapData({
dragAbortRef.current.abort();
dragAbortRef.current = null;
}
if (latestDragRequestKeyRef.current === requestKey) {
latestDragRequestKeyRef.current = '';
}
};
}, [
activeFeature,
@ -237,13 +270,18 @@ export function useMapData({
travelParam,
buildTravelParam,
dataViewFeature,
getBackendFeatureName,
viewFeatureIsEnum,
shareCode,
]);
// Fetch hexagons or postcodes when bounds/filters change
useEffect(() => {
if (!bounds) return;
if (!bounds) {
latestDataRequestKeyRef.current = '';
return;
}
latestDataRequestKeyRef.current = dataRequestKey;
if (debounceRef.current) {
clearTimeout(debounceRef.current);
@ -255,10 +293,9 @@ export function useMapData({
}
abortControllerRef.current = new AbortController();
const requestKey = dataRequestKey;
setLoading(true);
try {
const requestKey = dataRequestKey;
if (usePostcodeView) {
const params = new URLSearchParams({ bounds: boundsParam });
if (filtersParam) params.set('filters', filtersParam);
@ -279,6 +316,7 @@ export function useMapData({
);
if (res.status === 403) {
const errBody = await res.json();
if (requestKey !== latestDataRequestKeyRef.current) return;
if (errBody.error === 'license_required' && errBody.free_zone) {
setLicenseRequired(true);
setFreeZone(errBody.free_zone);
@ -287,8 +325,10 @@ export function useMapData({
}
}
assertOk(res, 'postcodes');
if (requestKey !== latestDataRequestKeyRef.current) return;
setLicenseRequired(false);
const json: { features: PostcodeFeature[] } = await res.json();
if (requestKey !== latestDataRequestKeyRef.current) return;
setPostcodeData(json.features);
setRawData([]);
setLoadedDataKey(requestKey);
@ -315,6 +355,7 @@ export function useMapData({
);
if (res.status === 403) {
const errBody = await res.json();
if (requestKey !== latestDataRequestKeyRef.current) return;
if (errBody.error === 'license_required' && errBody.free_zone) {
setLicenseRequired(true);
setFreeZone(errBody.free_zone);
@ -323,8 +364,10 @@ export function useMapData({
}
}
assertOk(res, 'hexagons');
if (requestKey !== latestDataRequestKeyRef.current) return;
setLicenseRequired(false);
const json: ApiResponse = await res.json();
if (requestKey !== latestDataRequestKeyRef.current) return;
setRawData(json.features);
setPostcodeData([]);
setLoadedDataKey(requestKey);
@ -338,7 +381,7 @@ export function useMapData({
}
setLoading(false);
} catch (err) {
if (!isAbortError(err)) {
if (requestKey === latestDataRequestKeyRef.current && !isAbortError(err)) {
logNonAbortError('Failed to fetch data', err);
setLoading(false);
}
@ -349,6 +392,10 @@ export function useMapData({
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
};
}, [
resolution,
@ -366,10 +413,12 @@ export function useMapData({
// Use drag data when it matches the current view feature, otherwise fall back to rawData
const data =
(viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ?? rawData;
(activeFeature && viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ??
rawData;
const effectivePostcodeData =
(viewFeature && dragFeatureRef.current === viewFeature ? dragPostcodeData : null) ??
postcodeData;
(activeFeature && viewFeature && dragFeatureRef.current === viewFeature
? dragPostcodeData
: null) ?? postcodeData;
// Compute p5/p95 from committed data for the viewed feature.
// Always uses rawData/postcodeData (not drag preview data) so the color
@ -548,6 +597,7 @@ export function useMapData({
return {
data,
committedHexagonData: rawData,
postcodeData: effectivePostcodeData,
resolution,
bounds,