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([]); const [postcodeData, setPostcodeData] = useState([]); const [resolution, setResolution] = useState(8); const [bounds, setBounds] = useState(null); const [loading, setLoading] = useState(false); const [zoom, setZoom] = useState(10); const [currentView, setCurrentView] = useState<{ latitude: number; longitude: number; zoom: number; } | null>(null); const debounceRef = useRef | null>(null); const abortControllerRef = useRef(null); const prevBoundsRef = useRef(''); 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, }; }