perfect-postcode/frontend/src/hooks/useMapData.ts
2026-03-25 08:04:48 +00:00

383 lines
13 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 { 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;
travelTimeEntries: TravelTimeEntry[];
}
export function useMapData({
filters,
features,
viewFeature,
activeFeature,
travelTimeEntries,
}: 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 debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const prevBoundsRef = useRef<string>('');
const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD;
const buildFilterParam = useCallback(
(): string => buildFilterString(filters, features),
[filters, features]
);
// 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's time range is omitted (for drag preview).
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 (entry.timeRange && !isExcluded) seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
segments.push(seg);
}
return segments.join('|');
},
[travelTimeEntries]
);
const travelParam = useMemo(() => buildTravelParam(), [buildTravelParam]);
// Keep activeFeatureRef in sync
useEffect(() => {
activeFeatureRef.current = activeFeature;
}, [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 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 ? '' : activeFeature;
if (usePostcodeView) {
const params = new URLSearchParams({ bounds: boundsStr });
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', fieldsParam);
if (dragTravelParam) params.set('travel', dragTravelParam);
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json())
.then((json: { features: PostcodeFeature[] }) => {
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);
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json())
.then((json: ApiResponse) => {
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;
}
};
}, [activeFeature, bounds, resolution, filters, features, usePostcodeView, travelParam, buildTravelParam]);
// Fetch hexagons or postcodes when bounds/filters change
useEffect(() => {
if (!bounds) return;
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(async () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
setLoading(true);
try {
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const filtersStr = buildFilterParam();
if (usePostcodeView) {
const params = new URLSearchParams({ bounds: boundsStr });
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', viewFeature && !viewFeature.startsWith('tt_') ? viewFeature : '');
if (travelParam) {
params.set('travel', travelParam);
}
const res = await fetch(
apiUrl('postcodes', params),
authHeaders({
signal: abortControllerRef.current.signal,
})
);
if (res.status === 403) {
const errBody = await res.json();
if (errBody.error === 'license_required' && errBody.free_zone) {
setLicenseRequired(true);
setFreeZone(errBody.free_zone);
setLoading(false);
return;
}
}
assertOk(res, 'postcodes');
setLicenseRequired(false);
const json: { features: PostcodeFeature[] } = await res.json();
setPostcodeData(json.features);
setRawData([]);
} else {
const params = new URLSearchParams({
resolution: resolution.toString(),
bounds: boundsStr,
});
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', viewFeature && !viewFeature.startsWith('tt_') ? viewFeature : '');
if (travelParam) {
params.set('travel', travelParam);
}
const res = await fetch(
apiUrl('hexagons', params),
authHeaders({
signal: abortControllerRef.current.signal,
})
);
if (res.status === 403) {
const errBody = await res.json();
if (errBody.error === 'license_required' && errBody.free_zone) {
setLicenseRequired(true);
setFreeZone(errBody.free_zone);
setLoading(false);
return;
}
}
assertOk(res, 'hexagons');
setLicenseRequired(false);
const json: ApiResponse = await res.json();
setRawData(json.features);
setPostcodeData([]);
}
// 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 (!isAbortError(err)) {
logNonAbortError('Failed to fetch data', err);
setLoading(false);
}
}
}, DEBOUNCE_MS);
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView, travelParam]);
// Use drag data when it matches the current view feature, otherwise fall back to rawData
const data =
(viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ?? rawData;
const effectivePostcodeData =
(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 (!viewFeature) return null;
const isTravelTime = viewFeature.startsWith('tt_');
if (!isTravelTime) {
const meta = features.find((f) => f.name === viewFeature);
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_${viewFeature}`];
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_${viewFeature}`];
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),
];
}, [viewFeature, rawData, postcodeData, usePostcodeView, features, bounds]);
// Color range for the legend and hex coloring
const colorRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
// Travel time keys: use dataRange directly (no FeatureMeta)
if (viewFeature.startsWith('tt_')) {
return dataRange;
}
const meta = features.find((f) => f.name === viewFeature);
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;
}, [viewFeature, features, dataRange]);
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,
postcodeData: effectivePostcodeData,
resolution,
bounds,
loading,
zoom,
currentView,
usePostcodeView,
colorRange,
handleViewChange,
setInitialView,
licenseRequired,
freeZone,
};
}