ruby homepage changes

This commit is contained in:
Andras Schmelczer 2026-02-21 20:49:56 +00:00
parent 94ebd0f614
commit 7777d4046e
7 changed files with 312 additions and 223 deletions

View file

@ -1,6 +1,5 @@
import { useState, useCallback, useRef, useMemo } from 'react';
import type { FeatureMeta, FeatureFilters, Bounds, HexagonData, ApiResponse } from '../types';
import { apiUrl, logNonAbortError } from '../lib/api';
import { useState, useCallback, useMemo } from 'react';
import type { FeatureMeta, FeatureFilters } from '../types';
interface UseFiltersOptions {
initialFilters: FeatureFilters;
@ -8,15 +7,10 @@ interface UseFiltersOptions {
}
export function useFilters({ initialFilters, features }: UseFiltersOptions) {
// Use refs for bounds/resolution so handleDragStart always has latest values
const boundsRef = useRef<Bounds | null>(null);
const resolutionRef = useRef<number>(8);
const [filters, setFilters] = useState<FeatureFilters>(initialFilters);
const [activeFeature, setActiveFeature] = useState<string | null>(null);
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
const [dragData, setDragData] = useState<HexagonData[] | null>(null);
const dragAbortRef = useRef<AbortController | null>(null);
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
@ -64,40 +58,6 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
setActiveFeature(name);
const fval = filters[name];
setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null);
const currentBounds = boundsRef.current;
if (!currentBounds) return;
if (dragAbortRef.current) dragAbortRef.current.abort();
dragAbortRef.current = new AbortController();
const otherFilters = Object.entries(filters).filter(([k]) => k !== name);
let filtersStr = '';
if (otherFilters.length > 0) {
filtersStr = otherFilters
.map(([n, value]) => {
const m = features.find((f) => f.name === n);
if (m?.type === 'enum') return `${n}:${(value as string[]).join('|')}`;
const [min, max] = value as [number, number];
const maxStr = m?.absolute && max === m.max ? 'inf' : String(max);
return `${n}:${min}:${maxStr}`;
})
.join(',');
}
const boundsStr = `${currentBounds.south},${currentBounds.west},${currentBounds.north},${currentBounds.east}`;
const params = new URLSearchParams({
resolution: resolutionRef.current.toString(),
bounds: boundsStr,
});
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', name);
fetch(apiUrl('hexagons', params), {
signal: dragAbortRef.current.signal,
})
.then((res) => res.json())
.then((json: ApiResponse) => setDragData(json.features))
.catch((err) => logNonAbortError('Failed to fetch drag data', err));
},
[filters, features]
);
@ -112,18 +72,12 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
}
setActiveFeature(null);
setDragValue(null);
setDragData(null);
if (dragAbortRef.current) {
dragAbortRef.current.abort();
dragAbortRef.current = null;
}
}, [activeFeature, dragValue]);
const handleSetFilters = useCallback((newFilters: FeatureFilters) => {
setFilters(newFilters);
setActiveFeature(null);
setDragValue(null);
setDragData(null);
setPinnedFeature(null);
}, []);
@ -139,16 +93,10 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
setPinnedFeature(null);
}, []);
const updateBoundsInfo = useCallback((newBounds: Bounds | null, newResolution: number) => {
boundsRef.current = newBounds;
resolutionRef.current = newResolution;
}, []);
return {
filters,
activeFeature,
dragValue,
dragData,
pinnedFeature,
enabledFeatures,
viewFeature,
@ -164,6 +112,5 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
handleTogglePin,
handleSetPin,
handleCancelPin,
updateBoundsInfo,
};
}

View file

