import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { cellToLatLng, cellToParent, latLngToCell } from 'h3-js'; import { trackEvent } from '../lib/analytics'; import type { FeatureMeta, FeatureFilters, Property, PostcodeGeometry, HexagonPropertiesResponse, HexagonStatsResponse, } from '../types'; import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api'; const CURRENT_LOCATION_HEX_RESOLUTION = 12; interface SelectedHexagon { id: string; type: 'hexagon' | 'postcode'; resolution: number; lockedResolution?: boolean; } interface JourneyDest { mode: string; slug: string; } interface PostcodeLookupResponse { postcode: string; latitude: number; longitude: number; geometry: PostcodeGeometry; } interface UseHexagonSelectionOptions { filters: FeatureFilters; features: FeatureMeta[]; resolution: number; usePostcodeView: boolean; /** First transit destination — used to pick the best central_postcode for journey display. */ journeyDest?: JourneyDest | null; } export function useHexagonSelection({ filters, features, resolution, usePostcodeView, journeyDest, }: UseHexagonSelectionOptions) { const [selectedHexagon, setSelectedHexagon] = useState(null); const [properties, setProperties] = useState([]); const [propertiesTotal, setPropertiesTotal] = useState(0); const [propertiesOffset, setPropertiesOffset] = useState(0); const [loadingProperties, setLoadingProperties] = useState(false); const [areaStats, setAreaStats] = useState(null); const [unfilteredAreaCount, setUnfilteredAreaCount] = useState(null); const [loadingAreaStats, setLoadingAreaStats] = useState(false); const [hoveredHexagon, setHoveredHexagon] = useState(null); const [rightPaneTab, setRightPaneTab] = useState<'properties' | 'area'>('area'); const [selectedPostcodeGeometry, setSelectedPostcodeGeometry] = useState( null ); const fetchHexagonStats = useCallback( async ( h3: string, res: number, signal?: AbortSignal, fields?: string[], includeFilters = true ) => { const params = new URLSearchParams({ h3, resolution: res.toString(), }); const filterStr = includeFilters ? buildFilterString(filters, features) : ''; if (filterStr) params.append('filters', filterStr); if (fields) { params.set('fields', fields.join(';;')); } if (journeyDest) { params.set('journey_mode', journeyDest.mode); params.set('journey_slug', journeyDest.slug); } const response = await fetch(apiUrl('hexagon-stats', params), authHeaders({ signal })); assertOk(response, 'hexagon-stats'); return (await response.json()) as HexagonStatsResponse; }, [filters, features, journeyDest] ); const fetchPostcodeStats = useCallback( async (postcode: string, signal?: AbortSignal, includeFilters = true) => { const params = new URLSearchParams({ postcode }); const filterStr = includeFilters ? buildFilterString(filters, features) : ''; if (filterStr) params.append('filters', filterStr); const response = await fetch(apiUrl('postcode-stats', params), authHeaders({ signal })); assertOk(response, 'postcode-stats'); return (await response.json()) as HexagonStatsResponse; }, [filters, features] ); const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]); const fetchUnfilteredAreaCount = useCallback( async (selection: SelectedHexagon, signal?: AbortSignal) => { if (!filterStr) { setUnfilteredAreaCount(null); return; } const stats = selection.type === 'postcode' ? await fetchPostcodeStats(selection.id, signal, false) : await fetchHexagonStats(selection.id, selection.resolution, signal, undefined, false); setUnfilteredAreaCount(stats.count); }, [filterStr, fetchHexagonStats, fetchPostcodeStats] ); const refreshUnfilteredAreaCount = useCallback( (selection: SelectedHexagon, filteredCount: number, signal?: AbortSignal) => { if (!filterStr || filteredCount > 0) { setUnfilteredAreaCount(null); return; } fetchUnfilteredAreaCount(selection, signal).catch((error) => logNonAbortError('Failed to fetch unfiltered area count', error) ); }, [filterStr, fetchUnfilteredAreaCount] ); const fetchPostcodeLookup = useCallback(async (postcode: string, signal?: AbortSignal) => { const response = await fetch( `/api/postcode/${encodeURIComponent(postcode)}`, authHeaders({ signal }) ); assertOk(response, 'postcode lookup'); return (await response.json()) as PostcodeLookupResponse; }, []); const fetchHexagonProperties = useCallback( async (h3: string, res: number, offset = 0) => { setLoadingProperties(true); try { const params = new URLSearchParams({ h3, resolution: res.toString(), limit: '100', offset: offset.toString(), }); const filterStr = buildFilterString(filters, features); if (filterStr) params.append('filters', filterStr); const response = await fetch(apiUrl('hexagon-properties', params), authHeaders()); assertOk(response, 'hexagon-properties'); const data: HexagonPropertiesResponse = await response.json(); if (offset === 0) { setProperties(data.properties); } else { setProperties((prev) => [...prev, ...data.properties]); } setPropertiesTotal(data.total); setPropertiesOffset(offset + data.properties.length); } catch (err) { logNonAbortError('Failed to fetch properties', err); } finally { setLoadingProperties(false); } }, [filters, features] ); const fetchPostcodeProperties = useCallback( async (postcode: string, offset = 0) => { setLoadingProperties(true); try { const params = new URLSearchParams({ postcode, limit: '100', offset: offset.toString(), }); const filterStr = buildFilterString(filters, features); if (filterStr) params.append('filters', filterStr); const response = await fetch(apiUrl('postcode-properties', params), authHeaders()); assertOk(response, 'postcode-properties'); const data: HexagonPropertiesResponse = await response.json(); if (offset === 0) { setProperties(data.properties); } else { setProperties((prev) => [...prev, ...data.properties]); } setPropertiesTotal(data.total); setPropertiesOffset(offset + data.properties.length); } catch (err) { logNonAbortError('Failed to fetch postcode properties', err); } finally { setLoadingProperties(false); } }, [filters, features] ); const handleHexagonClick = useCallback( (id: string, isPostcode = false, geometry?: PostcodeGeometry) => { if (selectedHexagon?.id === id) { setSelectedHexagon(null); setProperties([]); setAreaStats(null); setUnfilteredAreaCount(null); setSelectedPostcodeGeometry(null); } else { const type: SelectedHexagon['type'] = isPostcode ? 'postcode' : 'hexagon'; const selection = { id, type, resolution }; trackEvent('Hexagon Click', { type }); setSelectedHexagon(selection); setSelectedPostcodeGeometry(isPostcode && geometry ? geometry : null); setProperties([]); setPropertiesTotal(0); setPropertiesOffset(0); setUnfilteredAreaCount(null); setRightPaneTab('area'); if (isPostcode) { setLoadingAreaStats(true); fetchPostcodeStats(id) .then((stats) => { setAreaStats(stats); refreshUnfilteredAreaCount(selection, stats.count); }) .catch((error) => logNonAbortError('Failed to fetch postcode stats', error)) .finally(() => setLoadingAreaStats(false)); } else { setLoadingAreaStats(true); fetchHexagonStats(id, resolution) .then((stats) => { setAreaStats(stats); refreshUnfilteredAreaCount(selection, stats.count); }) .catch((error) => logNonAbortError('Failed to fetch area stats', error)) .finally(() => setLoadingAreaStats(false)); } } }, [selectedHexagon, resolution, fetchHexagonStats, fetchPostcodeStats, refreshUnfilteredAreaCount] ); const handleHexagonHover = useCallback((h3: string | null) => { setHoveredHexagon(h3); }, []); const handleViewPropertiesFromArea = useCallback(() => { if (!selectedHexagon) return; trackEvent('View Properties'); setRightPaneTab('properties'); setPropertiesOffset(0); if (selectedHexagon.type === 'postcode') { fetchPostcodeProperties(selectedHexagon.id, 0); } else { fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0); } }, [selectedHexagon, fetchHexagonProperties, fetchPostcodeProperties]); const handlePropertiesTabClick = useCallback(() => { setRightPaneTab('properties'); if (selectedHexagon && properties.length === 0 && !loadingProperties) { setPropertiesOffset(0); if (selectedHexagon.type === 'postcode') { fetchPostcodeProperties(selectedHexagon.id, 0); } else { fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0); } } }, [ selectedHexagon, properties.length, loadingProperties, fetchHexagonProperties, fetchPostcodeProperties, ]); const handleLoadMoreProperties = useCallback(() => { if (!selectedHexagon) return; if (selectedHexagon.type === 'postcode') { fetchPostcodeProperties(selectedHexagon.id, propertiesOffset); } else { fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, propertiesOffset); } }, [selectedHexagon, propertiesOffset, fetchHexagonProperties, fetchPostcodeProperties]); const handleCloseSelection = useCallback(() => { setSelectedHexagon(null); setProperties([]); setAreaStats(null); setUnfilteredAreaCount(null); setSelectedPostcodeGeometry(null); }, []); // Keep the selected area aligned with the active map view as zoom changes. useEffect(() => { if (!selectedHexagon) return; const selection = selectedHexagon; const shouldSync = (usePostcodeView && selection.type === 'hexagon' && !selection.lockedResolution && areaStats?.central_postcode != null) || (!usePostcodeView && selection.type === 'postcode') || (!usePostcodeView && selection.type === 'hexagon' && !selection.lockedResolution && selection.resolution !== resolution); if (!shouldSync) return; let cancelled = false; const controller = new AbortController(); const refreshProperties = (selection: SelectedHexagon) => { if (rightPaneTab !== 'properties') return; if (selection.type === 'postcode') { fetchPostcodeProperties(selection.id, 0); } else { fetchHexagonProperties(selection.id, selection.resolution, 0); } }; async function syncSelection() { let nextSelection: SelectedHexagon | null = null; let nextGeometry: PostcodeGeometry | null = null; let nextStats: HexagonStatsResponse | null = null; if (usePostcodeView && selection.type === 'hexagon' && !selection.lockedResolution) { if (!areaStats?.central_postcode) return; const lookup = await fetchPostcodeLookup(areaStats.central_postcode, controller.signal); nextSelection = { id: lookup.postcode, type: 'postcode', resolution }; nextGeometry = lookup.geometry; nextStats = await fetchPostcodeStats(lookup.postcode, controller.signal); } else if (!usePostcodeView && selection.type === 'postcode') { const lookup = await fetchPostcodeLookup(selection.id, controller.signal); const nextId = latLngToCell(lookup.latitude, lookup.longitude, resolution); nextSelection = { id: nextId, type: 'hexagon', resolution }; nextStats = await fetchHexagonStats(nextId, resolution, controller.signal); } else if ( !usePostcodeView && selection.type === 'hexagon' && !selection.lockedResolution && selection.resolution !== resolution ) { const nextId = resolution < selection.resolution ? cellToParent(selection.id, resolution) : latLngToCell(...cellToLatLng(selection.id), resolution); nextSelection = { id: nextId, type: 'hexagon', resolution }; nextStats = await fetchHexagonStats(nextId, resolution, controller.signal); } else { return; } if (cancelled || !nextSelection || !nextStats) return; setSelectedHexagon(nextSelection); setSelectedPostcodeGeometry(nextGeometry); setAreaStats(nextStats); refreshUnfilteredAreaCount(nextSelection, nextStats.count, controller.signal); refreshProperties(nextSelection); } setProperties([]); setPropertiesTotal(0); setPropertiesOffset(0); setUnfilteredAreaCount(null); setLoadingAreaStats(true); syncSelection() .catch((error) => { if (!cancelled) logNonAbortError('Failed to sync selected area with map view', error); }) .finally(() => { if (!cancelled) setLoadingAreaStats(false); }); return () => { cancelled = true; controller.abort(); }; }, [ selectedHexagon, resolution, usePostcodeView, areaStats?.central_postcode, fetchHexagonStats, fetchPostcodeStats, fetchPostcodeLookup, fetchHexagonProperties, fetchPostcodeProperties, refreshUnfilteredAreaCount, rightPaneTab, ]); // Re-fetch stats when filters change while a hexagon is selected const prevFilterStr = useRef(filterStr); useEffect(() => { if (prevFilterStr.current === filterStr) return; prevFilterStr.current = filterStr; if (!selectedHexagon) return; // Clear stale properties setProperties([]); setPropertiesTotal(0); setPropertiesOffset(0); setLoadingAreaStats(true); let cancelled = false; const fetchStats = selectedHexagon.type === 'postcode' ? fetchPostcodeStats(selectedHexagon.id) : fetchHexagonStats(selectedHexagon.id, selectedHexagon.resolution); fetchStats .then((stats) => { if (cancelled) return; setAreaStats(stats); refreshUnfilteredAreaCount(selectedHexagon, stats.count); // Re-fetch properties if the properties tab is active and the filtered area still has matches. if (rightPaneTab === 'properties' && stats.count > 0) { if (selectedHexagon.type === 'postcode') { fetchPostcodeProperties(selectedHexagon.id, 0); } else { fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0); } } }) .catch((error) => { if (cancelled) return; logNonAbortError('Failed to refresh stats', error); }) .finally(() => { if (!cancelled) setLoadingAreaStats(false); }); return () => { cancelled = true; }; }, [ filterStr, selectedHexagon, fetchHexagonStats, fetchPostcodeStats, rightPaneTab, fetchHexagonProperties, fetchPostcodeProperties, refreshUnfilteredAreaCount, ]); const handleLocationSearch = useCallback( (postcode: string, geometry: PostcodeGeometry, lat?: number, lng?: number) => { trackEvent('Postcode Search'); setProperties([]); setPropertiesTotal(0); setPropertiesOffset(0); setUnfilteredAreaCount(null); setRightPaneTab('area'); setLoadingAreaStats(true); // First try the postcode; if it has no properties, fall back to hexagons fetchPostcodeStats(postcode) .then(async (stats) => { if (stats.count > 0) { const selection = { id: postcode, type: 'postcode' as const, resolution }; setSelectedHexagon(selection); setSelectedPostcodeGeometry(geometry); setAreaStats(stats); refreshUnfilteredAreaCount(selection, stats.count); return; } // No properties in this postcode — fall back to hexagons if (lat == null || lng == null) { // No coordinates available, show empty postcode anyway const selection = { id: postcode, type: 'postcode' as const, resolution }; setSelectedHexagon(selection); setSelectedPostcodeGeometry(geometry); setAreaStats(stats); refreshUnfilteredAreaCount(selection, stats.count); return; } // Try progressively coarser H3 resolutions until we find >1 property const resolutions = [9, 8, 7, 6, 5]; for (const res of resolutions) { const h3 = latLngToCell(lat, lng, res); const hexStats = await fetchHexagonStats(h3, res); if (hexStats.count > 1) { const selection = { id: h3, type: 'hexagon' as const, resolution: res }; setSelectedHexagon(selection); setSelectedPostcodeGeometry(null); setAreaStats(hexStats); refreshUnfilteredAreaCount(selection, hexStats.count); return; } } // Even the coarsest hexagon has ≤1 property — show whatever the finest has const h3 = latLngToCell(lat, lng, 9); const fallbackStats = await fetchHexagonStats(h3, 9); const selection = { id: h3, type: 'hexagon' as const, resolution: 9 }; setSelectedHexagon(selection); setSelectedPostcodeGeometry(null); setAreaStats(fallbackStats); refreshUnfilteredAreaCount(selection, fallbackStats.count); }) .catch((error) => logNonAbortError('Failed to fetch postcode stats', error)) .finally(() => setLoadingAreaStats(false)); }, [resolution, fetchPostcodeStats, fetchHexagonStats, refreshUnfilteredAreaCount] ); const handleCurrentLocationSearch = useCallback( (lat: number, lng: number) => { const h3 = latLngToCell(lat, lng, CURRENT_LOCATION_HEX_RESOLUTION); const selection = { id: h3, type: 'hexagon' as const, resolution: CURRENT_LOCATION_HEX_RESOLUTION, lockedResolution: true, }; trackEvent('Current Location Search'); setSelectedHexagon(selection); setSelectedPostcodeGeometry(null); setProperties([]); setPropertiesTotal(0); setPropertiesOffset(0); setUnfilteredAreaCount(null); setRightPaneTab('area'); setLoadingAreaStats(true); fetchHexagonStats(h3, CURRENT_LOCATION_HEX_RESOLUTION) .then((stats) => { setAreaStats(stats); refreshUnfilteredAreaCount(selection, stats.count); }) .catch((error) => logNonAbortError('Failed to fetch current location hex stats', error)) .finally(() => setLoadingAreaStats(false)); }, [fetchHexagonStats, refreshUnfilteredAreaCount] ); return { selectedHexagon, properties, propertiesTotal, loadingProperties, areaStats, loadingAreaStats, unfilteredAreaCount, hoveredHexagon, rightPaneTab, setRightPaneTab, handleHexagonClick, handleHexagonHover, handleViewPropertiesFromArea, handlePropertiesTabClick, handleLoadMoreProperties, handleCloseSelection, selectedPostcodeGeometry, handleLocationSearch, handleCurrentLocationSearch, }; }