import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { trackEvent } from '../lib/analytics'; import type { FeatureMeta, FeatureFilters, Property, PostcodeGeometry, HexagonPropertiesResponse, HexagonStatsResponse, } from '../types'; import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api'; interface SelectedHexagon { id: string; type: 'hexagon' | 'postcode'; resolution: number; } interface JourneyDest { mode: string; slug: string; } interface UseHexagonSelectionOptions { filters: FeatureFilters; features: FeatureMeta[]; resolution: number; /** First transit destination — used to pick the best central_postcode for journey display. */ journeyDest?: JourneyDest | null; } export function useHexagonSelection({ filters, features, resolution, 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 [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[]) => { const params = new URLSearchParams({ h3, resolution: res.toString(), }); const filterStr = 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) => { const params = new URLSearchParams({ postcode }); const filterStr = 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 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); setSelectedPostcodeGeometry(null); } else { const type = isPostcode ? 'postcode' : 'hexagon'; trackEvent('Hexagon Click', { type }); setSelectedHexagon({ id, type, resolution }); setSelectedPostcodeGeometry(isPostcode && geometry ? geometry : null); setProperties([]); setPropertiesTotal(0); setPropertiesOffset(0); setRightPaneTab('area'); if (isPostcode) { setLoadingAreaStats(true); fetchPostcodeStats(id) .then((stats) => setAreaStats(stats)) .catch((error) => logNonAbortError('Failed to fetch postcode stats', error)) .finally(() => setLoadingAreaStats(false)); } else { setLoadingAreaStats(true); fetchHexagonStats(id, resolution) .then((stats) => setAreaStats(stats)) .catch((error) => logNonAbortError('Failed to fetch area stats', error)) .finally(() => setLoadingAreaStats(false)); } } }, [selectedHexagon, resolution, fetchHexagonStats, fetchPostcodeStats] ); 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); setSelectedPostcodeGeometry(null); }, []); // Re-fetch stats when filters change while a hexagon is selected const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]); 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; if (stats.count === 0) { setSelectedHexagon(null); setAreaStats(null); setSelectedPostcodeGeometry(null); } else { setAreaStats(stats); } }) .catch((error) => { if (cancelled) return; logNonAbortError('Failed to refresh stats', error); }) .finally(() => { if (!cancelled) setLoadingAreaStats(false); }); return () => { cancelled = true; }; }, [filterStr, selectedHexagon, fetchHexagonStats, fetchPostcodeStats]); const handleLocationSearch = useCallback( (postcode: string, geometry: PostcodeGeometry) => { trackEvent('Postcode Search'); setSelectedHexagon({ id: postcode, type: 'postcode', resolution }); setSelectedPostcodeGeometry(geometry); setProperties([]); setPropertiesTotal(0); setPropertiesOffset(0); setRightPaneTab('area'); setLoadingAreaStats(true); fetchPostcodeStats(postcode) .then((stats) => setAreaStats(stats)) .catch((error) => logNonAbortError('Failed to fetch postcode stats', error)) .finally(() => setLoadingAreaStats(false)); }, [resolution, fetchPostcodeStats] ); return { selectedHexagon, properties, propertiesTotal, loadingProperties, areaStats, loadingAreaStats, hoveredHexagon, rightPaneTab, setRightPaneTab, handleHexagonClick, handleHexagonHover, handleViewPropertiesFromArea, handlePropertiesTabClick, handleLoadMoreProperties, handleCloseSelection, selectedPostcodeGeometry, handleLocationSearch, }; }