perfect-postcode/frontend/src/hooks/useMapData.ts
2026-05-11 21:38:26 +01:00

625 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import type {
FeatureMeta,
FeatureFilters,
Bounds,
HexagonData,
PostcodeFeature,
ViewChangeParams,
ApiResponse,
} from '../types';
import {
buildFilterString,
apiUrl,
assertOk,
logNonAbortError,
authHeaders,
isAbortError,
} from '../lib/api';
import { getSchoolBackendFeatureName } from '../lib/school-filter';
import { getSpecificCrimeFeatureName } from '../lib/crime-filter';
import { getElectionVoteShareFeatureName } from '../lib/election-filter';
import { getEthnicityFeatureName } from '../lib/ethnicity-filter';
import { getPoiDistanceFeatureName } from '../lib/poi-distance-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';
/** Return the p-th percentile (0100) from a sorted array via linear interpolation. */
function percentile(sorted: number[], p: number): number {
if (sorted.length === 0) return 0;
if (sorted.length === 1) return sorted[0];
const idx = (p / 100) * (sorted.length - 1);
const lo = Math.floor(idx);
const hi = Math.ceil(idx);
if (lo === hi) return sorted[lo];
return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
}
const DEBOUNCE_MS = 150;
interface UseMapDataOptions {
filters: FeatureFilters;
features: FeatureMeta[];
viewFeature: string | null;
activeFeature: string | null;
pinnedFeature: string | null;
travelTimeEntries: TravelTimeEntry[];
/** Share-link code from the URL; appended to data fetches so the backend
* grants bbox-scoped access for unlicensed recipients. */
shareCode?: string;
}
export function useMapData({
filters,
features,
viewFeature,
activeFeature,
pinnedFeature,
travelTimeEntries,
shareCode,
}: UseMapDataOptions) {
const [rawData, setRawData] = useState<HexagonData[]>([]);
const [postcodeData, setPostcodeData] = useState<PostcodeFeature[]>([]);
const [resolution, setResolution] = useState<number>(8);
const [bounds, setBounds] = useState<Bounds | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [zoom, setZoom] = useState<number>(10);
const [currentView, setCurrentView] = useState<{
latitude: number;
longitude: number;
zoom: number;
} | null>(null);
const [licenseRequired, setLicenseRequired] = useState(false);
const [freeZone, setFreeZone] = useState<Bounds | null>(null);
// Drag preview state
const [dragHexData, setDragHexData] = useState<HexagonData[] | null>(null);
const [dragPostcodeData, setDragPostcodeData] = useState<PostcodeFeature[] | null>(null);
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) ??
getElectionVoteShareFeatureName(name) ??
getEthnicityFeatureName(name) ??
getPoiDistanceFeatureName(name) ??
name,
[]
);
const dataViewFeature = useMemo(
() => (viewFeature ? getBackendFeatureName(viewFeature) : null),
[getBackendFeatureName, viewFeature]
);
const pinnedDataViewFeature = useMemo(
() => (pinnedFeature ? getBackendFeatureName(pinnedFeature) : null),
[getBackendFeatureName, pinnedFeature]
);
// Determine if the current viewFeature is an enum (for enum_dist param)
const viewFeatureIsEnum = useMemo(
() =>
dataViewFeature ? features.find((f) => f.name === dataViewFeature)?.type === 'enum' : false,
[dataViewFeature, features]
);
const buildFilterParam = useCallback(
(): string => buildFilterString(filters, features),
[filters, features]
);
const filtersParam = useMemo(() => buildFilterParam(), [buildFilterParam]);
// Build the travel param string from entries with destinations.
// Format: mode:slug[:best][:min:max] — server filters rows outside [min,max].
// When excludeFieldKey is set, that entry uses a wide range (0:1440) instead of
// the committed range. This still filters out rows with no travel data (the server
// skips rows where minutes=None when any range is set) while including all actual values.
const buildTravelParam = useCallback(
(excludeFieldKey?: string): string => {
const segments: string[] = [];
for (const entry of travelTimeEntries) {
if (!entry.slug) continue;
let seg = `${entry.mode}:${entry.slug}`;
if (entry.useBest) seg += ':best';
const isExcluded = excludeFieldKey === `tt_${entry.mode}_${entry.slug}`;
if (isExcluded) {
seg += ':0:1440';
} else if (entry.timeRange) {
seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
}
segments.push(seg);
}
return segments.join('|');
},
[travelTimeEntries]
);
const travelParam = useMemo(() => buildTravelParam(), [buildTravelParam]);
const boundsParam = useMemo(
() => (bounds ? `${bounds.south},${bounds.west},${bounds.north},${bounds.east}` : ''),
[bounds]
);
const dataRequestKey = useMemo(
() =>
bounds
? [
usePostcodeView ? 'postcodes' : 'hexagons',
resolution,
boundsParam,
filtersParam,
dataViewFeature ?? '',
viewFeatureIsEnum && dataViewFeature ? dataViewFeature : '',
travelParam,
shareCode ?? '',
].join('|')
: '',
[
bounds,
boundsParam,
dataViewFeature,
filtersParam,
resolution,
shareCode,
travelParam,
usePostcodeView,
viewFeatureIsEnum,
]
);
const [loadedDataKey, setLoadedDataKey] = useState<string>('');
// Keep activeFeatureRef in sync
useEffect(() => {
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.
useEffect(() => {
if (!activeFeature || !bounds) return;
if (dragAbortRef.current) dragAbortRef.current.abort();
dragAbortRef.current = new AbortController();
const filtersStr = buildFilterString(filters, features, activeFeature);
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const isTravelTimeDrag = activeFeature.startsWith('tt_');
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 });
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', fieldsParam);
if (dragTravelParam) params.set('travel', dragTravelParam);
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
if (shareCode) params.set('share', shareCode);
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;
})
.catch((err) => logNonAbortError('Failed to fetch drag postcode data', err));
} else {
const params = new URLSearchParams({
resolution: resolution.toString(),
bounds: boundsStr,
});
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', fieldsParam);
if (dragTravelParam) params.set('travel', dragTravelParam);
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
if (shareCode) params.set('share', shareCode);
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;
})
.catch((err) => logNonAbortError('Failed to fetch drag hex data', err));
}
return () => {
if (dragAbortRef.current) {
dragAbortRef.current.abort();
dragAbortRef.current = null;
}
if (latestDragRequestKeyRef.current === requestKey) {
latestDragRequestKeyRef.current = '';
}
};
}, [
activeFeature,
bounds,
resolution,
filters,
features,
usePostcodeView,
travelParam,
buildTravelParam,
dataViewFeature,
getBackendFeatureName,
viewFeatureIsEnum,
shareCode,
]);
// Fetch hexagons or postcodes when bounds/filters change
useEffect(() => {
if (!bounds) {
latestDataRequestKeyRef.current = '';
setLoading(false);
return;
}
latestDataRequestKeyRef.current = dataRequestKey;
setLoading(true);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(async () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
const requestKey = dataRequestKey;
try {
if (usePostcodeView) {
const params = new URLSearchParams({ bounds: boundsParam });
if (filtersParam) params.set('filters', filtersParam);
params.set(
'fields',
dataViewFeature && !dataViewFeature.startsWith('tt_') ? dataViewFeature : ''
);
if (travelParam) {
params.set('travel', travelParam);
}
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
if (shareCode) params.set('share', shareCode);
const res = await fetch(
apiUrl('postcodes', params),
authHeaders({
signal: abortControllerRef.current.signal,
})
);
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);
setLoading(false);
return;
}
}
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);
} else {
const params = new URLSearchParams({
resolution: resolution.toString(),
bounds: boundsParam,
});
if (filtersParam) params.set('filters', filtersParam);
params.set(
'fields',
dataViewFeature && !dataViewFeature.startsWith('tt_') ? dataViewFeature : ''
);
if (travelParam) {
params.set('travel', travelParam);
}
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
if (shareCode) params.set('share', shareCode);
const res = await fetch(
apiUrl('hexagons', params),
authHeaders({
signal: abortControllerRef.current.signal,
})
);
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);
setLoading(false);
return;
}
}
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);
}
// Clear drag data when committed fetch completes and we're not mid-drag
if (!activeFeatureRef.current) {
setDragHexData(null);
setDragPostcodeData(null);
dragFeatureRef.current = null;
}
setLoading(false);
} catch (err) {
if (requestKey === latestDataRequestKeyRef.current && !isAbortError(err)) {
logNonAbortError('Failed to fetch data', err);
setLoading(false);
}
}
}, DEBOUNCE_MS);
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
};
}, [
resolution,
bounds,
filters,
filtersParam,
boundsParam,
dataRequestKey,
dataViewFeature,
viewFeatureIsEnum,
usePostcodeView,
travelParam,
shareCode,
]);
// Use drag data when it matches the current view feature, otherwise fall back to rawData
const data =
(activeFeature && viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ??
rawData;
const effectivePostcodeData =
(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
// scale stays stable while dragging a filter slider.
const dataRange = useMemo((): [number, number] | null => {
if (!dataViewFeature) return null;
const isTravelTime = dataViewFeature.startsWith('tt_');
if (!isTravelTime) {
const meta = features.find((f) => f.name === dataViewFeature);
if (!meta || meta.type === 'enum') return null;
}
const vals: number[] = [];
if (usePostcodeView) {
if (postcodeData.length === 0) return null;
for (const feat of postcodeData) {
if (bounds) {
const [lng, lat] = feat.properties.centroid as [number, number];
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east)
continue;
}
const val = feat.properties[`avg_${dataViewFeature}`];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
} else {
if (rawData.length === 0) return null;
for (const item of rawData) {
if (bounds) {
const { lat, lon } = item;
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
continue;
}
const val = item[`avg_${dataViewFeature}`];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
}
if (vals.length === 0) return null;
vals.sort((a, b) => a - b);
return [
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
];
}, [dataViewFeature, rawData, postcodeData, usePostcodeView, features, bounds]);
// Live color range for the legend and hex coloring.
const liveColorRange = useMemo((): [number, number] | null => {
if (!dataViewFeature) return null;
// Travel time keys: use dataRange directly (no FeatureMeta)
if (dataViewFeature.startsWith('tt_')) {
return dataRange;
}
const meta = features.find((f) => f.name === dataViewFeature);
if (!meta) return null;
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
return [0, meta.values.length - 1];
}
if (dataRange) return dataRange;
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
return null;
}, [dataViewFeature, features, dataRange]);
const isEyePreviewingPinnedFeature =
!activeFeature && dataViewFeature != null && dataViewFeature === pinnedDataViewFeature;
const [frozenPreviewRange, setFrozenPreviewRange] = useState<{
feature: string;
range: [number, number];
} | null>(null);
useEffect(() => {
setFrozenPreviewRange((prev) => {
if (!pinnedDataViewFeature) return prev ? null : prev;
return prev?.feature === pinnedDataViewFeature ? prev : null;
});
}, [pinnedDataViewFeature]);
useEffect(() => {
if (!isEyePreviewingPinnedFeature || !pinnedDataViewFeature) return;
const meta = pinnedDataViewFeature.startsWith('tt_')
? null
: features.find((f) => f.name === pinnedDataViewFeature);
const rangeToFreeze =
dataRange && loadedDataKey === dataRequestKey
? dataRange
: meta?.type === 'enum' && liveColorRange
? liveColorRange
: null;
if (!rangeToFreeze) return;
setFrozenPreviewRange((prev) =>
prev?.feature === pinnedDataViewFeature
? prev
: { feature: pinnedDataViewFeature, range: rangeToFreeze }
);
}, [
dataRange,
dataRequestKey,
features,
isEyePreviewingPinnedFeature,
loadedDataKey,
liveColorRange,
pinnedDataViewFeature,
]);
const colorRange = useMemo((): [number, number] | null => {
if (
isEyePreviewingPinnedFeature &&
frozenPreviewRange &&
frozenPreviewRange.feature === dataViewFeature
) {
return frozenPreviewRange.range;
}
return liveColorRange;
}, [dataViewFeature, frozenPreviewRange, isEyePreviewingPinnedFeature, liveColorRange]);
const canResetPreviewScale = useMemo(() => {
if (
!isEyePreviewingPinnedFeature ||
!pinnedDataViewFeature ||
!liveColorRange ||
loadedDataKey !== dataRequestKey
) {
return false;
}
if (pinnedDataViewFeature.startsWith('tt_')) return true;
return features.find((f) => f.name === pinnedDataViewFeature)?.type !== 'enum';
}, [
dataRequestKey,
features,
isEyePreviewingPinnedFeature,
liveColorRange,
loadedDataKey,
pinnedDataViewFeature,
]);
const handleResetPreviewScale = useCallback(() => {
if (!canResetPreviewScale || !pinnedDataViewFeature || !liveColorRange) return;
setFrozenPreviewRange({ feature: pinnedDataViewFeature, range: liveColorRange });
}, [canResetPreviewScale, liveColorRange, pinnedDataViewFeature]);
const handleViewChange = useCallback(
({
resolution: newRes,
bounds: newBounds,
zoom: newZoom,
latitude,
longitude,
}: ViewChangeParams) => {
const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`;
if (boundsKey !== prevBoundsRef.current) {
prevBoundsRef.current = boundsKey;
setResolution(newRes);
setBounds(newBounds);
}
setZoom(newZoom);
setCurrentView({ latitude, longitude, zoom: newZoom });
},
[]
);
const setInitialView = useCallback(
(view: { latitude: number; longitude: number; zoom: number }) => {
setCurrentView(view);
setZoom(view.zoom);
},
[]
);
return {
data,
committedHexagonData: rawData,
postcodeData: effectivePostcodeData,
resolution,
bounds,
loading,
zoom,
currentView,
usePostcodeView,
colorRange,
canResetPreviewScale,
handleResetPreviewScale,
handleViewChange,
setInitialView,
licenseRequired,
freeZone,
};
}