Refactor map page
This commit is contained in:
parent
29d048ffd4
commit
d4d79f0d99
17 changed files with 1014 additions and 878 deletions
197
frontend/src/hooks/useMapData.ts
Normal file
197
frontend/src/hooks/useMapData.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import type {
|
||||
FeatureMeta,
|
||||
FeatureFilters,
|
||||
Bounds,
|
||||
HexagonData,
|
||||
PostcodeFeature,
|
||||
ViewChangeParams,
|
||||
ApiResponse,
|
||||
} from '../types';
|
||||
import { buildFilterString, apiUrl, logNonAbortError } from '../lib/api';
|
||||
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/map-utils';
|
||||
|
||||
const DEBOUNCE_MS = 150;
|
||||
|
||||
interface UseMapDataOptions {
|
||||
filters: FeatureFilters;
|
||||
features: FeatureMeta[];
|
||||
viewFeature: string | null;
|
||||
activeFeature: string | null;
|
||||
dragValue: [number, number] | null;
|
||||
dragData: HexagonData[] | null;
|
||||
}
|
||||
|
||||
export function useMapData({
|
||||
filters,
|
||||
features,
|
||||
viewFeature,
|
||||
activeFeature,
|
||||
dragValue,
|
||||
dragData,
|
||||
}: UseMapDataOptions) {
|
||||
const [rawData, setRawData] = useState<HexagonData[]>([]);
|
||||
const [postcodeData, setPostcodeData] = useState<PostcodeFeature[]>([]);
|
||||
const [resolution, setResolution] = useState<number>(8);
|
||||
const [bounds, setBounds] = useState<Bounds | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [zoom, setZoom] = useState<number>(10);
|
||||
const [currentView, setCurrentView] = useState<{
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
zoom: number;
|
||||
} | null>(null);
|
||||
|
||||
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 buildFilterParam = useCallback(
|
||||
(): string => buildFilterString(filters, features),
|
||||
[filters, features]
|
||||
);
|
||||
|
||||
// Fetch hexagons or postcodes when bounds/filters change
|
||||
useEffect(() => {
|
||||
if (!bounds) return;
|
||||
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||||
const filtersStr = buildFilterParam();
|
||||
|
||||
if (usePostcodeView) {
|
||||
const params = new URLSearchParams({ bounds: boundsStr });
|
||||
if (filtersStr) params.set('filters', filtersStr);
|
||||
params.set('fields', viewFeature || '');
|
||||
const res = await fetch(apiUrl('postcodes', params), {
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
const json: { features: PostcodeFeature[] } = await res.json();
|
||||
setPostcodeData(json.features || []);
|
||||
setRawData([]);
|
||||
} else {
|
||||
const params = new URLSearchParams({
|
||||
resolution: resolution.toString(),
|
||||
bounds: boundsStr,
|
||||
});
|
||||
if (filtersStr) params.set('filters', filtersStr);
|
||||
params.set('fields', viewFeature || '');
|
||||
const res = await fetch(apiUrl('hexagons', params), {
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
const json: ApiResponse = await res.json();
|
||||
setRawData(json.features || []);
|
||||
setPostcodeData([]);
|
||||
}
|
||||
} catch (err) {
|
||||
logNonAbortError('Failed to fetch data', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView]);
|
||||
|
||||
const data = dragData ?? rawData;
|
||||
|
||||
// Compute actual min/max from visible data for the viewed feature
|
||||
const dataRange = useMemo((): [number, number] | null => {
|
||||
if (!viewFeature) return null;
|
||||
const meta = features.find((f) => f.name === viewFeature);
|
||||
if (!meta || meta.type === 'enum') return null;
|
||||
|
||||
if (activeFeature && !dragData) return null;
|
||||
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
|
||||
if (usePostcodeView) {
|
||||
if (postcodeData.length === 0) return null;
|
||||
for (const feat of postcodeData) {
|
||||
const val = feat.properties[`min_${viewFeature}`];
|
||||
if (typeof val === 'number' && !isNaN(val)) {
|
||||
min = Math.min(min, val);
|
||||
max = Math.max(max, val);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (data.length === 0) return null;
|
||||
for (const item of data) {
|
||||
const val = item[`min_${viewFeature}`];
|
||||
if (typeof val === 'number' && !isNaN(val)) {
|
||||
min = Math.min(min, val);
|
||||
max = Math.max(max, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (min === Infinity || max === -Infinity) return null;
|
||||
return [min, max];
|
||||
}, [viewFeature, data, dragData, postcodeData, usePostcodeView, features, activeFeature]);
|
||||
|
||||
// Color range for the legend and hex coloring
|
||||
const colorRange = useMemo((): [number, number] | null => {
|
||||
if (!viewFeature) return null;
|
||||
const meta = features.find((f) => f.name === viewFeature);
|
||||
if (!meta) return null;
|
||||
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
|
||||
return [0, meta.values.length - 1];
|
||||
}
|
||||
if (dataRange) return dataRange;
|
||||
if (activeFeature && dragValue) return dragValue;
|
||||
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
|
||||
return null;
|
||||
}, [viewFeature, features, dataRange, activeFeature, dragValue]);
|
||||
|
||||
const handleViewChange = useCallback(
|
||||
({ resolution: newRes, bounds: newBounds, zoom: newZoom, latitude, longitude }: ViewChangeParams) => {
|
||||
const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`;
|
||||
if (boundsKey !== prevBoundsRef.current) {
|
||||
prevBoundsRef.current = boundsKey;
|
||||
setResolution(newRes);
|
||||
setBounds(newBounds);
|
||||
}
|
||||
setZoom(newZoom);
|
||||
setCurrentView({ latitude, longitude, zoom: newZoom });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const setInitialView = useCallback((view: { latitude: number; longitude: number; zoom: number }) => {
|
||||
setCurrentView(view);
|
||||
setZoom(view.zoom);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
data,
|
||||
rawData,
|
||||
postcodeData,
|
||||
resolution,
|
||||
bounds,
|
||||
loading,
|
||||
zoom,
|
||||
currentView,
|
||||
usePostcodeView,
|
||||
colorRange,
|
||||
handleViewChange,
|
||||
setInitialView,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue