perfect-postcode/frontend/src/hooks/useHexagonSelection.ts
2026-03-15 17:38:26 +00:00

322 lines
10 KiB
TypeScript

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<SelectedHexagon | null>(null);
const [properties, setProperties] = useState<Property[]>([]);
const [propertiesTotal, setPropertiesTotal] = useState(0);
const [propertiesOffset, setPropertiesOffset] = useState(0);
const [loadingProperties, setLoadingProperties] = useState(false);
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
const [rightPaneTab, setRightPaneTab] = useState<'properties' | 'area'>('area');
const [selectedPostcodeGeometry, setSelectedPostcodeGeometry] = useState<PostcodeGeometry | null>(
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,
};
}