import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { cellToParent, latLngToCell } from 'h3-js'; import { trackEvent } from '../lib/analytics'; import type { FeatureMeta, FeatureFilters, HexagonData, Property, PostcodeGeometry, HexagonPropertiesResponse, HexagonStatsResponse, } from '../types'; import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api'; import { findOverlappingSelectableHexagon } from '../lib/h3-selection'; import { SMALLEST_VISIBLE_HEXAGON_RESOLUTION } from '../lib/consts'; import type { TravelTimeEntry } from './useTravelTime'; import { buildTravelParam } from '../lib/travel-params'; 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[]; hexagonData: HexagonData[]; resolution: number; usePostcodeView: boolean; travelTimeEntries: TravelTimeEntry[]; shareCode?: string; /** First transit destination — used to pick the best central_postcode for journey display. */ journeyDest?: JourneyDest | null; } export function useHexagonSelection({ filters, features, hexagonData, resolution, usePostcodeView, travelTimeEntries, shareCode, 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 [areaStatsUseFilters, setAreaStatsUseFilters] = useState(true); const [selectedPostcodeGeometry, setSelectedPostcodeGeometry] = useState( null ); const areaRequestIdRef = useRef(0); const propertiesRequestIdRef = useRef(0); const invalidateAreaRequests = useCallback(() => { areaRequestIdRef.current += 1; return areaRequestIdRef.current; }, []); const invalidatePropertyRequests = useCallback(() => { propertiesRequestIdRef.current += 1; return propertiesRequestIdRef.current; }, []); const isCurrentAreaRequest = useCallback((requestId: number) => { return areaRequestIdRef.current === requestId; }, []); const isCurrentPropertyRequest = useCallback((requestId: number) => { return propertiesRequestIdRef.current === requestId; }, []); const travelParam = useMemo(() => buildTravelParam(travelTimeEntries), [travelTimeEntries]); 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 (includeFilters && travelParam) params.set('travel', travelParam); if (shareCode) params.set('share', shareCode); 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, shareCode, travelParam] ); 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); if (includeFilters && travelParam) params.set('travel', travelParam); if (shareCode) params.set('share', shareCode); const response = await fetch(apiUrl('postcode-stats', params), authHeaders({ signal })); assertOk(response, 'postcode-stats'); return (await response.json()) as HexagonStatsResponse; }, [filters, features, shareCode, travelParam] ); const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]); const hasStatsFilters = filterStr.length > 0 || travelParam.length > 0; const journeyKey = journeyDest ? `${journeyDest.mode}:${journeyDest.slug}` : ''; const areaStatsQueryKey = useMemo( () => [ areaStatsUseFilters ? 'filtered' : 'all', areaStatsUseFilters ? filterStr : '', areaStatsUseFilters ? travelParam : '', journeyKey, shareCode ?? '', ].join('|'), [areaStatsUseFilters, filterStr, journeyKey, shareCode, travelParam] ); const fetchUnfilteredAreaCount = useCallback( async (selection: SelectedHexagon, requestId: number, signal?: AbortSignal) => { if (!hasStatsFilters) { if (isCurrentAreaRequest(requestId)) setUnfilteredAreaCount(null); return; } const stats = selection.type === 'postcode' ? await fetchPostcodeStats(selection.id, signal, false) : await fetchHexagonStats(selection.id, selection.resolution, signal, undefined, false); if (isCurrentAreaRequest(requestId)) setUnfilteredAreaCount(stats.count); }, [fetchHexagonStats, fetchPostcodeStats, hasStatsFilters, isCurrentAreaRequest] ); const refreshUnfilteredAreaCount = useCallback( ( selection: SelectedHexagon, statsCount: number, includeFilters: boolean, requestId: number, signal?: AbortSignal ) => { if (!includeFilters || !hasStatsFilters || statsCount > 0) { if (isCurrentAreaRequest(requestId)) setUnfilteredAreaCount(null); return; } fetchUnfilteredAreaCount(selection, requestId, signal).catch((error) => logNonAbortError('Failed to fetch unfiltered area count', error) ); }, [fetchUnfilteredAreaCount, hasStatsFilters, isCurrentAreaRequest] ); 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) => { const requestId = invalidatePropertyRequests(); setLoadingProperties(true); try { const params = new URLSearchParams({ h3, resolution: res.toString(), offset: offset.toString(), }); const filterStr = buildFilterString(filters, features); if (filterStr) params.append('filters', filterStr); if (travelParam) params.set('travel', travelParam); if (shareCode) params.set('share', shareCode); const response = await fetch(apiUrl('hexagon-properties', params), authHeaders()); assertOk(response, 'hexagon-properties'); const data: HexagonPropertiesResponse = await response.json(); if (!isCurrentPropertyRequest(requestId)) return; 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 { if (isCurrentPropertyRequest(requestId)) setLoadingProperties(false); } }, [ filters, features, invalidatePropertyRequests, isCurrentPropertyRequest, shareCode, travelParam, ] ); const fetchPostcodeProperties = useCallback( async (postcode: string, offset = 0, focusAddress?: string) => { const requestId = invalidatePropertyRequests(); setLoadingProperties(true); try { const params = new URLSearchParams({ postcode, offset: offset.toString(), }); if (focusAddress && offset === 0) { params.set('focus_address', focusAddress); } const filterStr = buildFilterString(filters, features); if (filterStr) params.append('filters', filterStr); if (travelParam) params.set('travel', travelParam); if (shareCode) params.set('share', shareCode); const response = await fetch(apiUrl('postcode-properties', params), authHeaders()); assertOk(response, 'postcode-properties'); const data: HexagonPropertiesResponse = await response.json(); if (!isCurrentPropertyRequest(requestId)) return; 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 { if (isCurrentPropertyRequest(requestId)) setLoadingProperties(false); } }, [ filters, features, invalidatePropertyRequests, isCurrentPropertyRequest, shareCode, travelParam, ] ); const handleHexagonClick = useCallback( (id: string, isPostcode = false, geometry?: PostcodeGeometry) => { if (selectedHexagon?.id === id) { invalidateAreaRequests(); invalidatePropertyRequests(); setSelectedHexagon(null); setProperties([]); setAreaStats(null); setUnfilteredAreaCount(null); setSelectedPostcodeGeometry(null); } else { const type: SelectedHexagon['type'] = isPostcode ? 'postcode' : 'hexagon'; const selection = { id, type, resolution }; const requestId = invalidateAreaRequests(); invalidatePropertyRequests(); trackEvent('Hexagon Click', { type }); setSelectedHexagon(selection); setSelectedPostcodeGeometry(isPostcode && geometry ? geometry : null); setProperties([]); setPropertiesTotal(0); setPropertiesOffset(0); setAreaStats(null); setUnfilteredAreaCount(null); setRightPaneTab('area'); if (isPostcode) { setLoadingAreaStats(true); fetchPostcodeStats(id, undefined, areaStatsUseFilters) .then((stats) => { if (!isCurrentAreaRequest(requestId)) return; setAreaStats(stats); refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters, requestId); }) .catch((error) => logNonAbortError('Failed to fetch postcode stats', error)) .finally(() => { if (isCurrentAreaRequest(requestId)) setLoadingAreaStats(false); }); } else { setLoadingAreaStats(true); fetchHexagonStats(id, resolution, undefined, undefined, areaStatsUseFilters) .then((stats) => { if (!isCurrentAreaRequest(requestId)) return; setAreaStats(stats); refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters, requestId); }) .catch((error) => logNonAbortError('Failed to fetch area stats', error)) .finally(() => { if (isCurrentAreaRequest(requestId)) setLoadingAreaStats(false); }); } } }, [ selectedHexagon, resolution, areaStatsUseFilters, fetchHexagonStats, fetchPostcodeStats, invalidateAreaRequests, invalidatePropertyRequests, isCurrentAreaRequest, 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(() => { invalidateAreaRequests(); invalidatePropertyRequests(); setSelectedHexagon(null); setProperties([]); setAreaStats(null); setUnfilteredAreaCount(null); setSelectedPostcodeGeometry(null); }, [invalidateAreaRequests, invalidatePropertyRequests]); // 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' && !selection.lockedResolution) || (!usePostcodeView && selection.type === 'hexagon' && !selection.lockedResolution && selection.resolution !== resolution); if (!shouldSync) return; const zoomingIntoHexagon = !usePostcodeView && selection.type === 'hexagon' && !selection.lockedResolution && selection.resolution < resolution; const overlappingHexagon = zoomingIntoHexagon ? findOverlappingSelectableHexagon(selection.id, hexagonData, resolution) : null; if (zoomingIntoHexagon && !overlappingHexagon) return; const requestId = invalidateAreaRequests(); invalidatePropertyRequests(); 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, areaStatsUseFilters ); } 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, undefined, areaStatsUseFilters ); } else if ( !usePostcodeView && selection.type === 'hexagon' && !selection.lockedResolution && selection.resolution !== resolution ) { const nextId = resolution < selection.resolution ? cellToParent(selection.id, resolution) : overlappingHexagon?.h3; if (!nextId) return; nextSelection = { id: nextId, type: 'hexagon', resolution }; nextStats = await fetchHexagonStats( nextId, resolution, controller.signal, undefined, areaStatsUseFilters ); } else { return; } if (cancelled || !isCurrentAreaRequest(requestId) || !nextSelection || !nextStats) return; setSelectedHexagon(nextSelection); setSelectedPostcodeGeometry(nextGeometry); setAreaStats(nextStats); refreshUnfilteredAreaCount( nextSelection, nextStats.count, areaStatsUseFilters, requestId, 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 && isCurrentAreaRequest(requestId)) setLoadingAreaStats(false); }); return () => { cancelled = true; controller.abort(); }; }, [ selectedHexagon, hexagonData, resolution, usePostcodeView, areaStatsUseFilters, areaStats?.central_postcode, fetchHexagonStats, fetchPostcodeStats, fetchPostcodeLookup, fetchHexagonProperties, fetchPostcodeProperties, invalidateAreaRequests, invalidatePropertyRequests, isCurrentAreaRequest, refreshUnfilteredAreaCount, rightPaneTab, ]); // Re-fetch stats when filters or travel constraints change while an area is selected const prevAreaStatsQueryKey = useRef(areaStatsQueryKey); useEffect(() => { if (prevAreaStatsQueryKey.current === areaStatsQueryKey) return; prevAreaStatsQueryKey.current = areaStatsQueryKey; if (!selectedHexagon) return; // Clear stale properties setProperties([]); setPropertiesTotal(0); setPropertiesOffset(0); invalidatePropertyRequests(); setAreaStats(null); setUnfilteredAreaCount(null); setLoadingAreaStats(true); let cancelled = false; const requestId = invalidateAreaRequests(); const fetchStats = selectedHexagon.type === 'postcode' ? fetchPostcodeStats(selectedHexagon.id, undefined, areaStatsUseFilters) : fetchHexagonStats( selectedHexagon.id, selectedHexagon.resolution, undefined, undefined, areaStatsUseFilters ); fetchStats .then((stats) => { if (cancelled || !isCurrentAreaRequest(requestId)) return; setAreaStats(stats); refreshUnfilteredAreaCount(selectedHexagon, stats.count, areaStatsUseFilters, requestId); // Re-fetch properties if the properties tab is active and the filtered area still has matches. if (areaStatsUseFilters && rightPaneTab === 'properties' && stats.count > 0) { if (selectedHexagon.type === 'postcode') { fetchPostcodeProperties(selectedHexagon.id, 0); } else { fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0); } } }) .catch((error) => { if (cancelled || !isCurrentAreaRequest(requestId)) return; logNonAbortError('Failed to refresh stats', error); }) .finally(() => { if (!cancelled && isCurrentAreaRequest(requestId)) setLoadingAreaStats(false); }); return () => { cancelled = true; }; }, [ areaStatsQueryKey, selectedHexagon, fetchHexagonStats, fetchPostcodeStats, areaStatsUseFilters, rightPaneTab, fetchHexagonProperties, fetchPostcodeProperties, invalidateAreaRequests, invalidatePropertyRequests, isCurrentAreaRequest, refreshUnfilteredAreaCount, ]); const handleLocationSearch = useCallback( ( postcode: string, geometry: PostcodeGeometry, _lat?: number, _lng?: number, openProperties = false, focusAddress?: string ) => { const requestId = invalidateAreaRequests(); invalidatePropertyRequests(); const selection = { id: postcode, type: 'postcode' as const, resolution, lockedResolution: true, }; trackEvent(openProperties ? 'Address Search' : 'Postcode Search'); setSelectedHexagon(selection); setSelectedPostcodeGeometry(geometry); setProperties([]); setPropertiesTotal(0); setPropertiesOffset(0); setAreaStats(null); setUnfilteredAreaCount(null); setRightPaneTab(openProperties ? 'properties' : 'area'); setLoadingAreaStats(true); fetchPostcodeStats(postcode, undefined, areaStatsUseFilters) .then((stats) => { if (!isCurrentAreaRequest(requestId)) return; setAreaStats(stats); refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters, requestId); if (openProperties && stats.count > 0) { fetchPostcodeProperties(postcode, 0, focusAddress); } }) .catch((error) => logNonAbortError('Failed to fetch postcode stats', error)) .finally(() => { if (isCurrentAreaRequest(requestId)) setLoadingAreaStats(false); }); }, [ resolution, areaStatsUseFilters, fetchPostcodeStats, fetchPostcodeProperties, invalidateAreaRequests, invalidatePropertyRequests, isCurrentAreaRequest, refreshUnfilteredAreaCount, ] ); const handleCurrentLocationSearch = useCallback( (lat: number, lng: number) => { const requestId = invalidateAreaRequests(); invalidatePropertyRequests(); const h3 = latLngToCell(lat, lng, SMALLEST_VISIBLE_HEXAGON_RESOLUTION); const selection = { id: h3, type: 'hexagon' as const, resolution: SMALLEST_VISIBLE_HEXAGON_RESOLUTION, lockedResolution: true, }; trackEvent('Current Location Search'); setSelectedHexagon(selection); setSelectedPostcodeGeometry(null); setProperties([]); setPropertiesTotal(0); setPropertiesOffset(0); setAreaStats(null); setUnfilteredAreaCount(null); setRightPaneTab('area'); setLoadingAreaStats(true); fetchHexagonStats( h3, SMALLEST_VISIBLE_HEXAGON_RESOLUTION, undefined, undefined, areaStatsUseFilters ) .then((stats) => { if (!isCurrentAreaRequest(requestId)) return; setAreaStats(stats); refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters, requestId); }) .catch((error) => logNonAbortError('Failed to fetch current location hex stats', error)) .finally(() => { if (isCurrentAreaRequest(requestId)) setLoadingAreaStats(false); }); }, [ areaStatsUseFilters, fetchHexagonStats, invalidateAreaRequests, invalidatePropertyRequests, isCurrentAreaRequest, refreshUnfilteredAreaCount, ] ); return { selectedHexagon, properties, propertiesTotal, loadingProperties, areaStats, loadingAreaStats, unfilteredAreaCount, areaStatsUseFilters, setAreaStatsUseFilters, hoveredHexagon, rightPaneTab, setRightPaneTab, handleHexagonClick, handleHexagonHover, handleViewPropertiesFromArea, handlePropertiesTabClick, handleLoadMoreProperties, handleCloseSelection, selectedPostcodeGeometry, handleLocationSearch, handleCurrentLocationSearch, }; }