748 lines
25 KiB
TypeScript
748 lines
25 KiB
TypeScript
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<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 [unfilteredAreaCount, setUnfilteredAreaCount] = useState<number | null>(null);
|
|
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
|
|
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
|
|
const [rightPaneTab, setRightPaneTab] = useState<'properties' | 'area'>('area');
|
|
const [areaStatsUseFilters, setAreaStatsUseFilters] = useState(true);
|
|
const [selectedPostcodeGeometry, setSelectedPostcodeGeometry] = useState<PostcodeGeometry | null>(
|
|
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,
|
|
};
|
|
}
|