LGTM
This commit is contained in:
parent
9248e26af2
commit
f2a2651b8a
95 changed files with 3993 additions and 1471 deletions
|
|
@ -159,9 +159,10 @@ export function useDeckLayers({
|
|||
continue;
|
||||
}
|
||||
const c = d.count as number;
|
||||
total += c;
|
||||
if (c <= 0) continue;
|
||||
if (c < min) min = c;
|
||||
if (c > max) max = c;
|
||||
total += c;
|
||||
}
|
||||
if (min === Infinity) return { min: 0, max: 1, total: 0 };
|
||||
if (min === max) return { min, max: min + 1, total };
|
||||
|
|
@ -188,9 +189,10 @@ export function useDeckLayers({
|
|||
continue;
|
||||
}
|
||||
const c = d.properties.count;
|
||||
total += c;
|
||||
if (c <= 0) continue;
|
||||
if (c < min) min = c;
|
||||
if (c > max) max = c;
|
||||
total += c;
|
||||
}
|
||||
if (min === Infinity) return { min: 0, max: 1, total: 0 };
|
||||
if (min === max) return { min, max: min + 1, total };
|
||||
|
|
@ -296,6 +298,9 @@ export function useDeckLayers({
|
|||
data,
|
||||
getHexagon: (d) => d.h3,
|
||||
getFillColor: (d) => {
|
||||
if ((d.count as number) <= 0) {
|
||||
return [0, 0, 0, 0] as [number, number, number, number];
|
||||
}
|
||||
const dark = isDarkRef.current;
|
||||
const vf = viewFeatureRef.current;
|
||||
const clr = colorRangeRef.current;
|
||||
|
|
@ -400,6 +405,9 @@ export function useDeckLayers({
|
|||
data: postcodeData as PostcodeFeature[],
|
||||
getFillColor: (f) => {
|
||||
const d = f.properties;
|
||||
if (d.count <= 0) {
|
||||
return [0, 0, 0, 0] as [number, number, number, number];
|
||||
}
|
||||
const dark = isDarkRef.current;
|
||||
const vf = viewFeatureRef.current;
|
||||
const clr = colorRangeRef.current;
|
||||
|
|
@ -477,6 +485,7 @@ export function useDeckLayers({
|
|||
const dark = isDarkRef.current;
|
||||
if (pc === hoveredPostcodeRef.current)
|
||||
return [29, 228, 195, 200] as [number, number, number, number];
|
||||
if (f.properties.count <= 0) return [0, 0, 0, 0] as [number, number, number, number];
|
||||
return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [
|
||||
number,
|
||||
number,
|
||||
|
|
@ -487,6 +496,7 @@ export function useDeckLayers({
|
|||
getLineWidth: (f) => {
|
||||
const pc = f.properties.postcode;
|
||||
if (pc === hoveredPostcodeRef.current) return 2;
|
||||
if (f.properties.count <= 0) return 0;
|
||||
return 1;
|
||||
},
|
||||
lineWidthUnits: 'pixels',
|
||||
|
|
@ -504,11 +514,16 @@ export function useDeckLayers({
|
|||
});
|
||||
}, [postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]);
|
||||
|
||||
const labeledPostcodeData = useMemo(
|
||||
() => postcodeData.filter((feature) => feature.properties.count > 0),
|
||||
[postcodeData]
|
||||
);
|
||||
|
||||
const postcodeLabelsLayer = useMemo(
|
||||
() =>
|
||||
new TextLayer<PostcodeFeature>({
|
||||
id: 'postcode-labels',
|
||||
data: postcodeData,
|
||||
data: labeledPostcodeData,
|
||||
getPosition: (f) => f.properties.centroid,
|
||||
getText: (f) => f.properties.postcode,
|
||||
getSize: 12,
|
||||
|
|
@ -525,7 +540,7 @@ export function useDeckLayers({
|
|||
billboard: false,
|
||||
pickable: false,
|
||||
}),
|
||||
[postcodeData, theme]
|
||||
[labeledPostcodeData, theme]
|
||||
);
|
||||
|
||||
// Marching ants highlight layer for selected hexagon or postcode
|
||||
|
|
|
|||
|
|
@ -1,21 +1,88 @@
|
|||
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
import type React from 'react';
|
||||
|
||||
export type DropdownPosition = {
|
||||
left: number;
|
||||
width: number;
|
||||
maxHeight: number;
|
||||
placement: 'above' | 'below';
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
};
|
||||
|
||||
const GAP = 4;
|
||||
const SAFETY = 8;
|
||||
const MIN_BELOW = 200;
|
||||
const MIN_HEIGHT = 120;
|
||||
const MAX_VIEWPORT_FRACTION = 0.6;
|
||||
|
||||
function compute(anchor: HTMLElement): DropdownPosition {
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const vv = window.visualViewport;
|
||||
const visibleTop = vv?.offsetTop ?? 0;
|
||||
const visibleHeight = vv?.height ?? window.innerHeight;
|
||||
const visibleBottom = visibleTop + visibleHeight;
|
||||
const layoutHeight = window.innerHeight;
|
||||
const cap = Math.max(MIN_HEIGHT, visibleHeight * MAX_VIEWPORT_FRACTION);
|
||||
|
||||
const spaceBelow = visibleBottom - rect.bottom - GAP - SAFETY;
|
||||
const spaceAbove = rect.top - visibleTop - GAP - SAFETY;
|
||||
const placeBelow = spaceBelow >= MIN_BELOW || spaceBelow >= spaceAbove;
|
||||
|
||||
if (placeBelow) {
|
||||
// Pin top to the visible viewport so the panel can't slide off when the
|
||||
// page auto-scrolls to reveal a focused input.
|
||||
const top = Math.max(rect.bottom + GAP, visibleTop + SAFETY);
|
||||
const available = visibleBottom - top - SAFETY;
|
||||
return {
|
||||
placement: 'below',
|
||||
top,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
maxHeight: Math.min(Math.max(available, MIN_HEIGHT), cap),
|
||||
};
|
||||
}
|
||||
|
||||
// Pin the bottom edge to the visible viewport for the same reason.
|
||||
const bottomY = Math.min(rect.top - GAP, visibleBottom - SAFETY);
|
||||
const available = bottomY - visibleTop - SAFETY;
|
||||
return {
|
||||
placement: 'above',
|
||||
bottom: layoutHeight - bottomY,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
maxHeight: Math.min(Math.max(available, MIN_HEIGHT), cap),
|
||||
};
|
||||
}
|
||||
|
||||
export function dropdownPositionStyle(pos: DropdownPosition): React.CSSProperties {
|
||||
return {
|
||||
position: 'fixed',
|
||||
left: pos.left,
|
||||
width: pos.width,
|
||||
maxHeight: pos.maxHeight,
|
||||
...(pos.placement === 'below' ? { top: pos.top } : { bottom: pos.bottom }),
|
||||
zIndex: 50,
|
||||
};
|
||||
}
|
||||
|
||||
export function useDropdownPosition(anchorRef: React.RefObject<HTMLElement | null>, open: boolean) {
|
||||
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);
|
||||
const [pos, setPos] = useState<DropdownPosition | null>(null);
|
||||
const posRef = useRef(pos);
|
||||
posRef.current = pos;
|
||||
|
||||
const update = useCallback(() => {
|
||||
if (!anchorRef.current) return;
|
||||
const rect = anchorRef.current.getBoundingClientRect();
|
||||
const next = { top: rect.bottom + 4, left: rect.left, width: rect.width };
|
||||
const next = compute(anchorRef.current);
|
||||
const prev = posRef.current;
|
||||
if (
|
||||
prev &&
|
||||
Math.abs(prev.top - next.top) < 0.5 &&
|
||||
prev.placement === next.placement &&
|
||||
Math.abs((prev.top ?? 0) - (next.top ?? 0)) < 0.5 &&
|
||||
Math.abs((prev.bottom ?? 0) - (next.bottom ?? 0)) < 0.5 &&
|
||||
Math.abs(prev.left - next.left) < 0.5 &&
|
||||
Math.abs(prev.width - next.width) < 0.5
|
||||
Math.abs(prev.width - next.width) < 0.5 &&
|
||||
Math.abs(prev.maxHeight - next.maxHeight) < 0.5
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,14 @@ import {
|
|||
getSpecificCrimeFilterKeyId,
|
||||
normalizeSpecificCrimeFilters,
|
||||
} from '../lib/crime-filter';
|
||||
import {
|
||||
ELECTION_VOTE_SHARE_FILTER_NAME,
|
||||
createElectionVoteShareFilterKey,
|
||||
getDefaultElectionVoteShareFeatureName,
|
||||
getElectionVoteShareFeatureName,
|
||||
getElectionVoteShareFilterKeyId,
|
||||
normalizeElectionVoteShareFilters,
|
||||
} from '../lib/election-filter';
|
||||
import {
|
||||
ETHNICITIES_FILTER_NAME,
|
||||
createEthnicityFilterKey,
|
||||
|
|
@ -42,7 +50,11 @@ interface UseFiltersOptions {
|
|||
|
||||
function normalizeFilters(filters: FeatureFilters): FeatureFilters {
|
||||
return normalizePoiDistanceFilters(
|
||||
normalizeEthnicityFilters(normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters)))
|
||||
normalizeEthnicityFilters(
|
||||
normalizeElectionVoteShareFilters(
|
||||
normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters))
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -50,6 +62,7 @@ function getBackendFeatureName(name: string): string {
|
|||
return (
|
||||
getSchoolBackendFeatureName(name) ??
|
||||
getSpecificCrimeFeatureName(name) ??
|
||||
getElectionVoteShareFeatureName(name) ??
|
||||
getEthnicityFeatureName(name) ??
|
||||
getPoiDistanceFeatureName(name) ??
|
||||
name
|
||||
|
|
@ -110,6 +123,9 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
const specificCrimeFilterIdRef = useRef(
|
||||
getNextNumericKeyId(initialFiltersRef.current!, getSpecificCrimeFilterKeyId)
|
||||
);
|
||||
const electionVoteShareFilterIdRef = useRef(
|
||||
getNextNumericKeyId(initialFiltersRef.current!, getElectionVoteShareFilterKeyId)
|
||||
);
|
||||
const ethnicityFilterIdRef = useRef(
|
||||
getNextNumericKeyId(initialFiltersRef.current!, getEthnicityFilterKeyId)
|
||||
);
|
||||
|
|
@ -151,6 +167,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
if (
|
||||
name !== SCHOOL_FILTER_NAME &&
|
||||
name !== SPECIFIC_CRIMES_FILTER_NAME &&
|
||||
name !== ELECTION_VOTE_SHARE_FILTER_NAME &&
|
||||
name !== ETHNICITIES_FILTER_NAME &&
|
||||
!POI_FILTER_NAMES.includes(name as PoiFilterName) &&
|
||||
!meta
|
||||
|
|
@ -198,6 +215,24 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
],
|
||||
};
|
||||
}
|
||||
if (name === ELECTION_VOTE_SHARE_FILTER_NAME) {
|
||||
const defaultVoteShareFeatureName = getDefaultElectionVoteShareFeatureName(features);
|
||||
const defaultVoteShareFeature = defaultVoteShareFeatureName
|
||||
? features.find((feature) => feature.name === defaultVoteShareFeatureName)
|
||||
: undefined;
|
||||
if (!defaultVoteShareFeatureName) return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[createElectionVoteShareFilterKey(
|
||||
defaultVoteShareFeatureName,
|
||||
electionVoteShareFilterIdRef.current++
|
||||
)]: [
|
||||
defaultVoteShareFeature?.histogram?.min ?? defaultVoteShareFeature?.min ?? 0,
|
||||
defaultVoteShareFeature?.histogram?.max ?? defaultVoteShareFeature?.max ?? 100,
|
||||
],
|
||||
};
|
||||
}
|
||||
if (name === ETHNICITIES_FILTER_NAME) {
|
||||
const defaultEthnicityFeatureName = getDefaultEthnicityFeatureName(features);
|
||||
const defaultEthnicityFeature = defaultEthnicityFeatureName
|
||||
|
|
@ -326,6 +361,23 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
if (replaced) return normalizeFilters(next);
|
||||
}
|
||||
|
||||
const electionVoteShareKeyId = getElectionVoteShareFilterKeyId(name);
|
||||
if (electionVoteShareKeyId != null) {
|
||||
let replaced = false;
|
||||
const next: FeatureFilters = {};
|
||||
for (const [existingName, existingValue] of Object.entries(prev)) {
|
||||
if (getElectionVoteShareFilterKeyId(existingName) === electionVoteShareKeyId) {
|
||||
if (!replaced) {
|
||||
next[name] = value;
|
||||
replaced = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
next[existingName] = existingValue;
|
||||
}
|
||||
if (replaced) return normalizeFilters(next);
|
||||
}
|
||||
|
||||
const poiDistanceKeyId = getPoiDistanceFilterKeyId(name);
|
||||
if (poiDistanceKeyId != null) {
|
||||
let replaced = false;
|
||||
|
|
|
|||
134
frontend/src/hooks/useHexagonSelection.test.ts
Normal file
134
frontend/src/hooks/useHexagonSelection.test.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useHexagonSelection } from './useHexagonSelection';
|
||||
import type { FeatureMeta, HexagonStatsResponse, PostcodeGeometry } from '../types';
|
||||
|
||||
vi.mock('../lib/pocketbase', () => ({
|
||||
default: { authStore: { isValid: false, token: '' } },
|
||||
}));
|
||||
|
||||
vi.mock('../lib/analytics', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
const postcodeGeometry: PostcodeGeometry = {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[-0.12, 51.5],
|
||||
[-0.11, 51.5],
|
||||
[-0.11, 51.51],
|
||||
[-0.12, 51.51],
|
||||
[-0.12, 51.5],
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
function stats(count: number): HexagonStatsResponse {
|
||||
return {
|
||||
count,
|
||||
numeric_features: [],
|
||||
enum_features: [],
|
||||
central_postcode: 'SW1A 1AA',
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('useHexagonSelection', () => {
|
||||
const requests: string[] = [];
|
||||
const features: FeatureMeta[] = [{ name: 'Price', type: 'numeric', min: 0, max: 100 }];
|
||||
|
||||
beforeEach(() => {
|
||||
requests.length = 0;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((input: string | URL | Request) => {
|
||||
const url = new URL(String(input), 'http://localhost');
|
||||
requests.push(`${url.pathname}${url.search}`);
|
||||
|
||||
if (url.pathname === '/api/postcode-stats') {
|
||||
return Promise.resolve(jsonResponse(stats(url.searchParams.has('filters') ? 0 : 4)));
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/hexagon-stats') {
|
||||
return Promise.resolve(jsonResponse(stats(12)));
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 404 }));
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('keeps a postcode search selected when filters exclude its properties', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useHexagonSelection({
|
||||
filters: { Price: [0, 50] },
|
||||
features,
|
||||
hexagonData: [],
|
||||
resolution: 9,
|
||||
usePostcodeView: true,
|
||||
travelTimeEntries: [],
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleLocationSearch('SW1A 1AA', postcodeGeometry, 51.505, -0.115);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedHexagon).toEqual({
|
||||
id: 'SW1A 1AA',
|
||||
type: 'postcode',
|
||||
resolution: 9,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.selectedPostcodeGeometry).toBe(postcodeGeometry);
|
||||
expect(result.current.areaStats?.count).toBe(0);
|
||||
expect(result.current.unfilteredAreaCount).toBe(4);
|
||||
expect(requests.some((url) => url.startsWith('/api/hexagon-stats'))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a postcode search selected when stats are based on all properties', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useHexagonSelection({
|
||||
filters: { Price: [0, 50] },
|
||||
features,
|
||||
hexagonData: [],
|
||||
resolution: 9,
|
||||
usePostcodeView: true,
|
||||
travelTimeEntries: [],
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setAreaStatsUseFilters(false);
|
||||
});
|
||||
act(() => {
|
||||
result.current.handleLocationSearch('SW1A 1AA', postcodeGeometry, 51.505, -0.115);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedHexagon).toEqual({
|
||||
id: 'SW1A 1AA',
|
||||
type: 'postcode',
|
||||
resolution: 9,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.areaStats?.count).toBe(4);
|
||||
expect(result.current.unfilteredAreaCount).toBeNull();
|
||||
expect(requests.some((url) => url.startsWith('/api/hexagon-stats'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -11,7 +11,7 @@ import type {
|
|||
HexagonStatsResponse,
|
||||
} from '../types';
|
||||
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api';
|
||||
import { findOverlappingMatchingHexagon } from '../lib/h3-selection';
|
||||
import { findOverlappingSelectableHexagon } from '../lib/h3-selection';
|
||||
import { SMALLEST_VISIBLE_HEXAGON_RESOLUTION } from '../lib/consts';
|
||||
import type { TravelTimeEntry } from './useTravelTime';
|
||||
|
||||
|
|
@ -66,6 +66,7 @@ export function useHexagonSelection({
|
|||
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
|
||||
);
|
||||
|
|
@ -149,14 +150,23 @@ export function useHexagonSelection({
|
|||
);
|
||||
|
||||
const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]);
|
||||
const selectionQueryKey = useMemo(
|
||||
() => [filterStr, travelParam, shareCode ?? ''].join('|'),
|
||||
[filterStr, shareCode, travelParam]
|
||||
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, signal?: AbortSignal) => {
|
||||
if (!filterStr) {
|
||||
if (!hasStatsFilters) {
|
||||
setUnfilteredAreaCount(null);
|
||||
return;
|
||||
}
|
||||
|
|
@ -167,12 +177,17 @@ export function useHexagonSelection({
|
|||
: await fetchHexagonStats(selection.id, selection.resolution, signal, undefined, false);
|
||||
setUnfilteredAreaCount(stats.count);
|
||||
},
|
||||
[filterStr, fetchHexagonStats, fetchPostcodeStats]
|
||||
[fetchHexagonStats, fetchPostcodeStats, hasStatsFilters]
|
||||
);
|
||||
|
||||
const refreshUnfilteredAreaCount = useCallback(
|
||||
(selection: SelectedHexagon, filteredCount: number, signal?: AbortSignal) => {
|
||||
if (!filterStr || filteredCount > 0) {
|
||||
(
|
||||
selection: SelectedHexagon,
|
||||
statsCount: number,
|
||||
includeFilters: boolean,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
if (!includeFilters || !hasStatsFilters || statsCount > 0) {
|
||||
setUnfilteredAreaCount(null);
|
||||
return;
|
||||
}
|
||||
|
|
@ -181,7 +196,7 @@ export function useHexagonSelection({
|
|||
logNonAbortError('Failed to fetch unfiltered area count', error)
|
||||
);
|
||||
},
|
||||
[filterStr, fetchUnfilteredAreaCount]
|
||||
[fetchUnfilteredAreaCount, hasStatsFilters]
|
||||
);
|
||||
|
||||
const fetchPostcodeLookup = useCallback(async (postcode: string, signal?: AbortSignal) => {
|
||||
|
|
@ -311,11 +326,11 @@ export function useHexagonSelection({
|
|||
|
||||
if (isPostcode) {
|
||||
setLoadingAreaStats(true);
|
||||
fetchPostcodeStats(id)
|
||||
fetchPostcodeStats(id, undefined, areaStatsUseFilters)
|
||||
.then((stats) => {
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count);
|
||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
|
||||
.finally(() => {
|
||||
|
|
@ -323,11 +338,11 @@ export function useHexagonSelection({
|
|||
});
|
||||
} else {
|
||||
setLoadingAreaStats(true);
|
||||
fetchHexagonStats(id, resolution)
|
||||
fetchHexagonStats(id, resolution, undefined, undefined, areaStatsUseFilters)
|
||||
.then((stats) => {
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count);
|
||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
|
||||
.finally(() => {
|
||||
|
|
@ -339,6 +354,7 @@ export function useHexagonSelection({
|
|||
[
|
||||
selectedHexagon,
|
||||
resolution,
|
||||
areaStatsUseFilters,
|
||||
fetchHexagonStats,
|
||||
fetchPostcodeStats,
|
||||
invalidateAreaRequests,
|
||||
|
|
@ -423,7 +439,7 @@ export function useHexagonSelection({
|
|||
!selection.lockedResolution &&
|
||||
selection.resolution < resolution;
|
||||
const overlappingHexagon = zoomingIntoHexagon
|
||||
? findOverlappingMatchingHexagon(selection.id, hexagonData, resolution)
|
||||
? findOverlappingSelectableHexagon(selection.id, hexagonData, resolution)
|
||||
: null;
|
||||
if (zoomingIntoHexagon && !overlappingHexagon) return;
|
||||
|
||||
|
|
@ -451,12 +467,22 @@ export function useHexagonSelection({
|
|||
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);
|
||||
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);
|
||||
nextStats = await fetchHexagonStats(
|
||||
nextId,
|
||||
resolution,
|
||||
controller.signal,
|
||||
undefined,
|
||||
areaStatsUseFilters
|
||||
);
|
||||
} else if (
|
||||
!usePostcodeView &&
|
||||
selection.type === 'hexagon' &&
|
||||
|
|
@ -469,7 +495,13 @@ export function useHexagonSelection({
|
|||
: overlappingHexagon?.h3;
|
||||
if (!nextId) return;
|
||||
nextSelection = { id: nextId, type: 'hexagon', resolution };
|
||||
nextStats = await fetchHexagonStats(nextId, resolution, controller.signal);
|
||||
nextStats = await fetchHexagonStats(
|
||||
nextId,
|
||||
resolution,
|
||||
controller.signal,
|
||||
undefined,
|
||||
areaStatsUseFilters
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
|
@ -478,7 +510,12 @@ export function useHexagonSelection({
|
|||
setSelectedHexagon(nextSelection);
|
||||
setSelectedPostcodeGeometry(nextGeometry);
|
||||
setAreaStats(nextStats);
|
||||
refreshUnfilteredAreaCount(nextSelection, nextStats.count, controller.signal);
|
||||
refreshUnfilteredAreaCount(
|
||||
nextSelection,
|
||||
nextStats.count,
|
||||
areaStatsUseFilters,
|
||||
controller.signal
|
||||
);
|
||||
refreshProperties(nextSelection);
|
||||
}
|
||||
|
||||
|
|
@ -505,6 +542,7 @@ export function useHexagonSelection({
|
|||
hexagonData,
|
||||
resolution,
|
||||
usePostcodeView,
|
||||
areaStatsUseFilters,
|
||||
areaStats?.central_postcode,
|
||||
fetchHexagonStats,
|
||||
fetchPostcodeStats,
|
||||
|
|
@ -519,11 +557,11 @@ export function useHexagonSelection({
|
|||
]);
|
||||
|
||||
// Re-fetch stats when filters or travel constraints change while an area is selected
|
||||
const prevSelectionQueryKey = useRef(selectionQueryKey);
|
||||
const prevAreaStatsQueryKey = useRef(areaStatsQueryKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevSelectionQueryKey.current === selectionQueryKey) return;
|
||||
prevSelectionQueryKey.current = selectionQueryKey;
|
||||
if (prevAreaStatsQueryKey.current === areaStatsQueryKey) return;
|
||||
prevAreaStatsQueryKey.current = areaStatsQueryKey;
|
||||
|
||||
if (!selectedHexagon) return;
|
||||
|
||||
|
|
@ -538,16 +576,22 @@ export function useHexagonSelection({
|
|||
|
||||
const fetchStats =
|
||||
selectedHexagon.type === 'postcode'
|
||||
? fetchPostcodeStats(selectedHexagon.id)
|
||||
: fetchHexagonStats(selectedHexagon.id, selectedHexagon.resolution);
|
||||
? 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);
|
||||
refreshUnfilteredAreaCount(selectedHexagon, stats.count, areaStatsUseFilters);
|
||||
// Re-fetch properties if the properties tab is active and the filtered area still has matches.
|
||||
if (rightPaneTab === 'properties' && stats.count > 0) {
|
||||
if (areaStatsUseFilters && rightPaneTab === 'properties' && stats.count > 0) {
|
||||
if (selectedHexagon.type === 'postcode') {
|
||||
fetchPostcodeProperties(selectedHexagon.id, 0);
|
||||
} else {
|
||||
|
|
@ -567,10 +611,11 @@ export function useHexagonSelection({
|
|||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
selectionQueryKey,
|
||||
areaStatsQueryKey,
|
||||
selectedHexagon,
|
||||
fetchHexagonStats,
|
||||
fetchPostcodeStats,
|
||||
areaStatsUseFilters,
|
||||
rightPaneTab,
|
||||
fetchHexagonProperties,
|
||||
fetchPostcodeProperties,
|
||||
|
|
@ -598,8 +643,9 @@ export function useHexagonSelection({
|
|||
setRightPaneTab(openProperties ? 'properties' : 'area');
|
||||
setLoadingAreaStats(true);
|
||||
|
||||
// First try the postcode; if it has no properties, fall back to hexagons
|
||||
fetchPostcodeStats(postcode)
|
||||
// First try the postcode; if it only has no matches because of active filters,
|
||||
// keep the searched postcode selected instead of widening to nearby hexagons.
|
||||
fetchPostcodeStats(postcode, undefined, areaStatsUseFilters)
|
||||
.then(async (stats) => {
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
if (stats.count > 0) {
|
||||
|
|
@ -607,13 +653,27 @@ export function useHexagonSelection({
|
|||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(geometry);
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count);
|
||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
|
||||
if (openProperties) {
|
||||
fetchPostcodeProperties(postcode, 0, focusAddress);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (areaStatsUseFilters && hasStatsFilters) {
|
||||
const unfilteredStats = await fetchPostcodeStats(postcode, undefined, false);
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
if (unfilteredStats.count > 0) {
|
||||
const selection = { id: postcode, type: 'postcode' as const, resolution };
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(geometry);
|
||||
setAreaStats(stats);
|
||||
setUnfilteredAreaCount(unfilteredStats.count);
|
||||
setRightPaneTab(openProperties ? 'properties' : 'area');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No properties in this postcode — fall back to hexagons
|
||||
if (lat == null || lng == null) {
|
||||
// No coordinates available, show empty postcode anyway
|
||||
|
|
@ -621,7 +681,7 @@ export function useHexagonSelection({
|
|||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(geometry);
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count);
|
||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
|
||||
setRightPaneTab('area');
|
||||
return;
|
||||
}
|
||||
|
|
@ -630,14 +690,20 @@ export function useHexagonSelection({
|
|||
const resolutions = [9, 8, 7, 6, 5];
|
||||
for (const res of resolutions) {
|
||||
const h3 = latLngToCell(lat, lng, res);
|
||||
const hexStats = await fetchHexagonStats(h3, res);
|
||||
const hexStats = await fetchHexagonStats(
|
||||
h3,
|
||||
res,
|
||||
undefined,
|
||||
undefined,
|
||||
areaStatsUseFilters
|
||||
);
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
if (hexStats.count > 1) {
|
||||
const selection = { id: h3, type: 'hexagon' as const, resolution: res };
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(null);
|
||||
setAreaStats(hexStats);
|
||||
refreshUnfilteredAreaCount(selection, hexStats.count);
|
||||
refreshUnfilteredAreaCount(selection, hexStats.count, areaStatsUseFilters);
|
||||
setRightPaneTab('area');
|
||||
return;
|
||||
}
|
||||
|
|
@ -645,13 +711,19 @@ 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);
|
||||
const fallbackStats = await fetchHexagonStats(
|
||||
h3,
|
||||
9,
|
||||
undefined,
|
||||
undefined,
|
||||
areaStatsUseFilters
|
||||
);
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
const selection = { id: h3, type: 'hexagon' as const, resolution: 9 };
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(null);
|
||||
setAreaStats(fallbackStats);
|
||||
refreshUnfilteredAreaCount(selection, fallbackStats.count);
|
||||
refreshUnfilteredAreaCount(selection, fallbackStats.count, areaStatsUseFilters);
|
||||
setRightPaneTab('area');
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
|
||||
|
|
@ -661,6 +733,8 @@ export function useHexagonSelection({
|
|||
},
|
||||
[
|
||||
resolution,
|
||||
areaStatsUseFilters,
|
||||
hasStatsFilters,
|
||||
fetchPostcodeStats,
|
||||
fetchHexagonStats,
|
||||
fetchPostcodeProperties,
|
||||
|
|
@ -693,11 +767,17 @@ export function useHexagonSelection({
|
|||
setRightPaneTab('area');
|
||||
setLoadingAreaStats(true);
|
||||
|
||||
fetchHexagonStats(h3, SMALLEST_VISIBLE_HEXAGON_RESOLUTION)
|
||||
fetchHexagonStats(
|
||||
h3,
|
||||
SMALLEST_VISIBLE_HEXAGON_RESOLUTION,
|
||||
undefined,
|
||||
undefined,
|
||||
areaStatsUseFilters
|
||||
)
|
||||
.then((stats) => {
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count);
|
||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch current location hex stats', error))
|
||||
.finally(() => {
|
||||
|
|
@ -705,6 +785,7 @@ export function useHexagonSelection({
|
|||
});
|
||||
},
|
||||
[
|
||||
areaStatsUseFilters,
|
||||
fetchHexagonStats,
|
||||
invalidateAreaRequests,
|
||||
invalidatePropertyRequests,
|
||||
|
|
@ -721,6 +802,8 @@ export function useHexagonSelection({
|
|||
areaStats,
|
||||
loadingAreaStats,
|
||||
unfilteredAreaCount,
|
||||
areaStatsUseFilters,
|
||||
setAreaStatsUseFilters,
|
||||
hoveredHexagon,
|
||||
rightPaneTab,
|
||||
setRightPaneTab,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
} from '../lib/api';
|
||||
import { getSchoolBackendFeatureName } from '../lib/school-filter';
|
||||
import { getSpecificCrimeFeatureName } from '../lib/crime-filter';
|
||||
import { getElectionVoteShareFeatureName } from '../lib/election-filter';
|
||||
import { getEthnicityFeatureName } from '../lib/ethnicity-filter';
|
||||
import { getPoiDistanceFeatureName } from '../lib/poi-distance-filter';
|
||||
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
|
||||
|
|
@ -90,6 +91,7 @@ export function useMapData({
|
|||
(name: string) =>
|
||||
getSchoolBackendFeatureName(name) ??
|
||||
getSpecificCrimeFeatureName(name) ??
|
||||
getElectionVoteShareFeatureName(name) ??
|
||||
getEthnicityFeatureName(name) ??
|
||||
getPoiDistanceFeatureName(name) ??
|
||||
name,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue