Tonight
This commit is contained in:
parent
28323f145e
commit
94f9c0d594
76 changed files with 3238 additions and 1230 deletions
|
|
@ -366,7 +366,7 @@ export function useDeckLayers({
|
|||
},
|
||||
lineWidthUnits: 'pixels',
|
||||
updateTriggers: {
|
||||
getFillColor: [colorTrigger],
|
||||
getFillColor: [colorTrigger, data],
|
||||
getLineColor: [colorTrigger],
|
||||
getLineWidth: [colorTrigger],
|
||||
...(pieProps.updateTriggers || {}),
|
||||
|
|
|
|||
|
|
@ -5,19 +5,73 @@ import {
|
|||
SCHOOL_FILTER_NAME,
|
||||
createSchoolFilterKey,
|
||||
getDefaultSchoolFeatureName,
|
||||
getSchoolBackendFeatureName,
|
||||
getSchoolFilterKeyId,
|
||||
normalizeSchoolFilters,
|
||||
} from '../lib/school-filter';
|
||||
import {
|
||||
SPECIFIC_CRIMES_FILTER_NAME,
|
||||
createSpecificCrimeFilterKey,
|
||||
getDefaultSpecificCrimeFeatureName,
|
||||
getSpecificCrimeFeatureName,
|
||||
getSpecificCrimeFilterKeyId,
|
||||
normalizeSpecificCrimeFilters,
|
||||
} from '../lib/crime-filter';
|
||||
|
||||
interface UseFiltersOptions {
|
||||
initialFilters: FeatureFilters;
|
||||
features: FeatureMeta[];
|
||||
}
|
||||
|
||||
function normalizeFilters(filters: FeatureFilters): FeatureFilters {
|
||||
return normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters));
|
||||
}
|
||||
|
||||
function getBackendFeatureName(name: string): string {
|
||||
return getSchoolBackendFeatureName(name) ?? getSpecificCrimeFeatureName(name) ?? name;
|
||||
}
|
||||
|
||||
function dropUnknownFilters(filters: FeatureFilters, features: FeatureMeta[]): FeatureFilters {
|
||||
if (features.length === 0) return filters;
|
||||
|
||||
const knownFeatures = new Set(features.map((feature) => feature.name));
|
||||
let changed = false;
|
||||
const next: FeatureFilters = {};
|
||||
|
||||
for (const [name, value] of Object.entries(filters)) {
|
||||
if (knownFeatures.has(getBackendFeatureName(name))) {
|
||||
next[name] = value;
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? next : filters;
|
||||
}
|
||||
|
||||
function getNextNumericKeyId(
|
||||
filters: FeatureFilters,
|
||||
getId: (name: string) => string | null
|
||||
): number {
|
||||
let max = -1;
|
||||
for (const name of Object.keys(filters)) {
|
||||
const id = getId(name);
|
||||
if (id == null) continue;
|
||||
const numeric = Number(id);
|
||||
if (Number.isInteger(numeric) && numeric >= 0) {
|
||||
max = Math.max(max, numeric);
|
||||
}
|
||||
}
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||
const [filters, setFilters] = useState<FeatureFilters>(() =>
|
||||
normalizeSchoolFilters(initialFilters)
|
||||
);
|
||||
const initialFiltersRef = useRef<FeatureFilters | null>(null);
|
||||
if (!initialFiltersRef.current) {
|
||||
initialFiltersRef.current = normalizeFilters(initialFilters);
|
||||
}
|
||||
|
||||
const [filters, setFilters] = useState<FeatureFilters>(() => initialFiltersRef.current!);
|
||||
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
||||
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
|
||||
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
|
||||
|
|
@ -25,7 +79,12 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
const dragActiveRef = useRef<string | null>(null);
|
||||
const dragValueRef = useRef<[number, number] | null>(null);
|
||||
const undoStackRef = useRef<FeatureFilters[]>([]);
|
||||
const schoolFilterIdRef = useRef(1);
|
||||
const schoolFilterIdRef = useRef(
|
||||
getNextNumericKeyId(initialFiltersRef.current!, getSchoolFilterKeyId)
|
||||
);
|
||||
const specificCrimeFilterIdRef = useRef(
|
||||
getNextNumericKeyId(initialFiltersRef.current!, getSpecificCrimeFilterKeyId)
|
||||
);
|
||||
|
||||
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
|
||||
|
||||
|
|
@ -40,10 +99,25 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
return null;
|
||||
}, [viewFeature, activeFeature, dragValue, filters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (features.length === 0) return;
|
||||
|
||||
const knownFeatures = new Set(features.map((feature) => feature.name));
|
||||
setFilters((prev) => dropUnknownFilters(prev, features));
|
||||
setPinnedFeature((prev) => {
|
||||
if (!prev) return prev;
|
||||
return knownFeatures.has(getBackendFeatureName(prev)) ? prev : null;
|
||||
});
|
||||
setActiveFeature((prev) => {
|
||||
if (!prev) return prev;
|
||||
return knownFeatures.has(getBackendFeatureName(prev)) ? prev : null;
|
||||
});
|
||||
}, [features]);
|
||||
|
||||
const handleAddFilter = useCallback(
|
||||
(name: string) => {
|
||||
const meta = features.find((f) => f.name === name);
|
||||
if (name !== SCHOOL_FILTER_NAME && !meta) return;
|
||||
if (name !== SCHOOL_FILTER_NAME && name !== SPECIFIC_CRIMES_FILTER_NAME && !meta) return;
|
||||
trackEvent('Filter Add', { feature: name });
|
||||
setFilters((prev) => {
|
||||
undoStackRef.current.push(prev);
|
||||
|
|
@ -67,6 +141,24 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
],
|
||||
};
|
||||
}
|
||||
if (name === SPECIFIC_CRIMES_FILTER_NAME) {
|
||||
const defaultCrimeFeatureName = getDefaultSpecificCrimeFeatureName(features);
|
||||
const defaultCrimeFeature = defaultCrimeFeatureName
|
||||
? features.find((feature) => feature.name === defaultCrimeFeatureName)
|
||||
: undefined;
|
||||
if (!defaultCrimeFeatureName) return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[createSpecificCrimeFilterKey(
|
||||
defaultCrimeFeatureName,
|
||||
specificCrimeFilterIdRef.current++
|
||||
)]: [
|
||||
defaultCrimeFeature?.histogram?.min ?? defaultCrimeFeature?.min ?? 0,
|
||||
defaultCrimeFeature?.histogram?.max ?? defaultCrimeFeature?.max ?? 100,
|
||||
],
|
||||
};
|
||||
}
|
||||
if (!meta) return prev;
|
||||
if (meta.type === 'enum' && meta.values) {
|
||||
return { ...prev, [name]: [...meta.values!] };
|
||||
|
|
@ -105,7 +197,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
if (Array.isArray(value) && value.length === 0) {
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
return normalizeSchoolFilters(next);
|
||||
return normalizeFilters(next);
|
||||
}
|
||||
|
||||
const schoolKeyId = getSchoolFilterKeyId(name);
|
||||
|
|
@ -122,10 +214,27 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
}
|
||||
next[existingName] = existingValue;
|
||||
}
|
||||
if (replaced) return normalizeSchoolFilters(next);
|
||||
if (replaced) return normalizeFilters(next);
|
||||
}
|
||||
|
||||
return normalizeSchoolFilters({ ...prev, [name]: value });
|
||||
const specificCrimeKeyId = getSpecificCrimeFilterKeyId(name);
|
||||
if (specificCrimeKeyId != null) {
|
||||
let replaced = false;
|
||||
const next: FeatureFilters = {};
|
||||
for (const [existingName, existingValue] of Object.entries(prev)) {
|
||||
if (getSpecificCrimeFilterKeyId(existingName) === specificCrimeKeyId) {
|
||||
if (!replaced) {
|
||||
next[name] = value;
|
||||
replaced = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
next[existingName] = existingValue;
|
||||
}
|
||||
if (replaced) return normalizeFilters(next);
|
||||
}
|
||||
|
||||
return normalizeFilters({ ...prev, [name]: value });
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
|
@ -169,7 +278,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
const af = dragActiveRef.current;
|
||||
const dv = dragValueRef.current;
|
||||
if (af && dv) {
|
||||
setFilters((prev) => ({ ...prev, [af]: dv }));
|
||||
setFilters((prev) => normalizeFilters({ ...prev, [af]: dv }));
|
||||
}
|
||||
setActiveFeature(null);
|
||||
setDragValue(null);
|
||||
|
|
@ -193,7 +302,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
}, []);
|
||||
|
||||
const handleSetFilters = useCallback((newFilters: FeatureFilters) => {
|
||||
setFilters(normalizeSchoolFilters(newFilters));
|
||||
setFilters(normalizeFilters(newFilters));
|
||||
setActiveFeature(null);
|
||||
setDragValue(null);
|
||||
setPinnedFeature(null);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { cellToLatLng, cellToParent, latLngToCell } from 'h3-js';
|
||||
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';
|
||||
|
||||
const CURRENT_LOCATION_HEX_RESOLUTION = 12;
|
||||
import { findOverlappingMatchingHexagon } from '../lib/h3-selection';
|
||||
import { SMALLEST_VISIBLE_HEXAGON_RESOLUTION } from '../lib/consts';
|
||||
import type { TravelTimeEntry } from './useTravelTime';
|
||||
|
||||
interface SelectedHexagon {
|
||||
id: string;
|
||||
|
|
@ -35,8 +37,11 @@ interface PostcodeLookupResponse {
|
|||
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;
|
||||
}
|
||||
|
|
@ -44,8 +49,11 @@ interface UseHexagonSelectionOptions {
|
|||
export function useHexagonSelection({
|
||||
filters,
|
||||
features,
|
||||
hexagonData,
|
||||
resolution,
|
||||
usePostcodeView,
|
||||
travelTimeEntries,
|
||||
shareCode,
|
||||
journeyDest,
|
||||
}: UseHexagonSelectionOptions) {
|
||||
const [selectedHexagon, setSelectedHexagon] = useState<SelectedHexagon | null>(null);
|
||||
|
|
@ -61,6 +69,40 @@ export function useHexagonSelection({
|
|||
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(() => {
|
||||
const segments: string[] = [];
|
||||
for (const entry of travelTimeEntries) {
|
||||
if (!entry.slug) continue;
|
||||
let segment = `${entry.mode}:${entry.slug}`;
|
||||
if (entry.useBest) segment += ':best';
|
||||
if (entry.timeRange) {
|
||||
segment += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
|
||||
}
|
||||
segments.push(segment);
|
||||
}
|
||||
return segments.join('|');
|
||||
}, [travelTimeEntries]);
|
||||
|
||||
const fetchHexagonStats = useCallback(
|
||||
async (
|
||||
|
|
@ -76,6 +118,8 @@ export function useHexagonSelection({
|
|||
});
|
||||
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(';;'));
|
||||
}
|
||||
|
|
@ -87,7 +131,7 @@ export function useHexagonSelection({
|
|||
assertOk(response, 'hexagon-stats');
|
||||
return (await response.json()) as HexagonStatsResponse;
|
||||
},
|
||||
[filters, features, journeyDest]
|
||||
[filters, features, journeyDest, shareCode, travelParam]
|
||||
);
|
||||
|
||||
const fetchPostcodeStats = useCallback(
|
||||
|
|
@ -95,14 +139,20 @@ export function useHexagonSelection({
|
|||
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]
|
||||
[filters, features, shareCode, travelParam]
|
||||
);
|
||||
|
||||
const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]);
|
||||
const selectionQueryKey = useMemo(
|
||||
() => [filterStr, travelParam, shareCode ?? ''].join('|'),
|
||||
[filterStr, shareCode, travelParam]
|
||||
);
|
||||
|
||||
const fetchUnfilteredAreaCount = useCallback(
|
||||
async (selection: SelectedHexagon, signal?: AbortSignal) => {
|
||||
|
|
@ -145,6 +195,7 @@ export function useHexagonSelection({
|
|||
|
||||
const fetchHexagonProperties = useCallback(
|
||||
async (h3: string, res: number, offset = 0) => {
|
||||
const requestId = invalidatePropertyRequests();
|
||||
setLoadingProperties(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
|
|
@ -156,10 +207,13 @@ export function useHexagonSelection({
|
|||
|
||||
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);
|
||||
|
|
@ -171,14 +225,22 @@ export function useHexagonSelection({
|
|||
} catch (err) {
|
||||
logNonAbortError('Failed to fetch properties', err);
|
||||
} finally {
|
||||
setLoadingProperties(false);
|
||||
if (isCurrentPropertyRequest(requestId)) setLoadingProperties(false);
|
||||
}
|
||||
},
|
||||
[filters, features]
|
||||
[
|
||||
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({
|
||||
|
|
@ -192,10 +254,13 @@ export function useHexagonSelection({
|
|||
|
||||
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);
|
||||
|
|
@ -207,15 +272,24 @@ export function useHexagonSelection({
|
|||
} catch (err) {
|
||||
logNonAbortError('Failed to fetch postcode properties', err);
|
||||
} finally {
|
||||
setLoadingProperties(false);
|
||||
if (isCurrentPropertyRequest(requestId)) setLoadingProperties(false);
|
||||
}
|
||||
},
|
||||
[filters, features]
|
||||
[
|
||||
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);
|
||||
|
|
@ -224,6 +298,8 @@ export function useHexagonSelection({
|
|||
} 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);
|
||||
|
|
@ -237,24 +313,39 @@ export function useHexagonSelection({
|
|||
setLoadingAreaStats(true);
|
||||
fetchPostcodeStats(id)
|
||||
.then((stats) => {
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
|
||||
.finally(() => setLoadingAreaStats(false));
|
||||
.finally(() => {
|
||||
if (isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
|
||||
});
|
||||
} else {
|
||||
setLoadingAreaStats(true);
|
||||
fetchHexagonStats(id, resolution)
|
||||
.then((stats) => {
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
|
||||
.finally(() => setLoadingAreaStats(false));
|
||||
.finally(() => {
|
||||
if (isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[selectedHexagon, resolution, fetchHexagonStats, fetchPostcodeStats, refreshUnfilteredAreaCount]
|
||||
[
|
||||
selectedHexagon,
|
||||
resolution,
|
||||
fetchHexagonStats,
|
||||
fetchPostcodeStats,
|
||||
invalidateAreaRequests,
|
||||
invalidatePropertyRequests,
|
||||
isCurrentAreaRequest,
|
||||
refreshUnfilteredAreaCount,
|
||||
]
|
||||
);
|
||||
|
||||
const handleHexagonHover = useCallback((h3: string | null) => {
|
||||
|
|
@ -301,12 +392,14 @@ export function useHexagonSelection({
|
|||
}, [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(() => {
|
||||
|
|
@ -324,6 +417,18 @@ export function useHexagonSelection({
|
|||
selection.resolution !== resolution);
|
||||
if (!shouldSync) return;
|
||||
|
||||
const zoomingIntoHexagon =
|
||||
!usePostcodeView &&
|
||||
selection.type === 'hexagon' &&
|
||||
!selection.lockedResolution &&
|
||||
selection.resolution < resolution;
|
||||
const overlappingHexagon = zoomingIntoHexagon
|
||||
? findOverlappingMatchingHexagon(selection.id, hexagonData, resolution)
|
||||
: null;
|
||||
if (zoomingIntoHexagon && !overlappingHexagon) return;
|
||||
|
||||
const requestId = invalidateAreaRequests();
|
||||
invalidatePropertyRequests();
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
|
|
@ -361,14 +466,15 @@ export function useHexagonSelection({
|
|||
const nextId =
|
||||
resolution < selection.resolution
|
||||
? cellToParent(selection.id, resolution)
|
||||
: latLngToCell(...cellToLatLng(selection.id), resolution);
|
||||
: overlappingHexagon?.h3;
|
||||
if (!nextId) return;
|
||||
nextSelection = { id: nextId, type: 'hexagon', resolution };
|
||||
nextStats = await fetchHexagonStats(nextId, resolution, controller.signal);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cancelled || !nextSelection || !nextStats) return;
|
||||
if (cancelled || !isCurrentAreaRequest(requestId) || !nextSelection || !nextStats) return;
|
||||
setSelectedHexagon(nextSelection);
|
||||
setSelectedPostcodeGeometry(nextGeometry);
|
||||
setAreaStats(nextStats);
|
||||
|
|
@ -387,7 +493,7 @@ export function useHexagonSelection({
|
|||
if (!cancelled) logNonAbortError('Failed to sync selected area with map view', error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingAreaStats(false);
|
||||
if (!cancelled && isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
|
@ -396,6 +502,7 @@ export function useHexagonSelection({
|
|||
};
|
||||
}, [
|
||||
selectedHexagon,
|
||||
hexagonData,
|
||||
resolution,
|
||||
usePostcodeView,
|
||||
areaStats?.central_postcode,
|
||||
|
|
@ -404,16 +511,19 @@ export function useHexagonSelection({
|
|||
fetchPostcodeLookup,
|
||||
fetchHexagonProperties,
|
||||
fetchPostcodeProperties,
|
||||
invalidateAreaRequests,
|
||||
invalidatePropertyRequests,
|
||||
isCurrentAreaRequest,
|
||||
refreshUnfilteredAreaCount,
|
||||
rightPaneTab,
|
||||
]);
|
||||
|
||||
// Re-fetch stats when filters change while a hexagon is selected
|
||||
const prevFilterStr = useRef(filterStr);
|
||||
// Re-fetch stats when filters or travel constraints change while an area is selected
|
||||
const prevSelectionQueryKey = useRef(selectionQueryKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevFilterStr.current === filterStr) return;
|
||||
prevFilterStr.current = filterStr;
|
||||
if (prevSelectionQueryKey.current === selectionQueryKey) return;
|
||||
prevSelectionQueryKey.current = selectionQueryKey;
|
||||
|
||||
if (!selectedHexagon) return;
|
||||
|
||||
|
|
@ -424,6 +534,7 @@ export function useHexagonSelection({
|
|||
|
||||
setLoadingAreaStats(true);
|
||||
let cancelled = false;
|
||||
const requestId = invalidateAreaRequests();
|
||||
|
||||
const fetchStats =
|
||||
selectedHexagon.type === 'postcode'
|
||||
|
|
@ -432,7 +543,7 @@ export function useHexagonSelection({
|
|||
|
||||
fetchStats
|
||||
.then((stats) => {
|
||||
if (cancelled) return;
|
||||
if (cancelled || !isCurrentAreaRequest(requestId)) return;
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selectedHexagon, stats.count);
|
||||
// Re-fetch properties if the properties tab is active and the filtered area still has matches.
|
||||
|
|
@ -445,24 +556,26 @@ export function useHexagonSelection({
|
|||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
if (cancelled || !isCurrentAreaRequest(requestId)) return;
|
||||
logNonAbortError('Failed to refresh stats', error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingAreaStats(false);
|
||||
if (!cancelled && isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
filterStr,
|
||||
selectionQueryKey,
|
||||
selectedHexagon,
|
||||
fetchHexagonStats,
|
||||
fetchPostcodeStats,
|
||||
rightPaneTab,
|
||||
fetchHexagonProperties,
|
||||
fetchPostcodeProperties,
|
||||
invalidateAreaRequests,
|
||||
isCurrentAreaRequest,
|
||||
refreshUnfilteredAreaCount,
|
||||
]);
|
||||
|
||||
|
|
@ -475,6 +588,8 @@ export function useHexagonSelection({
|
|||
openProperties = false,
|
||||
focusAddress?: string
|
||||
) => {
|
||||
const requestId = invalidateAreaRequests();
|
||||
invalidatePropertyRequests();
|
||||
trackEvent(openProperties ? 'Address Search' : 'Postcode Search');
|
||||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
|
|
@ -486,6 +601,7 @@ export function useHexagonSelection({
|
|||
// First try the postcode; if it has no properties, fall back to hexagons
|
||||
fetchPostcodeStats(postcode)
|
||||
.then(async (stats) => {
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
if (stats.count > 0) {
|
||||
const selection = { id: postcode, type: 'postcode' as const, resolution };
|
||||
setSelectedHexagon(selection);
|
||||
|
|
@ -515,6 +631,7 @@ export function useHexagonSelection({
|
|||
for (const res of resolutions) {
|
||||
const h3 = latLngToCell(lat, lng, res);
|
||||
const hexStats = await fetchHexagonStats(h3, res);
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
if (hexStats.count > 1) {
|
||||
const selection = { id: h3, type: 'hexagon' as const, resolution: res };
|
||||
setSelectedHexagon(selection);
|
||||
|
|
@ -529,6 +646,7 @@ export function useHexagonSelection({
|
|||
// Even the coarsest hexagon has ≤1 property — show whatever the finest has
|
||||
const h3 = latLngToCell(lat, lng, 9);
|
||||
const fallbackStats = await fetchHexagonStats(h3, 9);
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
const selection = { id: h3, type: 'hexagon' as const, resolution: 9 };
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(null);
|
||||
|
|
@ -537,24 +655,31 @@ export function useHexagonSelection({
|
|||
setRightPaneTab('area');
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
|
||||
.finally(() => setLoadingAreaStats(false));
|
||||
.finally(() => {
|
||||
if (isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
|
||||
});
|
||||
},
|
||||
[
|
||||
resolution,
|
||||
fetchPostcodeStats,
|
||||
fetchHexagonStats,
|
||||
fetchPostcodeProperties,
|
||||
invalidateAreaRequests,
|
||||
invalidatePropertyRequests,
|
||||
isCurrentAreaRequest,
|
||||
refreshUnfilteredAreaCount,
|
||||
]
|
||||
);
|
||||
|
||||
const handleCurrentLocationSearch = useCallback(
|
||||
(lat: number, lng: number) => {
|
||||
const h3 = latLngToCell(lat, lng, CURRENT_LOCATION_HEX_RESOLUTION);
|
||||
const requestId = invalidateAreaRequests();
|
||||
invalidatePropertyRequests();
|
||||
const h3 = latLngToCell(lat, lng, SMALLEST_VISIBLE_HEXAGON_RESOLUTION);
|
||||
const selection = {
|
||||
id: h3,
|
||||
type: 'hexagon' as const,
|
||||
resolution: CURRENT_LOCATION_HEX_RESOLUTION,
|
||||
resolution: SMALLEST_VISIBLE_HEXAGON_RESOLUTION,
|
||||
lockedResolution: true,
|
||||
};
|
||||
|
||||
|
|
@ -568,15 +693,24 @@ export function useHexagonSelection({
|
|||
setRightPaneTab('area');
|
||||
setLoadingAreaStats(true);
|
||||
|
||||
fetchHexagonStats(h3, CURRENT_LOCATION_HEX_RESOLUTION)
|
||||
fetchHexagonStats(h3, SMALLEST_VISIBLE_HEXAGON_RESOLUTION)
|
||||
.then((stats) => {
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch current location hex stats', error))
|
||||
.finally(() => setLoadingAreaStats(false));
|
||||
.finally(() => {
|
||||
if (isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
|
||||
});
|
||||
},
|
||||
[fetchHexagonStats, refreshUnfilteredAreaCount]
|
||||
[
|
||||
fetchHexagonStats,
|
||||
invalidateAreaRequests,
|
||||
invalidatePropertyRequests,
|
||||
isCurrentAreaRequest,
|
||||
refreshUnfilteredAreaCount,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
|||
100
frontend/src/hooks/useMapData.test.ts
Normal file
100
frontend/src/hooks/useMapData.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useMapData } from './useMapData';
|
||||
import type { ApiResponse, Bounds, ViewChangeParams } from '../types';
|
||||
|
||||
vi.mock('../lib/pocketbase', () => ({
|
||||
default: { authStore: { isValid: false, token: '' } },
|
||||
}));
|
||||
|
||||
function response(features: ApiResponse['features']): Response {
|
||||
return new Response(JSON.stringify({ features }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function viewChange(bounds: Bounds): ViewChangeParams {
|
||||
return {
|
||||
resolution: 8,
|
||||
bounds,
|
||||
zoom: 10,
|
||||
latitude: (bounds.south + bounds.north) / 2,
|
||||
longitude: (bounds.west + bounds.east) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
async function flushPromises() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('useMapData', () => {
|
||||
const requests: Array<{ url: string; resolve: (response: Response) => void }> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
requests.length = 0;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((url: string | URL | Request) => {
|
||||
return new Promise<Response>((resolve) => {
|
||||
requests.push({ url: String(url), resolve });
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('ignores a stale map response after the view has already changed', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMapData({
|
||||
filters: {},
|
||||
features: [],
|
||||
viewFeature: null,
|
||||
activeFeature: null,
|
||||
pinnedFeature: null,
|
||||
travelTimeEntries: [],
|
||||
})
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleViewChange(
|
||||
viewChange({ south: 1, west: 1, north: 2, east: 2 })
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(150);
|
||||
});
|
||||
expect(requests).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleViewChange(
|
||||
viewChange({ south: 3, west: 3, north: 4, east: 4 })
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
requests[0].resolve(response([{ h3: 'old', count: 99, lat: 1.5, lon: 1.5 }]));
|
||||
await flushPromises();
|
||||
});
|
||||
expect(result.current.data).toEqual([]);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(150);
|
||||
});
|
||||
expect(requests).toHaveLength(2);
|
||||
|
||||
await act(async () => {
|
||||
requests[1].resolve(response([{ h3: 'new', count: 7, lat: 3.5, lon: 3.5 }]));
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual([{ h3: 'new', count: 7, lat: 3.5, lon: 3.5 }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
isAbortError,
|
||||
} from '../lib/api';
|
||||
import { getSchoolBackendFeatureName } from '../lib/school-filter';
|
||||
import { getSpecificCrimeFeatureName } from '../lib/crime-filter';
|
||||
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
|
||||
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
|
||||
import { type TravelTimeEntry } from './useTravelTime';
|
||||
|
|
@ -75,19 +76,26 @@ export function useMapData({
|
|||
const dragFeatureRef = useRef<string | null>(null);
|
||||
const dragAbortRef = useRef<AbortController | null>(null);
|
||||
const activeFeatureRef = useRef<string | null>(null);
|
||||
const latestDataRequestKeyRef = useRef<string>('');
|
||||
const latestDragRequestKeyRef = useRef<string>('');
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const prevBoundsRef = useRef<string>('');
|
||||
|
||||
const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD;
|
||||
const getBackendFeatureName = useCallback(
|
||||
(name: string) =>
|
||||
getSchoolBackendFeatureName(name) ?? getSpecificCrimeFeatureName(name) ?? name,
|
||||
[]
|
||||
);
|
||||
const dataViewFeature = useMemo(
|
||||
() => (viewFeature ? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature) : null),
|
||||
[viewFeature]
|
||||
() => (viewFeature ? getBackendFeatureName(viewFeature) : null),
|
||||
[getBackendFeatureName, viewFeature]
|
||||
);
|
||||
const pinnedDataViewFeature = useMemo(
|
||||
() => (pinnedFeature ? (getSchoolBackendFeatureName(pinnedFeature) ?? pinnedFeature) : null),
|
||||
[pinnedFeature]
|
||||
() => (pinnedFeature ? getBackendFeatureName(pinnedFeature) : null),
|
||||
[getBackendFeatureName, pinnedFeature]
|
||||
);
|
||||
|
||||
// Determine if the current viewFeature is an enum (for enum_dist param)
|
||||
|
|
@ -166,6 +174,14 @@ export function useMapData({
|
|||
activeFeatureRef.current = activeFeature;
|
||||
}, [activeFeature]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeFeature) return;
|
||||
latestDragRequestKeyRef.current = '';
|
||||
dragFeatureRef.current = null;
|
||||
setDragHexData(null);
|
||||
setDragPostcodeData(null);
|
||||
}, [activeFeature]);
|
||||
|
||||
// Drag prefetch: when activeFeature starts, fetch data excluding that filter.
|
||||
// For regular filters: excludes the filter from the filter string.
|
||||
// For travel time: excludes the time range from that entry's travel param segment.
|
||||
|
|
@ -178,11 +194,23 @@ export function useMapData({
|
|||
const filtersStr = buildFilterString(filters, features, activeFeature);
|
||||
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||||
const isTravelTimeDrag = activeFeature.startsWith('tt_');
|
||||
const dataActiveFeature = getSchoolBackendFeatureName(activeFeature) ?? activeFeature;
|
||||
const dataActiveFeature = getBackendFeatureName(activeFeature);
|
||||
const dragTravelParam = isTravelTimeDrag ? buildTravelParam(activeFeature) : travelParam;
|
||||
// Travel time fields are computed from the travel param, not regular feature columns.
|
||||
// Sending a tt_* name as fields would cause a 400 (unknown field). Use empty string instead.
|
||||
const fieldsParam = isTravelTimeDrag ? '' : dataActiveFeature;
|
||||
const requestKey = [
|
||||
usePostcodeView ? 'postcodes' : 'hexagons',
|
||||
activeFeature,
|
||||
resolution,
|
||||
boundsStr,
|
||||
filtersStr,
|
||||
fieldsParam,
|
||||
dragTravelParam,
|
||||
viewFeatureIsEnum && dataViewFeature ? dataViewFeature : '',
|
||||
shareCode ?? '',
|
||||
].join('|');
|
||||
latestDragRequestKeyRef.current = requestKey;
|
||||
|
||||
if (usePostcodeView) {
|
||||
const params = new URLSearchParams({ bounds: boundsStr });
|
||||
|
|
@ -195,6 +223,7 @@ export function useMapData({
|
|||
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
||||
.then((res) => res.json())
|
||||
.then((json: { features: PostcodeFeature[] }) => {
|
||||
if (latestDragRequestKeyRef.current !== requestKey) return;
|
||||
setDragPostcodeData(json.features);
|
||||
setDragHexData(null);
|
||||
dragFeatureRef.current = activeFeature;
|
||||
|
|
@ -214,6 +243,7 @@ export function useMapData({
|
|||
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
||||
.then((res) => res.json())
|
||||
.then((json: ApiResponse) => {
|
||||
if (latestDragRequestKeyRef.current !== requestKey) return;
|
||||
setDragHexData(json.features);
|
||||
setDragPostcodeData(null);
|
||||
dragFeatureRef.current = activeFeature;
|
||||
|
|
@ -226,6 +256,9 @@ export function useMapData({
|
|||
dragAbortRef.current.abort();
|
||||
dragAbortRef.current = null;
|
||||
}
|
||||
if (latestDragRequestKeyRef.current === requestKey) {
|
||||
latestDragRequestKeyRef.current = '';
|
||||
}
|
||||
};
|
||||
}, [
|
||||
activeFeature,
|
||||
|
|
@ -237,13 +270,18 @@ export function useMapData({
|
|||
travelParam,
|
||||
buildTravelParam,
|
||||
dataViewFeature,
|
||||
getBackendFeatureName,
|
||||
viewFeatureIsEnum,
|
||||
shareCode,
|
||||
]);
|
||||
|
||||
// Fetch hexagons or postcodes when bounds/filters change
|
||||
useEffect(() => {
|
||||
if (!bounds) return;
|
||||
if (!bounds) {
|
||||
latestDataRequestKeyRef.current = '';
|
||||
return;
|
||||
}
|
||||
latestDataRequestKeyRef.current = dataRequestKey;
|
||||
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
|
|
@ -255,10 +293,9 @@ export function useMapData({
|
|||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
const requestKey = dataRequestKey;
|
||||
setLoading(true);
|
||||
try {
|
||||
const requestKey = dataRequestKey;
|
||||
|
||||
if (usePostcodeView) {
|
||||
const params = new URLSearchParams({ bounds: boundsParam });
|
||||
if (filtersParam) params.set('filters', filtersParam);
|
||||
|
|
@ -279,6 +316,7 @@ export function useMapData({
|
|||
);
|
||||
if (res.status === 403) {
|
||||
const errBody = await res.json();
|
||||
if (requestKey !== latestDataRequestKeyRef.current) return;
|
||||
if (errBody.error === 'license_required' && errBody.free_zone) {
|
||||
setLicenseRequired(true);
|
||||
setFreeZone(errBody.free_zone);
|
||||
|
|
@ -287,8 +325,10 @@ export function useMapData({
|
|||
}
|
||||
}
|
||||
assertOk(res, 'postcodes');
|
||||
if (requestKey !== latestDataRequestKeyRef.current) return;
|
||||
setLicenseRequired(false);
|
||||
const json: { features: PostcodeFeature[] } = await res.json();
|
||||
if (requestKey !== latestDataRequestKeyRef.current) return;
|
||||
setPostcodeData(json.features);
|
||||
setRawData([]);
|
||||
setLoadedDataKey(requestKey);
|
||||
|
|
@ -315,6 +355,7 @@ export function useMapData({
|
|||
);
|
||||
if (res.status === 403) {
|
||||
const errBody = await res.json();
|
||||
if (requestKey !== latestDataRequestKeyRef.current) return;
|
||||
if (errBody.error === 'license_required' && errBody.free_zone) {
|
||||
setLicenseRequired(true);
|
||||
setFreeZone(errBody.free_zone);
|
||||
|
|
@ -323,8 +364,10 @@ export function useMapData({
|
|||
}
|
||||
}
|
||||
assertOk(res, 'hexagons');
|
||||
if (requestKey !== latestDataRequestKeyRef.current) return;
|
||||
setLicenseRequired(false);
|
||||
const json: ApiResponse = await res.json();
|
||||
if (requestKey !== latestDataRequestKeyRef.current) return;
|
||||
setRawData(json.features);
|
||||
setPostcodeData([]);
|
||||
setLoadedDataKey(requestKey);
|
||||
|
|
@ -338,7 +381,7 @@ export function useMapData({
|
|||
}
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
if (!isAbortError(err)) {
|
||||
if (requestKey === latestDataRequestKeyRef.current && !isAbortError(err)) {
|
||||
logNonAbortError('Failed to fetch data', err);
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -349,6 +392,10 @@ export function useMapData({
|
|||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
resolution,
|
||||
|
|
@ -366,10 +413,12 @@ export function useMapData({
|
|||
|
||||
// Use drag data when it matches the current view feature, otherwise fall back to rawData
|
||||
const data =
|
||||
(viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ?? rawData;
|
||||
(activeFeature && viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ??
|
||||
rawData;
|
||||
const effectivePostcodeData =
|
||||
(viewFeature && dragFeatureRef.current === viewFeature ? dragPostcodeData : null) ??
|
||||
postcodeData;
|
||||
(activeFeature && viewFeature && dragFeatureRef.current === viewFeature
|
||||
? dragPostcodeData
|
||||
: null) ?? postcodeData;
|
||||
|
||||
// Compute p5/p95 from committed data for the viewed feature.
|
||||
// Always uses rawData/postcodeData (not drag preview data) so the color
|
||||
|
|
@ -548,6 +597,7 @@ export function useMapData({
|
|||
|
||||
return {
|
||||
data,
|
||||
committedHexagonData: rawData,
|
||||
postcodeData: effectivePostcodeData,
|
||||
resolution,
|
||||
bounds,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue