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,196 @@
import { useState, useCallback } from 'react';
import type {
FeatureMeta,
FeatureFilters,
PostcodeFeature,
Property,
HexagonPropertiesResponse,
HexagonStatsResponse,
NumericFeatureStats,
} from '../types';
import { buildFilterString, apiUrl, logNonAbortError } from '../lib/api';
interface SelectedHexagon {
id: string;
type: 'hexagon' | 'postcode';
resolution: number;
}
interface UseHexagonSelectionOptions {
filters: FeatureFilters;
features: FeatureMeta[];
postcodeData: PostcodeFeature[];
resolution: number;
}
export function useHexagonSelection({
filters,
features,
postcodeData,
resolution,
}: 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 [loadingAreaStats, setLoadingAreaStats] = useState(false);
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties' | 'area'>('pois');
const fetchHexagonStats = useCallback(
async (h3: string, res: number, signal?: AbortSignal, fields?: string[]) => {
const params = new URLSearchParams({
h3,
resolution: res.toString(),
});
const filterStr = buildFilterString(filters, features);
if (filterStr) params.append('filters', filterStr);
if (fields) {
params.set('fields', fields.join(','));
}
const response = await fetch(apiUrl('hexagon-stats', params), { signal });
return (await response.json()) as HexagonStatsResponse;
},
[filters, features]
);
const buildPostcodeStats = useCallback(
(postcode: string): HexagonStatsResponse | null => {
const feat = postcodeData.find((f) => f.properties.postcode === postcode);
if (!feat) return null;
const props = feat.properties;
const numeric_features: NumericFeatureStats[] = [];
for (const f of features) {
if (f.type !== 'numeric') continue;
const minVal = props[`min_${f.name}`];
const maxVal = props[`max_${f.name}`];
if (typeof minVal !== 'number' || typeof maxVal !== 'number') continue;
numeric_features.push({
name: f.name,
count: props.count,
min: minVal,
max: maxVal,
mean: (minVal + maxVal) / 2,
});
}
return { count: props.count, numeric_features, enum_features: [] };
},
[postcodeData, features]
);
const fetchHexagonProperties = useCallback(
async (h3: string, res: number, offset = 0) => {
setLoadingProperties(true);
try {
const params = new URLSearchParams({
h3,
resolution: res.toString(),
limit: '100',
offset: offset.toString(),
});
const filterStr = buildFilterString(filters, features);
if (filterStr) params.append('filters', filterStr);
const response = await fetch(apiUrl('hexagon-properties', params));
const data: HexagonPropertiesResponse = await response.json();
if (offset === 0) {
setProperties(data.properties);
} else {
setProperties((prev) => [...prev, ...data.properties]);
}
setPropertiesTotal(data.total);
setPropertiesOffset(offset + data.properties.length);
} catch (err) {
console.error('Failed to fetch properties:', err);
} finally {
setLoadingProperties(false);
}
},
[filters, features]
);
const handleHexagonClick = useCallback(
(id: string, isPostcode = false) => {
if (selectedHexagon?.id === id) {
setSelectedHexagon(null);
setProperties([]);
setAreaStats(null);
} else {
const type = isPostcode ? 'postcode' : 'hexagon';
setSelectedHexagon({ id, type, resolution });
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
setRightPaneTab('area');
if (isPostcode) {
setAreaStats(buildPostcodeStats(id));
setLoadingAreaStats(false);
} else {
setLoadingAreaStats(true);
fetchHexagonStats(id, resolution)
.then((stats) => setAreaStats(stats))
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
.finally(() => setLoadingAreaStats(false));
}
}
},
[selectedHexagon, resolution, fetchHexagonStats, buildPostcodeStats]
);
const handleHexagonHover = useCallback((h3: string | null) => {
setHoveredHexagon(h3);
}, []);
const handleViewPropertiesFromArea = useCallback(() => {
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
setRightPaneTab('properties');
setPropertiesOffset(0);
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
}
}, [selectedHexagon, fetchHexagonProperties]);
const handlePropertiesTabClick = useCallback(() => {
setRightPaneTab('properties');
if (selectedHexagon?.type === 'hexagon' && properties.length === 0 && !loadingProperties) {
setPropertiesOffset(0);
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
}
}, [selectedHexagon, properties.length, loadingProperties, fetchHexagonProperties]);
const handleLoadMoreProperties = useCallback(() => {
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, propertiesOffset);
}
}, [selectedHexagon, propertiesOffset, fetchHexagonProperties]);
const handleCloseSelection = useCallback(() => {
setSelectedHexagon(null);
setProperties([]);
setAreaStats(null);
}, []);
return {
selectedHexagon,
properties,
propertiesTotal,
loadingProperties,
areaStats,
loadingAreaStats,
hoveredHexagon,
rightPaneTab,
setRightPaneTab,
handleHexagonClick,
handleHexagonHover,
handleViewPropertiesFromArea,
handlePropertiesTabClick,
handleLoadMoreProperties,
handleCloseSelection,
};
}