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 (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; travelTimeEntries: TravelTimeEntry[]; } export function useMapData({ filters, features, viewFeature, activeFeature, travelTimeEntries, }: 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 [licenseRequired, setLicenseRequired] = useState(false); const [freeZone, setFreeZone] = useState(null); // Drag preview state const [dragHexData, setDragHexData] = useState(null); const [dragPostcodeData, setDragPostcodeData] = useState(null); const dragFeatureRef = useRef(null); const dragAbortRef = useRef(null); const activeFeatureRef = useRef(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] ); // 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, }; }