Refactor map page

This commit is contained in:
Andras Schmelczer 2026-02-07 14:34:17 +00:00
parent 29d048ffd4
commit d4d79f0d99
17 changed files with 1014 additions and 878 deletions

View 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,
};
}