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 (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; 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([]); 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 latestDataRequestKeyRef = useRef(''); const latestDragRequestKeyRef = useRef(''); const debounceRef = useRef | null>(null); const abortControllerRef = useRef(null); const prevBoundsRef = useRef(''); 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(''); // 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, }; }