This commit is contained in:
Andras Schmelczer 2026-05-15 08:17:05 +01:00
parent 3fa95819e3
commit e9a06417ad
32 changed files with 1531 additions and 407 deletions

View file

@ -27,21 +27,24 @@ describe('useFilters', () => {
);
act(() => {
result.current.handleDragStart('price');
result.current.handleDragStart('price', [0, 100]);
});
expect(result.current.activeFeature).toBe('price');
expect(result.current.viewSource).toBe('drag');
expect(result.current.dragValue).toEqual([0, 100]);
expect(result.current.filterRange).toEqual([0, 100]);
act(() => {
result.current.handleDragEnd();
});
expect(result.current.activeFeature).toBeNull();
expect(result.current.dragValue).toBeNull();
expect(result.current.filters.price).toEqual([0, 100]);
act(() => {
result.current.handleDragStart('price');
result.current.handleDragStart('price', [0, 100]);
result.current.handleDragChange([10, 90]);
});
@ -55,4 +58,29 @@ describe('useFilters', () => {
expect(result.current.activeFeature).toBeNull();
expect(result.current.filters.price).toEqual([10, 90]);
});
it('uses the provided initial range for drag-only feature keys', () => {
const { result } = renderHook(() =>
useFilters({
initialFilters: {},
features,
})
);
act(() => {
result.current.handleDragStart('tt_car_station', [15, 45]);
});
expect(result.current.activeFeature).toBe('tt_car_station');
expect(result.current.dragValue).toEqual([15, 45]);
expect(result.current.filterRange).toEqual([15, 45]);
act(() => {
result.current.handleDragEnd();
});
expect(result.current.activeFeature).toBeNull();
expect(result.current.dragValue).toBeNull();
expect(result.current.filters).toEqual({});
});
});

View file

