264 lines
8.6 KiB
TypeScript
264 lines
8.6 KiB
TypeScript
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||
import type {
|
||
FeatureMeta,
|
||
FeatureFilters,
|
||
Bounds,
|
||
HexagonData,
|
||
PostcodeFeature,
|
||
ViewChangeParams,
|
||
ApiResponse,
|
||
} from '../types';
|
||
import { buildFilterString, apiUrl, logNonAbortError, authHeaders } from '../lib/api';
|
||
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/map-utils';
|
||
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
|
||
|
||
/** Return the p-th percentile (0–100) 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;
|
||
dragValue: [number, number] | null;
|
||
dragData: HexagonData[] | null;
|
||
travelTimeEnabled: boolean;
|
||
travelTimeDestination: [number, number] | null;
|
||
travelTimeMode: string;
|
||
}
|
||
|
||
export function useMapData({
|
||
filters,
|
||
features,
|
||
viewFeature,
|
||
activeFeature,
|
||
dragValue,
|
||
dragData,
|
||
travelTimeEnabled,
|
||
travelTimeDestination,
|
||
travelTimeMode,
|
||
}: 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 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]
|
||
);
|
||
|
||
// 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 || '');
|
||
const res = await fetch(
|
||
apiUrl('postcodes', params),
|
||
authHeaders({
|
||
signal: abortControllerRef.current.signal,
|
||
})
|
||
);
|
||
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 || '');
|
||
if (travelTimeEnabled && travelTimeDestination) {
|
||
params.set('destination', `${travelTimeDestination[0]},${travelTimeDestination[1]}`);
|
||
params.set('mode', travelTimeMode);
|
||
}
|
||
const res = await fetch(
|
||
apiUrl('hexagons', params),
|
||
authHeaders({
|
||
signal: abortControllerRef.current.signal,
|
||
})
|
||
);
|
||
const json: ApiResponse = await res.json();
|
||
setRawData(json.features || []);
|
||
setPostcodeData([]);
|
||
}
|
||
} catch (err) {
|
||
logNonAbortError('Failed to fetch data', err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, DEBOUNCE_MS);
|
||
|
||
return () => {
|
||
if (debounceRef.current) {
|
||
clearTimeout(debounceRef.current);
|
||
}
|
||
};
|
||
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView, travelTimeEnabled, travelTimeDestination, travelTimeMode]);
|
||
|
||
const data = dragData ?? rawData;
|
||
|
||
// Compute p5/p95 from visible data for the viewed feature
|
||
// Only considers hexagons/postcodes whose center falls within the viewport bounds
|
||
const dataRange = useMemo((): [number, number] | null => {
|
||
if (!viewFeature) return null;
|
||
const meta = features.find((f) => f.name === viewFeature);
|
||
if (!meta || meta.type === 'enum') return null;
|
||
|
||
if (activeFeature && !dragData) 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}`] ?? feat.properties[`min_${viewFeature}`];
|
||
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
|
||
}
|
||
} else {
|
||
if (data.length === 0) return null;
|
||
for (const item of data) {
|
||
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}`] ?? item[`min_${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, data, dragData, postcodeData, usePostcodeView, features, activeFeature, bounds]);
|
||
|
||
// Color range for the legend and hex coloring
|
||
const colorRange = useMemo((): [number, number] | null => {
|
||
if (!viewFeature) return null;
|
||
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 (activeFeature && dragValue) return dragValue;
|
||
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
|
||
return null;
|
||
}, [viewFeature, features, dataRange, activeFeature, dragValue]);
|
||
|
||
// Color range for travel time (computed from response data)
|
||
const travelTimeColorRange = useMemo((): [number, number] | null => {
|
||
if (!travelTimeEnabled || !travelTimeDestination) return null;
|
||
const vals: number[] = [];
|
||
for (const item of data) {
|
||
if (bounds) {
|
||
const { lat, lon } = item;
|
||
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
|
||
continue;
|
||
}
|
||
const val = item.travel_time;
|
||
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),
|
||
];
|
||
}, [travelTimeEnabled, travelTimeDestination, data, bounds]);
|
||
|
||
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,
|
||
rawData,
|
||
postcodeData,
|
||
resolution,
|
||
bounds,
|
||
loading,
|
||
zoom,
|
||
currentView,
|
||
usePostcodeView,
|
||
colorRange,
|
||
travelTimeColorRange,
|
||
handleViewChange,
|
||
setInitialView,
|
||
};
|
||
}
|