@ -32,7 +32,6 @@ interface UseMapDataOptions {
viewFeature: string | null;
activeFeature: string | null;
dragValue: [number, number] | null;
dragData: HexagonData[] | null;
travelTimeEntries: TravelTimeEntry[];
}
@ -42,7 +41,6 @@ export function useMapData({
viewFeature,
activeFeature,
dragValue,
dragData,
travelTimeEntries,
}: UseMapDataOptions) {
const [rawData, setRawData] = useState<HexagonData[]>([]);
@ -59,6 +57,13 @@ export function useMapData({
const [licenseRequired, setLicenseRequired] = useState(false);
const [freeZone, setFreeZone] = useState<Bounds | null>(null);
// Drag preview state
const [dragHexData, setDragHexData] = useState<HexagonData[] | null>(null);
const [dragPostcodeData, setDragPostcodeData] = useState<PostcodeFeature[] | null>(null);
const dragFeatureRef = useRef<string | null>(null);
const dragAbortRef = useRef<AbortController | null>(null);
const activeFeatureRef = useRef<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const prevBoundsRef = useRef<string>('');
@ -85,6 +90,61 @@ export function useMapData({
return segments.join('|');
}, [travelTimeEntries]);
// Keep activeFeatureRef in sync
useEffect(() => {
activeFeatureRef.current = activeFeature;
}, [activeFeature]);
// Drag prefetch: when activeFeature starts, fetch data excluding that filter
useEffect(() => {
if (!activeFeature || !bounds) return;
if (dragAbortRef.current) dragAbortRef.current.abort();
dragAbortRef.current = new AbortController();
const filtersStr = buildFilterString(filters, features, activeFeature);
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
if (usePostcodeView) {
const params = new URLSearchParams({ bounds: boundsStr });
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', activeFeature);
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json())
.then((json: { features: PostcodeFeature[] }) => {
setDragPostcodeData(json.features);
setDragHexData(null);
dragFeatureRef.current = activeFeature;
})
.catch((err) => logNonAbortError('Failed to fetch drag postcode data', err));
} else {
const params = new URLSearchParams({
resolution: resolution.toString(),
bounds: boundsStr,
});
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', activeFeature);
if (travelParam) params.set('travel', travelParam);
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json())
.then((json: ApiResponse) => {
setDragHexData(json.features);
setDragPostcodeData(null);
dragFeatureRef.current = activeFeature;
})
.catch((err) => logNonAbortError('Failed to fetch drag hex data', err));
}
return () => {
if (dragAbortRef.current) {
dragAbortRef.current.abort();
dragAbortRef.current = null;
}
};
}, [activeFeature, bounds, resolution, filters, features, usePostcodeView, travelParam]);
// Fetch hexagons or postcodes when bounds/filters change
useEffect(() => {
if (!bounds) return;
@ -157,6 +217,13 @@ export function useMapData({
setRawData(json.features);
setPostcodeData([]);
}
// Clear drag data when committed fetch completes and we're not mid-drag
if (!activeFeatureRef.current) {
setDragHexData(null);
setDragPostcodeData(null);
dragFeatureRef.current = null;
}
} catch (err) {
if (!isAbortError(err)) logNonAbortError('Failed to fetch data', err);
} finally {
@ -171,7 +238,9 @@ export function useMapData({
};
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView, travelParam]);
const data = dragData ?? rawData;
// Use drag data when it matches the current view feature, otherwise fall back to rawData
const data = (viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ?? rawData;
const effectivePostcodeData = (viewFeature && dragFeatureRef.current === viewFeature ? dragPostcodeData : null) ?? postcodeData;
// Compute p5/p95 from visible data for the viewed feature
const dataRange = useMemo((): [number, number] | null => {
@ -182,14 +251,14 @@ export function useMapData({
if (!isTravelTime) {
const meta = features.find((f) => f.name === viewFeature);
if (!meta || meta.type === 'enum') return null;
if (activeFeature && !dragData) return null;
if (activeFeature && !dragHexData && !dragPostcodeData) return null;
}
const vals: number[] = [];
if (usePostcodeView && !isTravelTime) {
if (postcodeData.length === 0) return null;
for (const feat of postcodeData) {
if (effectivePostcodeData.length === 0) return null;
for (const feat of effectivePostcodeData) {
if (bounds) {
const [lng, lat] = feat.properties.centroid as [number, number];
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east)
@ -217,7 +286,7 @@ export function useMapData({
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
];
}, [viewFeature, data, dragData, postcodeData, usePostcodeView, features, activeFeature, bounds]);
}, [viewFeature, data, dragHexData, dragPostcodeData, effectivePostcodeData, usePostcodeView, features, activeFeature, bounds]);
// Color range for the legend and hex coloring
const colorRange = useMemo((): [number, number] | null => {
@ -270,7 +339,7 @@ export function useMapData({
return {
data,
rawData,
postcodeData,
postcodeData: effectivePostcodeData,
resolution,
bounds,
loading,