@ -416,10 +416,12 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
}, []);
const handleDragStart = useCallback(
(name: string) => {
(name: string, initialValue?: [number, number]) => {
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') return;
pendingDragRef.current = name;
setDragValue(initialValue ?? null);
dragValueRef.current = initialValue ?? null;
setActiveFeature(name);
},
[features]
@ -440,6 +442,8 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
// Click without drag — no filter value was changed, just clear preview state.
pendingDragRef.current = null;
setActiveFeature(null);
setDragValue(null);
dragValueRef.current = null;
return;
}
const af = dragActiveRef.current;
@ -458,6 +462,8 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
if (pendingDragRef.current) {
pendingDragRef.current = null;
setActiveFeature(null);
setDragValue(null);
dragValueRef.current = null;
return null;
}
const dv = dragValueRef.current;

View file

@ -24,6 +24,7 @@ import { getPoiDistanceFeatureName } from '../lib/poi-distance-filter';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
import { type TravelTimeEntry } from './useTravelTime';
import { buildTravelParam as serializeTravelParam } from '../lib/travel-params';
/** Return the p-th percentile (0100) from a sorted array via linear interpolation. */
function percentile(sorted: number[], p: number): number {
@ -70,12 +71,18 @@ export function useMapData({
longitude: number;
zoom: number;
} | null>(null);
const [currentVisibleView, setCurrentVisibleView] = useState<{
latitude: number;
longitude: number;
zoom: number;
} | null>(null);
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 [dragDataKey, setDragDataKey] = useState<string>('');
const dragFeatureRef = useRef<string | null>(null);
const dragAbortRef = useRef<AbortController | null>(null);
const activeFeatureRef = useRef<string | null>(null);
@ -119,32 +126,19 @@ export function useMapData({
);
const filtersParam = useMemo(() => buildFilterParam(), [buildFilterParam]);
// Build the travel param string from entries with destinations.
// Format: mode:slug[:best][:min:max] — server filters rows outside [min,max].
// When excludeFieldKey is set, that entry uses a wide range (0:1440) instead of
// the committed range. This still filters out rows with no travel data (the server
// skips rows where minutes=None when any range is set) while including all actual values.
// Format: mode:slug[:best][:min:max]. For drag preview, the active travel
// filter uses an unbounded range so rows with travel data stay visible.
const buildTravelParam = useCallback(
(excludeFieldKey?: string): string => {
const segments: string[] = [];
for (const entry of travelTimeEntries) {
if (!entry.slug) continue;
let seg = `${entry.mode}:${entry.slug}`;
if (entry.useBest) seg += ':best';
const isExcluded = excludeFieldKey === `tt_${entry.mode}_${entry.slug}`;
if (isExcluded) {
seg += ':0:1440';
} else if (entry.timeRange) {
seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
}
segments.push(seg);
}
return segments.join('|');
},
(excludeFieldKey?: string): string =>
serializeTravelParam(travelTimeEntries, excludeFieldKey, true),
[travelTimeEntries]
);
const travelParam = useMemo(() => buildTravelParam(), [buildTravelParam]);
const filterStateKey = useMemo(
() => `${filtersParam}|${travelParam}`,
[filtersParam, travelParam]
);
const boundsParam = useMemo(
() => (bounds ? `${bounds.south},${bounds.west},${bounds.north},${bounds.east}` : ''),
[bounds]
@ -176,28 +170,13 @@ export function useMapData({
]
);
const [loadedDataKey, setLoadedDataKey] = useState<string>('');
// Keep activeFeatureRef in sync
useEffect(() => {
activeFeatureRef.current = activeFeature;
}, [activeFeature]);
useEffect(() => {
if (activeFeature) return;
latestDragRequestKeyRef.current = '';
dragFeatureRef.current = null;
setDragHexData(null);
setDragPostcodeData(null);
}, [activeFeature]);
// Drag prefetch: when activeFeature starts, fetch data excluding that filter.
// For regular filters: excludes the filter from the filter string.
// For travel time: excludes the time range from that entry's travel param segment.
useEffect(() => {
if (!activeFeature || !bounds) return;
if (dragAbortRef.current) dragAbortRef.current.abort();
dragAbortRef.current = new AbortController();
const previousDragStateRef = useRef<{ activeFeature: string | null; filterStateKey: string }>({
activeFeature: null,
filterStateKey,
});
const resetPreviewScaleAfterSliderRef = useRef(false);
const activeDragRequest = useMemo(() => {
if (!activeFeature || !bounds) return null;
const filtersStr = buildFilterString(filters, features, activeFeature);
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
@ -218,7 +197,58 @@ export function useMapData({
viewFeatureIsEnum && dataViewFeature ? dataViewFeature : '',
shareCode ?? '',
].join('|');
return { boundsStr, dragTravelParam, fieldsParam, filtersStr, requestKey };
}, [
activeFeature,
bounds,
buildTravelParam,
dataViewFeature,
features,
filters,
getBackendFeatureName,
resolution,
shareCode,
travelParam,
usePostcodeView,
viewFeatureIsEnum,
]);
// Keep activeFeatureRef in sync
useEffect(() => {
activeFeatureRef.current = activeFeature;
}, [activeFeature]);
useEffect(() => {
const previous = previousDragStateRef.current;
if (!activeFeature && previous.activeFeature && previous.filterStateKey !== filterStateKey) {
resetPreviewScaleAfterSliderRef.current = true;
}
previousDragStateRef.current = { activeFeature, filterStateKey };
}, [activeFeature, filterStateKey]);
useEffect(() => {
if (activeFeature) return;
latestDragRequestKeyRef.current = '';
dragFeatureRef.current = null;
setDragDataKey('');
setDragHexData(null);
setDragPostcodeData(null);
}, [activeFeature]);
// Drag prefetch: when activeFeature starts, fetch data excluding that filter.
// For regular filters: excludes the filter from the filter string.
// For travel time: excludes the time range from that entry's travel param segment.
useEffect(() => {
if (!activeFeature || !activeDragRequest) return;
if (dragAbortRef.current) dragAbortRef.current.abort();
dragAbortRef.current = new AbortController();
const { boundsStr, dragTravelParam, fieldsParam, filtersStr, requestKey } = activeDragRequest;
latestDragRequestKeyRef.current = requestKey;
setDragDataKey('');
dragFeatureRef.current = null;
if (usePostcodeView) {
const params = new URLSearchParams({ bounds: boundsStr });
@ -234,6 +264,7 @@ export function useMapData({
if (latestDragRequestKeyRef.current !== requestKey) return;
setDragPostcodeData(json.features);
setDragHexData(null);
setDragDataKey(requestKey);
dragFeatureRef.current = activeFeature;
})
.catch((err) => logNonAbortError('Failed to fetch drag postcode data', err));
@ -254,6 +285,7 @@ export function useMapData({
if (latestDragRequestKeyRef.current !== requestKey) return;
setDragHexData(json.features);
setDragPostcodeData(null);
setDragDataKey(requestKey);
dragFeatureRef.current = activeFeature;
})
.catch((err) => logNonAbortError('Failed to fetch drag hex data', err));
@ -270,15 +302,9 @@ export function useMapData({
};
}, [
activeFeature,
bounds,
resolution,
filters,
features,
usePostcodeView,
travelParam,
buildTravelParam,
activeDragRequest,
dataViewFeature,
getBackendFeatureName,
usePostcodeView,
viewFeatureIsEnum,
shareCode,
]);
@ -386,6 +412,7 @@ export function useMapData({
if (!activeFeatureRef.current) {
setDragHexData(null);
setDragPostcodeData(null);
setDragDataKey('');
dragFeatureRef.current = null;
}
setLoading(false);
@ -420,18 +447,16 @@ export function useMapData({
shareCode,
]);
// Use drag data when it matches the current view feature, otherwise fall back to rawData
const data =
(activeFeature && viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ??
rawData;
const effectivePostcodeData =
(activeFeature && viewFeature && dragFeatureRef.current === viewFeature
? dragPostcodeData
: null) ?? postcodeData;
// Use drag data only when it matches the current view feature and request key.
const hasMatchingDragData =
Boolean(activeFeature && viewFeature && activeDragRequest) &&
dragFeatureRef.current === viewFeature &&
dragDataKey === activeDragRequest?.requestKey;
const data = (hasMatchingDragData ? dragHexData : null) ?? rawData;
const effectivePostcodeData = (hasMatchingDragData ? dragPostcodeData : null) ?? postcodeData;
// Compute p5/p95 from committed data for the viewed feature.
// Always uses rawData/postcodeData (not drag preview data) so the color
// scale stays stable while dragging a filter slider.
// Compute p5/p95 from the data currently being drawn. During slider drags
// this uses the drag-preview data so the colour scale resets to that preview.
const dataRange = useMemo((): [number, number] | null => {
if (!dataViewFeature) return null;
@ -445,8 +470,8 @@ export function useMapData({
const vals: number[] = [];
if (usePostcodeView) {
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)
@ -456,8 +481,8 @@ export function useMapData({
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
} else {
if (rawData.length === 0) return null;
for (const item of rawData) {
if (data.length === 0) return null;
for (const item of data) {
if (bounds) {
const { lat, lon } = item;
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
@ -474,7 +499,7 @@ export function useMapData({
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
];
}, [dataViewFeature, rawData, postcodeData, usePostcodeView, features, bounds]);
}, [dataViewFeature, data, effectivePostcodeData, usePostcodeView, features, bounds]);
// Live color range for the legend and hex coloring.
const liveColorRange = useMemo((): [number, number] | null => {
@ -505,7 +530,10 @@ export function useMapData({
useEffect(() => {
setFrozenPreviewRange((prev) => {
if (!pinnedDataViewFeature) return prev ? null : prev;
if (!pinnedDataViewFeature) {
resetPreviewScaleAfterSliderRef.current = false;
return prev ? null : prev;
}
return prev?.feature === pinnedDataViewFeature ? prev : null;
});
}, [pinnedDataViewFeature]);
@ -524,11 +552,13 @@ export function useMapData({
: null;
if (!rangeToFreeze) return;
const resetAfterSlider = resetPreviewScaleAfterSliderRef.current;
setFrozenPreviewRange((prev) =>
prev?.feature === pinnedDataViewFeature
? prev
: { feature: pinnedDataViewFeature, range: rangeToFreeze }
resetAfterSlider || prev?.feature !== pinnedDataViewFeature
? { feature: pinnedDataViewFeature, range: rangeToFreeze }
: prev
);
if (resetAfterSlider) resetPreviewScaleAfterSliderRef.current = false;
}, [
dataRange,
dataRequestKey,
@ -583,6 +613,8 @@ export function useMapData({
zoom: newZoom,
latitude,
longitude,
visibleLatitude,
visibleLongitude,
}: ViewChangeParams) => {
const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`;
if (boundsKey !== prevBoundsRef.current) {
@ -592,6 +624,11 @@ export function useMapData({
}
setZoom(newZoom);
setCurrentView({ latitude, longitude, zoom: newZoom });
setCurrentVisibleView({
latitude: visibleLatitude ?? latitude,
longitude: visibleLongitude ?? longitude,
zoom: newZoom,
});
},
[]
);
@ -599,6 +636,7 @@ export function useMapData({
const setInitialView = useCallback(
(view: { latitude: number; longitude: number; zoom: number }) => {
setCurrentView(view);
setCurrentVisibleView(view);
setZoom(view.zoom);
},
[]
@ -613,6 +651,7 @@ export function useMapData({
loading,
zoom,
currentView,
currentVisibleView,
usePostcodeView,
colorRange,
canResetPreviewScale,

View file

@ -65,4 +65,51 @@ describe('useTravelTime', () => {
expect(result.current.entries).toEqual([replacement]);
expect(result.current.activeEntries).toEqual([replacement]);
});
it('deduplicates initial and replacement entries using the tightest range', () => {
const wide: TravelTimeEntry = {
mode: 'transit',
slug: 'bank-tube-station',
label: 'Bank',
timeRange: [0, 60],
useBest: false,
};
const tight: TravelTimeEntry = {
mode: 'transit',
slug: 'bank-tube-station',
label: 'Bank',
timeRange: [10, 45],
useBest: false,
};
const replacement: TravelTimeEntry = {
mode: 'transit',
slug: 'bank-tube-station',
label: 'Bank',
timeRange: [20, 40],
useBest: true,
};
const { result } = renderHook(() => useTravelTime({ entries: [wide, tight] }));
expect(result.current.entries).toEqual([
{
mode: 'transit',
slug: 'bank-tube-station',
label: 'Bank',
timeRange: [10, 45],
useBest: false,
},
]);
act(() => result.current.handleSetEntries([wide, replacement]));
expect(result.current.entries).toEqual([
{
mode: 'transit',
slug: 'bank-tube-station',
label: 'Bank',
timeRange: [20, 40],
useBest: true,
},
]);
});
});

View file

@ -2,6 +2,7 @@ import { useState, useCallback, useMemo } from 'react';
import type { ComponentType } from 'react';
import { useTranslation } from 'react-i18next';
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon } from '../components/ui/icons';
import { dedupeTravelTimeEntries } from '../lib/travel-params';
export type TransportMode = 'car' | 'bicycle' | 'walking' | 'transit';
@ -75,7 +76,9 @@ export interface TravelTimeInitial {
}
export function useTravelTime(initial?: TravelTimeInitial) {
const [entries, setEntries] = useState<TravelTimeEntry[]>(initial?.entries ?? []);
const [entries, setEntries] = useState<TravelTimeEntry[]>(() =>
dedupeTravelTimeEntries(initial?.entries ?? [])
);
const handleAddEntry = useCallback((mode: TransportMode) => {
setEntries((prev) => [...prev, { mode, slug: '', label: '', timeRange: null, useBest: false }]);
@ -87,26 +90,32 @@ export function useTravelTime(initial?: TravelTimeInitial) {
const handleSetDestination = useCallback((index: number, slug: string, label: string) => {
setEntries((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, slug, label, timeRange: slug ? [0, 120] : null } : entry
dedupeTravelTimeEntries(
prev.map((entry, i) =>
i === index ? { ...entry, slug, label, timeRange: slug ? [0, 120] : null } : entry
)
)
);
}, []);
const handleTimeRangeChange = useCallback((index: number, range: [number, number]) => {
setEntries((prev) =>
prev.map((entry, i) => (i === index ? { ...entry, timeRange: range } : entry))
dedupeTravelTimeEntries(
prev.map((entry, i) => (i === index ? { ...entry, timeRange: range } : entry))
)
);
}, []);
const handleToggleBest = useCallback((index: number) => {
setEntries((prev) =>
prev.map((entry, i) => (i === index ? { ...entry, useBest: !entry.useBest } : entry))
dedupeTravelTimeEntries(
prev.map((entry, i) => (i === index ? { ...entry, useBest: !entry.useBest } : entry))
)
);
}, []);
const handleSetEntries = useCallback((newEntries: TravelTimeEntry[]) => {
setEntries(newEntries);
setEntries(dedupeTravelTimeEntries(newEntries));
}, []);
/** Entries that have a destination selected (slug is set) */