- {enabledFeatureList.map((feature, featureIdx) => {
- const insertTravelCards = featureIdx === travelInsertIdx;
-
- if (isSchoolFilterName(feature.name)) {
- const schoolBackendName = getSchoolBackendFeatureName(feature.name);
- return (
-
- {insertTravelCards && travelCards}
- onRemoveFilter(feature.name)}
- />
-
- );
- }
-
- if (isSpecificCrimeFilterName(feature.name)) {
- const specificCrimeBackendName = getSpecificCrimeFeatureName(feature.name);
- return (
-
- {insertTravelCards && travelCards}
- onRemoveFilter(feature.name)}
- />
-
- );
- }
-
- if (isElectionVoteShareFilterName(feature.name)) {
- const electionVoteShareBackendName = getElectionVoteShareFeatureName(feature.name);
- return (
-
- {insertTravelCards && travelCards}
- onRemoveFilter(feature.name)}
- />
-
- );
- }
-
- if (isEthnicityFilterName(feature.name)) {
- const ethnicityBackendName = getEthnicityFeatureName(feature.name);
- return (
-
- {insertTravelCards && travelCards}
- onRemoveFilter(feature.name)}
- />
-
- );
- }
-
- if (isPoiDistanceFilterName(feature.name)) {
- const poiBackendName = getPoiDistanceFeatureName(feature.name);
- return (
-
- {insertTravelCards && travelCards}
- onRemoveFilter(feature.name)}
- />
-
- );
- }
-
+
+ {groupedFeatures.map((group) => {
+ const travelCount = group.name === TRANSPORT_GROUP ? travelTimeEntries.length : 0;
+ const count = group.features.length + travelCount;
+ if (count === 0) return null;
+ const expanded = isGroupExpanded(group.name);
return (
-
- {insertTravelCards && travelCards}
- {feature.type === 'enum' ? (
-
- ) : (
-
+
+
onToggleGroup(group.name)}
+ className="sticky top-0 z-10 px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800"
+ >
+ {count}
+
+ {expanded && (
+
+ {group.name === TRANSPORT_GROUP && travelCards}
+ {group.features.map((feature) => renderFeatureCard(feature))}
+
)}
-
+
);
})}
- {travelInsertIdx >= enabledFeatureList.length && travelCards}
);
}
diff --git a/frontend/src/components/map/filters/ActiveFiltersPanel.tsx b/frontend/src/components/map/filters/ActiveFiltersPanel.tsx
index 99b0b15..f1470fc 100644
--- a/frontend/src/components/map/filters/ActiveFiltersPanel.tsx
+++ b/frontend/src/components/map/filters/ActiveFiltersPanel.tsx
@@ -22,10 +22,11 @@ interface ActiveFiltersPanelProps {
dragValue: [number, number] | null;
pinnedFeature: string | null;
travelTimeEntries: TravelTimeEntry[];
- travelInsertIdx: number;
filterImpacts?: Record
;
percentileScales: Map;
destinationDropdownPortal: boolean;
+ isGroupExpanded: (name: string) => boolean;
+ onToggleGroup: (name: string) => void;
aiFilterLoading: boolean;
aiFilterError: string | null;
aiFilterErrorType: AiFilterErrorType | null;
@@ -39,7 +40,7 @@ interface ActiveFiltersPanelProps {
onLoginRequired: () => void;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
onRemoveFilter: (name: string) => void;
- onDragStart: (name: string) => void;
+ onDragStart: (name: string, initialValue?: [number, number]) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
@@ -70,10 +71,11 @@ export function ActiveFiltersPanel({
dragValue,
pinnedFeature,
travelTimeEntries,
- travelInsertIdx,
filterImpacts,
percentileScales,
destinationDropdownPortal,
+ isGroupExpanded,
+ onToggleGroup,
aiFilterLoading,
aiFilterError,
aiFilterErrorType,
@@ -108,14 +110,14 @@ export function ActiveFiltersPanel({
>
@@ -182,10 +184,11 @@ export function ActiveFiltersPanel({
dragValue={dragValue}
pinnedFeature={pinnedFeature}
travelTimeEntries={travelTimeEntries}
- travelInsertIdx={travelInsertIdx}
filterImpacts={filterImpacts}
percentileScales={percentileScales}
destinationDropdownPortal={destinationDropdownPortal}
+ isGroupExpanded={isGroupExpanded}
+ onToggleGroup={onToggleGroup}
onFilterChange={onFilterChange}
onRemoveFilter={onRemoveFilter}
onDragStart={onDragStart}
diff --git a/frontend/src/components/map/filters/AddFilterPanel.tsx b/frontend/src/components/map/filters/AddFilterPanel.tsx
index 7fef896..e3c2a87 100644
--- a/frontend/src/components/map/filters/AddFilterPanel.tsx
+++ b/frontend/src/components/map/filters/AddFilterPanel.tsx
@@ -110,14 +110,14 @@ export function AddFilterPanel({
>
{(!collapsed || !isLicensed) && (
diff --git a/frontend/src/components/map/filters/ElectionVoteShareFilterCard.tsx b/frontend/src/components/map/filters/ElectionVoteShareFilterCard.tsx
index 1bb56dc..6fdc876 100644
--- a/frontend/src/components/map/filters/ElectionVoteShareFilterCard.tsx
+++ b/frontend/src/components/map/filters/ElectionVoteShareFilterCard.tsx
@@ -45,7 +45,7 @@ export function ElectionVoteShareFilterCard({
filterImpact?: number;
percentileScale?: PercentileScale;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
- onDragStart: (name: string) => void;
+ onDragStart: (name: string, initialValue?: [number, number]) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
@@ -201,7 +201,7 @@ export function ElectionVoteShareFilterCard({
max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
])
}
- onPointerDown={() => onDragStart(voteShareFeature.name)}
+ onPointerDown={() => onDragStart(voteShareFeature.name, displayValue)}
onPointerUp={() => onDragEnd()}
/>
void;
- onDragStart: (name: string) => void;
+ onDragStart: (name: string, initialValue?: [number, number]) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
@@ -197,7 +197,7 @@ export function EthnicityFilterCard({
max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
])
}
- onPointerDown={() => onDragStart(ethnicityFeature.name)}
+ onPointerDown={() => onDragStart(ethnicityFeature.name, displayValue)}
onPointerUp={() => onDragEnd()}
/>
void;
- onDragStart: (name: string) => void;
+ onDragStart: (name: string, initialValue?: [number, number]) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
@@ -114,7 +114,7 @@ export function NumericFeatureFilterCard({
max >= feature.max! ? (hist?.max ?? feature.max!) : max,
])
}
- onPointerDown={() => onDragStart(feature.name)}
+ onPointerDown={() => onDragStart(feature.name, displayValue)}
onPointerUp={() => onDragEnd()}
/>
void;
- onDragStart: (name: string) => void;
+ onDragStart: (name: string, initialValue?: [number, number]) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
@@ -184,7 +184,7 @@ export function PoiDistanceFilterCard({
max >= sliderMax ? sliderMax : max,
])
}
- onPointerDown={() => onDragStart(poiFeature.name)}
+ onPointerDown={() => onDragStart(poiFeature.name, displayValue)}
onPointerUp={() => onDragEnd()}
/>
void;
- onDragStart: (name: string) => void;
+ onDragStart: (name: string, initialValue?: [number, number]) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
@@ -216,7 +216,7 @@ export function SchoolFilterCard({
max >= (backendFeature?.max ?? dataMax) ? dataMax : max,
])
}
- onPointerDown={() => onDragStart(schoolFeature.name)}
+ onPointerDown={() => onDragStart(schoolFeature.name, displayValue)}
onPointerUp={() => onDragEnd()}
/>
void;
- onDragStart: (name: string) => void;
+ onDragStart: (name: string, initialValue?: [number, number]) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
@@ -197,7 +197,7 @@ export function SpecificCrimeFilterCard({
max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
])
}
- onPointerDown={() => onDragStart(crimeFeature.name)}
+ onPointerDown={() => onDragStart(crimeFeature.name, displayValue)}
onPointerUp={() => onDragEnd()}
/>
void;
onTravelTimeDragEnd: (index: number) => void;
onTravelTimeToggleBest: (index: number) => void;
- onDragStart: (name: string) => void;
+ onDragStart: (name: string, initialValue?: [number, number]) => void;
onDragChange: (value: [number, number]) => void;
}
@@ -60,7 +60,7 @@ export function TravelTimeFilterCards({
onTravelTimeSetDestination(index, slug, label, lat, lon)
}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
- onDragStart={() => onDragStart(fieldKey)}
+ onDragStart={(range) => onDragStart(fieldKey, range)}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
diff --git a/frontend/src/components/map/map-page/useExportController.ts b/frontend/src/components/map/map-page/useExportController.ts
index 64076bc..9fa9bcc 100644
--- a/frontend/src/components/map/map-page/useExportController.ts
+++ b/frontend/src/components/map/map-page/useExportController.ts
@@ -6,7 +6,7 @@ import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../
import { trackEvent } from '../../../lib/analytics';
import type { ExportNotice, ExportState } from './types';
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
-import { buildTravelParam } from '../../../lib/travel-params';
+import { buildTravelParam, dedupeTravelTimeEntries } from '../../../lib/travel-params';
const EXPORT_FILE_NAME = 'perfect-postcode-export.xlsx';
const EXPORT_TIMEOUT_MS = 150_000;
@@ -68,7 +68,7 @@ function triggerExportDownload(blob: Blob, fileName: string): void {
}
function appendTravelStateParams(params: URLSearchParams, entries: TravelTimeEntry[]): void {
- for (const entry of entries) {
+ for (const entry of dedupeTravelTimeEntries(entries)) {
if (!entry.slug) continue;
let value = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
if (entry.useBest) value += ':b';
diff --git a/frontend/src/hooks/useFilters.test.ts b/frontend/src/hooks/useFilters.test.ts
index 5d8133b..2a14e8f 100644
--- a/frontend/src/hooks/useFilters.test.ts
+++ b/frontend/src/hooks/useFilters.test.ts
@@ -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({});
+ });
});
diff --git a/frontend/src/hooks/useFilters.ts b/frontend/src/hooks/useFilters.ts
index a426e39..b455905 100644
--- a/frontend/src/hooks/useFilters.ts
+++ b/frontend/src/hooks/useFilters.ts
@@ -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;
diff --git a/frontend/src/hooks/useMapData.ts b/frontend/src/hooks/useMapData.ts
index 7cbc841..b6f5b03 100644
--- a/frontend/src/hooks/useMapData.ts
+++ b/frontend/src/hooks/useMapData.ts
@@ -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 (0–100) 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(null);
// Drag preview state
const [dragHexData, setDragHexData] = useState(null);
const [dragPostcodeData, setDragPostcodeData] = useState(null);
+ const [dragDataKey, setDragDataKey] = useState('');
const dragFeatureRef = useRef(null);
const dragAbortRef = useRef(null);
const activeFeatureRef = useRef(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('');
-
- // 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,
diff --git a/frontend/src/hooks/useTravelTime.test.ts b/frontend/src/hooks/useTravelTime.test.ts
index d598495..a5b40fb 100644
--- a/frontend/src/hooks/useTravelTime.test.ts
+++ b/frontend/src/hooks/useTravelTime.test.ts
@@ -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,
+ },
+ ]);
+ });
});
diff --git a/frontend/src/hooks/useTravelTime.ts b/frontend/src/hooks/useTravelTime.ts
index 1ae2c49..c49c8f0 100644
--- a/frontend/src/hooks/useTravelTime.ts
+++ b/frontend/src/hooks/useTravelTime.ts
@@ -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(initial?.entries ?? []);
+ const [entries, setEntries] = useState(() =>
+ 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) */
diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts
index d47c8f9..e415258 100644
--- a/frontend/src/i18n/locales/de.ts
+++ b/frontend/src/i18n/locales/de.ts
@@ -829,10 +829,13 @@ const de: Translations = {
showAllStatsFallback:
'Wechseln Sie zu allen Immobilien, um dieses Gebiet ohne aktive Filter zu prüfen.',
showAllStats: 'Alle Immobilien anzeigen',
- closestBlockingFilters: 'Nächste Filter, die dieses Gebiet ausschließen',
+ closestBlockingFilters: 'Nächste Änderungen, um dieses Gebiet einzuschließen',
lowerMinTo: 'Minimum auf {{value}} senken',
raiseMaxTo: 'Maximum auf {{value}} erhöhen',
allowCategory: '{{value}} zulassen',
+ missingFilterValue:
+ 'Kein Wert für diesen Filter; entfernen Sie ihn oder lassen Sie fehlende Werte zu',
+ noFilterDataShort: 'Keine Daten',
travelTo: 'Fahrt zu {{destination}}',
viewProperties: '{{count}} Immobilien ansehen',
viewPropertiesShort: 'Immobilien ansehen',
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 286d8af..dc53a27 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -802,10 +802,12 @@ const en = {
showAllStatsFallback:
'Switch to all properties to inspect this area without the active filters.',
showAllStats: 'Show all properties',
- closestBlockingFilters: 'Closest filters excluding this area',
+ closestBlockingFilters: 'Closest changes to include this area',
lowerMinTo: 'Lower minimum to {{value}}',
raiseMaxTo: 'Raise maximum to {{value}}',
allowCategory: 'Allow {{value}}',
+ missingFilterValue: 'No value for this filter; remove it or allow missing values',
+ noFilterDataShort: 'No data',
travelTo: 'Travel to {{destination}}',
viewProperties: 'View {{count}} Properties',
viewPropertiesShort: 'View properties',
diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts
index 65f8c14..140d471 100644
--- a/frontend/src/i18n/locales/fr.ts
+++ b/frontend/src/i18n/locales/fr.ts
@@ -834,10 +834,13 @@ const fr: Translations = {
showAllStatsFallback:
'Passez à toutes les propriétés pour inspecter cette zone sans les filtres actifs.',
showAllStats: 'Afficher toutes les propriétés',
- closestBlockingFilters: 'Filtres les plus proches qui excluent cette zone',
+ closestBlockingFilters: 'Modifications les plus proches pour inclure cette zone',
lowerMinTo: 'Abaisser le minimum à {{value}}',
raiseMaxTo: 'Augmenter le maximum à {{value}}',
allowCategory: 'Autoriser {{value}}',
+ missingFilterValue:
+ 'Aucune valeur pour ce filtre ; supprimez-le ou autorisez les valeurs manquantes',
+ noFilterDataShort: 'Aucune donnée',
travelTo: 'Trajet vers {{destination}}',
viewProperties: 'Voir {{count}} propriétés',
viewPropertiesShort: 'Voir les propriétés',
diff --git a/frontend/src/i18n/locales/hi.ts b/frontend/src/i18n/locales/hi.ts
index 2b4adc9..910dcce 100644
--- a/frontend/src/i18n/locales/hi.ts
+++ b/frontend/src/i18n/locales/hi.ts
@@ -792,10 +792,12 @@ const hi: Translations = {
showAllStatsFallback:
'सक्रिय फिल्टर के बिना इस क्षेत्र को देखने के लिए सभी संपत्तियों पर जाएं.',
showAllStats: 'सभी संपत्तियां दिखाएं',
- closestBlockingFilters: 'इस क्षेत्र को बाहर करने वाले निकटतम फिल्टर',
+ closestBlockingFilters: 'इस क्षेत्र को शामिल करने के निकटतम बदलाव',
lowerMinTo: 'न्यूनतम को {{value}} तक घटाएं',
raiseMaxTo: 'अधिकतम को {{value}} तक बढ़ाएं',
allowCategory: '{{value}} की अनुमति दें',
+ missingFilterValue: 'इस फिल्टर के लिए कोई मान नहीं है; इसे हटाएं या गायब मानों की अनुमति दें',
+ noFilterDataShort: 'कोई डेटा नहीं',
travelTo: '{{destination}} तक यात्रा',
viewProperties: '{{count}} संपत्तियां देखें',
viewPropertiesShort: 'संपत्तियां देखें',
diff --git a/frontend/src/i18n/locales/hu.ts b/frontend/src/i18n/locales/hu.ts
index ab500ae..491004f 100644
--- a/frontend/src/i18n/locales/hu.ts
+++ b/frontend/src/i18n/locales/hu.ts
@@ -815,10 +815,13 @@ const hu: Translations = {
showAllStatsFallback:
'Váltson az összes ingatlanra, hogy aktív szűrők nélkül tekintse át ezt a területet.',
showAllStats: 'Összes ingatlan mutatása',
- closestBlockingFilters: 'A területet kizáró legközelebbi szűrők',
+ closestBlockingFilters: 'A terület bevonásához legközelebbi módosítások',
lowerMinTo: 'Minimum csökkentése erre: {{value}}',
raiseMaxTo: 'Maximum növelése erre: {{value}}',
allowCategory: '{{value}} engedélyezése',
+ missingFilterValue:
+ 'Ehhez a szűrőhöz nincs érték; távolítsa el, vagy engedélyezze a hiányzó értékeket',
+ noFilterDataShort: 'Nincs adat',
travelTo: 'Utazás ide: {{destination}}',
viewProperties: '{{count}} ingatlan megtekintése',
viewPropertiesShort: 'Ingatlanok megtekintése',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 8482678..c48d174 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -762,10 +762,12 @@ const zh: Translations = {
showAllStatsHint: '筛选前这里有 {{count}} 处房产。切换到全部房产即可查看该区域。',
showAllStatsFallback: '切换到全部房产即可在不应用当前筛选条件的情况下查看该区域。',
showAllStats: '显示全部房产',
- closestBlockingFilters: '最接近的排除此区域的筛选条件',
+ closestBlockingFilters: '纳入该区域所需的最小调整',
lowerMinTo: '将最小值降至 {{value}}',
raiseMaxTo: '将最大值提高至 {{value}}',
allowCategory: '允许 {{value}}',
+ missingFilterValue: '此筛选条件没有值;请移除它或允许缺失值',
+ noFilterDataShort: '无数据',
travelTo: '前往 {{destination}} 的出行',
viewProperties: '查看 {{count}} 处房产',
viewPropertiesShort: '查看房产',
diff --git a/frontend/src/lib/travel-params.test.ts b/frontend/src/lib/travel-params.test.ts
new file mode 100644
index 0000000..50ce5d1
--- /dev/null
+++ b/frontend/src/lib/travel-params.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, it } from 'vitest';
+
+import type { TravelTimeEntry } from '../hooks/useTravelTime';
+import { buildTravelParam, dedupeTravelTimeEntries } from './travel-params';
+
+const bankMedian: TravelTimeEntry = {
+ mode: 'transit',
+ slug: 'bank-tube-station',
+ label: 'Bank',
+ timeRange: [0, 60],
+ useBest: false,
+};
+
+describe('travel-params', () => {
+ it('deduplicates travel entries by backend key and keeps the tightest range', () => {
+ const entries = dedupeTravelTimeEntries([
+ bankMedian,
+ {
+ mode: 'transit',
+ slug: 'bank-tube-station',
+ label: 'Bank tube station',
+ timeRange: [15, 45],
+ useBest: false,
+ },
+ {
+ mode: 'walking',
+ slug: 'bank-tube-station',
+ label: 'Bank',
+ timeRange: [0, 20],
+ useBest: false,
+ },
+ ]);
+
+ expect(entries).toEqual([
+ {
+ mode: 'transit',
+ slug: 'bank-tube-station',
+ label: 'Bank',
+ timeRange: [15, 45],
+ useBest: false,
+ },
+ {
+ mode: 'walking',
+ slug: 'bank-tube-station',
+ label: 'Bank',
+ timeRange: [0, 20],
+ useBest: false,
+ },
+ ]);
+ });
+
+ it('serializes deduplicated entries before backend requests', () => {
+ expect(
+ buildTravelParam([
+ bankMedian,
+ {
+ mode: 'transit',
+ slug: 'bank-tube-station',
+ label: 'Bank',
+ timeRange: [10, 50],
+ useBest: false,
+ },
+ ])
+ ).toBe('transit:bank-tube-station:10:50');
+ });
+
+ it('keeps duplicate blank entries because they are editable placeholders', () => {
+ const blank: TravelTimeEntry = {
+ mode: 'transit',
+ slug: '',
+ label: '',
+ timeRange: null,
+ useBest: false,
+ };
+
+ expect(dedupeTravelTimeEntries([blank, blank])).toHaveLength(2);
+ });
+
+ it('uses an unbounded range when excluding a deduplicated travel filter', () => {
+ expect(
+ buildTravelParam(
+ [
+ bankMedian,
+ {
+ mode: 'transit',
+ slug: 'bank-tube-station',
+ label: 'Bank',
+ timeRange: [10, 50],
+ useBest: false,
+ },
+ ],
+ 'tt_transit_bank-tube-station',
+ true
+ )
+ ).toBe('transit:bank-tube-station:0:1440');
+ });
+});
diff --git a/frontend/src/lib/travel-params.ts b/frontend/src/lib/travel-params.ts
index 11676a6..f13753a 100644
--- a/frontend/src/lib/travel-params.ts
+++ b/frontend/src/lib/travel-params.ts
@@ -1,5 +1,44 @@
import type { TravelTimeEntry } from '../hooks/useTravelTime';
+function mergeTimeRanges(
+ current: [number, number] | null,
+ next: [number, number] | null
+): [number, number] | null {
+ if (!current) return next;
+ if (!next) return current;
+ return [Math.max(current[0], next[0]), Math.min(current[1], next[1])];
+}
+
+export function dedupeTravelTimeEntries(entries: TravelTimeEntry[]): TravelTimeEntry[] {
+ const result: TravelTimeEntry[] = [];
+ const indexByKey = new Map();
+
+ for (const entry of entries) {
+ if (!entry.slug) {
+ result.push(entry);
+ continue;
+ }
+
+ const key = `${entry.mode}:${entry.slug}`;
+ const existingIndex = indexByKey.get(key);
+ if (existingIndex == null) {
+ indexByKey.set(key, result.length);
+ result.push({ ...entry });
+ continue;
+ }
+
+ const existing = result[existingIndex];
+ result[existingIndex] = {
+ ...existing,
+ label: existing.label || entry.label,
+ timeRange: mergeTimeRanges(existing.timeRange, entry.timeRange),
+ useBest: existing.useBest || entry.useBest,
+ };
+ }
+
+ return result;
+}
+
export function buildTravelParam(
entries: TravelTimeEntry[],
excludeFieldKey?: string,
@@ -7,7 +46,7 @@ export function buildTravelParam(
): string {
const segments: string[] = [];
- for (const entry of entries) {
+ for (const entry of dedupeTravelTimeEntries(entries)) {
if (!entry.slug) continue;
let segment = `${entry.mode}:${entry.slug}`;
diff --git a/frontend/src/lib/url-state.test.ts b/frontend/src/lib/url-state.test.ts
index 6e5d6e2..097bd49 100644
--- a/frontend/src/lib/url-state.test.ts
+++ b/frontend/src/lib/url-state.test.ts
@@ -99,6 +99,45 @@ describe('url-state', () => {
expect(params.getAll('tt')).toEqual(['bicycle:bank:Bank:5:25']);
});
+ it('deduplicates travel-time URL params with the tightest range', () => {
+ window.history.replaceState(
+ {},
+ '',
+ '/?tt=transit:bank-tube-station:Bank:0:60&tt=transit:bank-tube-station:Bank:10:45'
+ );
+
+ const state = parseUrlState();
+
+ expect(state.travelTime?.entries).toEqual([
+ {
+ mode: 'transit',
+ slug: 'bank-tube-station',
+ label: 'Bank',
+ timeRange: [10, 45],
+ useBest: false,
+ },
+ ]);
+
+ const params = stateToParams(null, {}, [], new Set(), 'area', [
+ {
+ mode: 'transit',
+ slug: 'bank-tube-station',
+ label: 'Bank',
+ useBest: false,
+ timeRange: [0, 60],
+ },
+ {
+ mode: 'transit',
+ slug: 'bank-tube-station',
+ label: 'Bank',
+ useBest: false,
+ timeRange: [10, 45],
+ },
+ ]);
+
+ expect(params.getAll('tt')).toEqual(['transit:bank-tube-station:Bank:10:45']);
+ });
+
it('round-trips an explicitly empty POI selection', () => {
const params = stateToParams(null, {}, [], new Set(), 'area');
diff --git a/server-rs/src/routes/filter_counts.rs b/server-rs/src/routes/filter_counts.rs
index 7e96989..991ba53 100644
--- a/server-rs/src/routes/filter_counts.rs
+++ b/server-rs/src/routes/filter_counts.rs
@@ -3,12 +3,15 @@ use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
+use axum::Extension;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use tracing::info;
+use crate::auth::OptionalUser;
use crate::consts::NAN_U16;
use crate::data::travel_time::TravelData;
+use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::{parse_filters_with_poi, require_bounds};
use crate::routes::travel_time::parse_optional_travel;
use crate::state::SharedState;
@@ -18,6 +21,7 @@ pub struct FilterCountsParams {
bounds: Option,
filters: Option,
travel: Option,
+ share: Option,
}
#[derive(Serialize)]
@@ -28,12 +32,15 @@ pub struct FilterCountsResponse {
pub async fn get_filter_counts(
State(shared): State>,
+ Extension(user): Extension,
Query(params): Query,
) -> Result, axum::response::Response> {
let state = shared.load_state();
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
+ let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
+ check_license_bounds(&user.0, (south, west, north, east), share_bounds)?;
let quant = state.data.quant_ref();
let poi_quant = state.data.poi_metrics.quant_ref();
diff --git a/server-rs/src/routes/hexagon_stats.rs b/server-rs/src/routes/hexagon_stats.rs
index 01226a1..44085f7 100644
--- a/server-rs/src/routes/hexagon_stats.rs
+++ b/server-rs/src/routes/hexagon_stats.rs
@@ -1,4 +1,4 @@
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use std::sync::Arc;
@@ -11,15 +11,25 @@ use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
+use crate::consts::NAN_U16;
+use crate::data::travel_time::TravelData;
+use crate::data::PropertyData;
+use crate::features::{Feature, FEATURE_GROUPS};
use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::{
cell_for_row_cached, h3_cell_bounds, needs_parent, parse_field_set, parse_filters_with_poi,
- row_passes_filters, row_passes_poi_filters, validate_h3_resolution,
+ row_passes_filters, row_passes_poi_filters, validate_h3_resolution, ParsedEnumFilter,
+ ParsedFilter, ParsedPoiFilter,
};
use crate::state::SharedState;
use super::stats;
-use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters};
+use super::travel_time::{
+ load_travel_data, parse_optional_travel, row_passes_travel_filters, TravelEntry,
+};
+
+const AREA_STATS_EXCLUDED_GROUPS: &[&str] = &["Amenities"];
+const MAX_FILTER_EXCLUSIONS: usize = 5;
#[derive(Serialize)]
pub struct HistogramStats {
@@ -54,6 +64,47 @@ pub struct PricePoint {
pub price: f32,
}
+#[derive(Serialize)]
+pub struct FilterExclusion {
+ pub name: String,
+ pub kind: String,
+ pub direction: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub value: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub min: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub max: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub category: Option,
+ pub relative_difference: f32,
+ pub rejected_count: usize,
+}
+
+fn filter_exclusion_key(exclusion: &FilterExclusion) -> String {
+ format!(
+ "{}\u{1f}{}\u{1f}{}\u{1f}{}",
+ exclusion.kind,
+ exclusion.name,
+ exclusion.direction,
+ exclusion.category.as_deref().unwrap_or("")
+ )
+}
+
+fn missing_filter_exclusion(name: String, kind: &str) -> FilterExclusion {
+ FilterExclusion {
+ name,
+ kind: kind.to_string(),
+ direction: "missing_value".to_string(),
+ value: None,
+ min: None,
+ max: None,
+ category: None,
+ relative_difference: 1.0,
+ rejected_count: 0,
+ }
+}
+
#[derive(Serialize)]
pub struct HexagonStatsResponse {
pub count: usize,
@@ -63,6 +114,8 @@ pub struct HexagonStatsResponse {
pub price_history: Vec,
#[serde(skip_serializing_if = "Option::is_none")]
pub central_postcode: Option,
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ pub filter_exclusions: Vec,
}
#[derive(Deserialize)]
@@ -70,8 +123,9 @@ pub struct HexagonStatsParams {
pub h3: String,
pub resolution: u8,
pub filters: Option,
- /// Comma-separated feature names to include in stats response.
- /// Only listed features are computed; if absent or empty, no features are returned.
+ /// `;;`-separated feature names to include in stats response.
+ /// Only listed features are computed. If absent, area stats default to
+ /// displayable groups; if empty, no feature stats are returned.
pub fields: Option,
/// When set (with journey_slug), pick central_postcode as the postcode with the
/// shortest travel time for this mode+slug (so it has journey data).
@@ -84,6 +138,271 @@ pub struct HexagonStatsParams {
pub share: Option,
}
+fn default_area_stat_field_set() -> HashSet {
+ FEATURE_GROUPS
+ .iter()
+ .filter(|group| !AREA_STATS_EXCLUDED_GROUPS.contains(&group.name))
+ .flat_map(|group| group.features.iter())
+ .map(|feature| match feature {
+ Feature::Numeric(config) => config.name.to_string(),
+ Feature::Enum(config) => config.name.to_string(),
+ })
+ .collect()
+}
+
+pub(super) fn parse_area_stats_field_set(fields: Option<&str>) -> (bool, HashSet) {
+ let (fields_specified, field_set) = parse_field_set(fields);
+ if fields_specified {
+ return (fields_specified, field_set);
+ }
+
+ (true, default_area_stat_field_set())
+}
+
+#[inline]
+fn relative_difference(value: f32, min: f32, max: f32) -> Option<(String, f32)> {
+ let distance = if value < min {
+ min - value
+ } else if value > max {
+ value - max
+ } else {
+ return None;
+ };
+
+ let range = (max - min).abs();
+ let denominator = if range.is_finite() && range > f32::EPSILON {
+ range
+ } else {
+ min.abs().max(max.abs()).max(1.0)
+ };
+ let direction = if value < min {
+ "lower_min".to_string()
+ } else {
+ "raise_max".to_string()
+ };
+ Some((direction, distance / denominator))
+}
+
+pub(super) fn top_filter_exclusions(
+ area_rows: &[usize],
+ numeric_filters: &[ParsedFilter],
+ enum_filters: &[ParsedEnumFilter],
+ poi_filters: &[ParsedPoiFilter],
+ travel_entries: &[TravelEntry],
+ travel_data: &[TravelData],
+ data: &PropertyData,
+) -> Vec {
+ if area_rows.is_empty()
+ || (numeric_filters.is_empty()
+ && enum_filters.is_empty()
+ && poi_filters.is_empty()
+ && !travel_entries
+ .iter()
+ .any(|entry| entry.filter_min.is_some() && entry.filter_max.is_some()))
+ {
+ return Vec::new();
+ }
+
+ let feature_data = &data.feature_data;
+ let num_features = data.num_features;
+ let quant = data.quant_ref();
+ let poi_quant = data.poi_metrics.quant_ref();
+ let mut rejection_counts: HashMap = HashMap::new();
+ let mut best_path: Option> = None;
+
+ for &row in area_rows {
+ let mut path = Vec::new();
+
+ for filter in numeric_filters {
+ let min = quant.decode(filter.feat_idx, filter.min_u16);
+ let max = quant.decode(filter.feat_idx, filter.max_u16);
+ let raw = feature_data[row * num_features + filter.feat_idx];
+ if raw == NAN_U16 {
+ path.push(missing_filter_exclusion(
+ data.feature_names[filter.feat_idx].clone(),
+ "numeric",
+ ));
+ continue;
+ }
+
+ let value = quant.decode(filter.feat_idx, raw);
+ let Some((direction, rel_diff)) = relative_difference(value, min, max) else {
+ continue;
+ };
+
+ path.push(FilterExclusion {
+ name: data.feature_names[filter.feat_idx].clone(),
+ kind: "numeric".to_string(),
+ direction,
+ value: Some(value),
+ min: Some(min),
+ max: Some(max),
+ category: None,
+ relative_difference: rel_diff,
+ rejected_count: 0,
+ });
+ }
+
+ for filter in enum_filters {
+ let raw = feature_data[row * num_features + filter.feat_idx];
+ if raw == NAN_U16 {
+ path.push(missing_filter_exclusion(
+ data.feature_names[filter.feat_idx].clone(),
+ "enum",
+ ));
+ continue;
+ }
+ if filter.allowed.contains(&raw) {
+ continue;
+ }
+
+ let Some(values) = data.enum_values.get(&filter.feat_idx) else {
+ continue;
+ };
+ let Some(category) = values.get(raw as usize) else {
+ continue;
+ };
+
+ path.push(FilterExclusion {
+ name: data.feature_names[filter.feat_idx].clone(),
+ kind: "enum".to_string(),
+ direction: "allow_value".to_string(),
+ value: None,
+ min: None,
+ max: None,
+ category: Some(category.clone()),
+ relative_difference: 1.0,
+ rejected_count: 0,
+ });
+ }
+
+ for filter in poi_filters {
+ let min = poi_quant.decode(filter.metric_idx, filter.min_u16);
+ let max = poi_quant.decode(filter.metric_idx, filter.max_u16);
+ let raw = data
+ .poi_metrics
+ .raw_for_property_row(row, filter.metric_idx);
+ if raw == NAN_U16 {
+ path.push(missing_filter_exclusion(
+ data.poi_metrics.feature_names[filter.metric_idx].clone(),
+ "poi",
+ ));
+ continue;
+ }
+
+ let value = poi_quant.decode(filter.metric_idx, raw);
+ let Some((direction, rel_diff)) = relative_difference(value, min, max) else {
+ continue;
+ };
+
+ path.push(FilterExclusion {
+ name: data.poi_metrics.feature_names[filter.metric_idx].clone(),
+ kind: "poi".to_string(),
+ direction,
+ value: Some(value),
+ min: Some(min),
+ max: Some(max),
+ category: None,
+ relative_difference: rel_diff,
+ rejected_count: 0,
+ });
+ }
+
+ for (filter_index, entry) in travel_entries.iter().enumerate() {
+ let (Some(min), Some(max)) = (entry.filter_min, entry.filter_max) else {
+ continue;
+ };
+
+ let postcode = data.postcode(row);
+ let Some(row_data) = travel_data
+ .get(filter_index)
+ .and_then(|travel| travel.get(postcode))
+ else {
+ path.push(missing_filter_exclusion(
+ format!("tt_{}_{}", entry.mode, entry.slug),
+ "travel",
+ ));
+ continue;
+ };
+
+ let minutes = if entry.use_best {
+ row_data.best_minutes.unwrap_or(row_data.minutes)
+ } else {
+ row_data.minutes
+ } as f32;
+ let Some((direction, rel_diff)) = relative_difference(minutes, min, max) else {
+ continue;
+ };
+
+ path.push(FilterExclusion {
+ name: format!("tt_{}_{}", entry.mode, entry.slug),
+ kind: "travel".to_string(),
+ direction,
+ value: Some(minutes),
+ min: Some(min),
+ max: Some(max),
+ category: None,
+ relative_difference: rel_diff,
+ rejected_count: 0,
+ });
+ }
+
+ if path.is_empty() {
+ continue;
+ }
+
+ for exclusion in &path {
+ *rejection_counts
+ .entry(filter_exclusion_key(exclusion))
+ .or_default() += 1;
+ }
+
+ let path_score = path
+ .iter()
+ .map(|exclusion| exclusion.relative_difference)
+ .sum::();
+ let current_score = best_path
+ .as_ref()
+ .map(|current| {
+ current
+ .iter()
+ .map(|exclusion| exclusion.relative_difference)
+ .sum::()
+ })
+ .unwrap_or(f32::INFINITY);
+
+ let replace = path_score < current_score
+ || (path_score == current_score
+ && best_path
+ .as_ref()
+ .map_or(true, |current| path.len() < current.len()));
+ if replace {
+ best_path = Some(path);
+ }
+ }
+
+ let Some(mut exclusions) = best_path else {
+ return Vec::new();
+ };
+
+ for exclusion in &mut exclusions {
+ exclusion.rejected_count = rejection_counts
+ .get(&filter_exclusion_key(exclusion))
+ .copied()
+ .unwrap_or(0);
+ }
+
+ exclusions.sort_by(|a, b| {
+ a.relative_difference
+ .partial_cmp(&b.relative_difference)
+ .unwrap_or(std::cmp::Ordering::Equal)
+ .then_with(|| b.rejected_count.cmp(&a.rejected_count))
+ .then_with(|| a.name.cmp(&b.name))
+ });
+ exclusions.truncate(MAX_FILTER_EXCLUSIONS);
+ exclusions
+}
+
pub async fn get_hexagon_stats(
State(shared): State>,
Extension(user): Extension,
@@ -124,7 +443,7 @@ pub async fn get_hexagon_stats(
let filters_str = params.filters;
let has_poi_filters = !parsed_poi_filters.is_empty();
- let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
+ let (fields_specified, field_set) = parse_area_stats_field_set(params.fields.as_deref());
let travel_entries = parse_optional_travel(params.travel.as_deref())
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
@@ -151,38 +470,55 @@ pub async fn get_hexagon_stats(
let (min_lat, min_lon, max_lat, max_lon) = h3_cell_bounds(cell, 0.001);
let mut h3_cache: FxHashMap = FxHashMap::default();
+ let mut area_rows: Vec = Vec::new();
let mut matching_rows: Vec = Vec::new();
state
.grid
.for_each_in_bounds(min_lat, min_lon, max_lat, max_lon, |row_idx| {
let row = row_idx as usize;
if cell_for_row_cached(row, precomputed, h3_res, need_parent, &mut h3_cache)
- == cell_u64
- && row_passes_filters(
- row,
- &parsed_filters,
- &parsed_enum_filters,
- feature_data,
- num_features,
- )
- && (!has_poi_filters
- || row_passes_poi_filters(
- row,
- &parsed_poi_filters,
- &state.data.poi_metrics,
- ))
+ != cell_u64
{
- if has_travel {
- let postcode = state.data.postcode(row);
- if !row_passes_travel_filters(postcode, &travel_entries, &travel_data) {
- return;
- }
+ return;
+ }
+
+ area_rows.push(row);
+ if row_passes_filters(
+ row,
+ &parsed_filters,
+ &parsed_enum_filters,
+ feature_data,
+ num_features,
+ ) && (!has_poi_filters
+ || row_passes_poi_filters(row, &parsed_poi_filters, &state.data.poi_metrics))
+ {
+ if has_travel
+ && !row_passes_travel_filters(
+ state.data.postcode(row),
+ &travel_entries,
+ &travel_data,
+ )
+ {
+ return;
}
matching_rows.push(row);
}
});
let total_count = matching_rows.len();
+ let filter_exclusions = if total_count == 0 {
+ top_filter_exclusions(
+ &area_rows,
+ &parsed_filters,
+ &parsed_enum_filters,
+ &parsed_poi_filters,
+ &travel_entries,
+ &travel_data,
+ &state.data,
+ )
+ } else {
+ Vec::new()
+ };
// Pick central_postcode: prefer the postcode with the shortest travel time
// for the requested journey destination (so it has journey data). Fall back
@@ -277,6 +613,7 @@ pub async fn get_hexagon_stats(
enum_features: enum_features_out,
price_history,
central_postcode,
+ filter_exclusions,
})
})
.await
@@ -285,3 +622,30 @@ pub async fn get_hexagon_stats(
Ok(Json(response))
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn default_area_stat_fields_skip_amenities() {
+ let (fields_specified, field_set) = parse_area_stats_field_set(None);
+
+ assert!(fields_specified);
+ assert!(field_set.contains("Property type"));
+ assert!(!field_set.contains("Noise (dB)"));
+ assert!(!field_set.contains("Max available download speed (Mbps)"));
+ assert!(!field_set.contains("Distance to nearest amenity (Cafe) (km)"));
+ }
+
+ #[test]
+ fn explicit_area_stat_fields_are_respected() {
+ let (fields_specified, field_set) =
+ parse_area_stats_field_set(Some("Noise (dB);;Property type"));
+
+ assert!(fields_specified);
+ assert!(field_set.contains("Noise (dB)"));
+ assert!(field_set.contains("Property type"));
+ assert_eq!(field_set.len(), 2);
+ }
+}
diff --git a/server-rs/src/routes/shorten.rs b/server-rs/src/routes/shorten.rs
index 6380154..53611db 100644
--- a/server-rs/src/routes/shorten.rs
+++ b/server-rs/src/routes/shorten.rs
@@ -1,7 +1,7 @@
use std::sync::Arc;
use axum::extract::{Path, State};
-use axum::http::{header, StatusCode};
+use axum::http::{header, HeaderMap, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::Extension;
use axum::Json;
@@ -11,7 +11,8 @@ use tracing::warn;
use url::form_urlencoded;
use crate::auth::OptionalUser;
-use crate::licensing::{is_valid_share_bounds, share_bounds_from_params, ShareBounds};
+use crate::language::{language_from_accept_language, query_string_with_language};
+use crate::licensing::{is_valid_share_bounds, share_params_and_bounds_from_params, ShareBounds};
use crate::pocketbase::get_superuser_token;
use crate::state::SharedState;
@@ -136,6 +137,7 @@ fn is_allowed_param_key(key: &str) -> bool {
| "tab"
| "pc"
| "tt"
+ | "lang"
| "share"
)
}
@@ -180,15 +182,42 @@ fn record_share_bounds(item: &serde_json::Value) -> Option {
}
fn dashboard_redirect_url(params: &str, code: &str, include_share: bool) -> String {
- match (params.is_empty(), include_share) {
- (true, false) => "/dashboard".to_string(),
- (true, true) => format!("/dashboard?share={code}"),
- (false, false) => format!("/dashboard?{params}"),
- (false, true) => format!("/dashboard?{params}&share={code}"),
+ let params = match include_share {
+ true => params_with_share(params, code),
+ false => params.to_string(),
+ };
+
+ if params.is_empty() {
+ "/dashboard".to_string()
+ } else {
+ format!("/dashboard?{params}")
}
}
-fn og_image_url(public_url: &str, params: &str) -> String {
+fn params_with_share(params: &str, code: &str) -> String {
+ let mut out = form_urlencoded::Serializer::new(String::new());
+ for (key, value) in form_urlencoded::parse(params.as_bytes()) {
+ if key == "share" {
+ continue;
+ }
+ out.append_pair(&key, &value);
+ }
+ out.append_pair("share", code);
+ out.finish()
+}
+
+fn og_image_url(
+ public_url: &str,
+ params: &str,
+ language: &str,
+ share_code: Option<&str>,
+) -> String {
+ let params = query_string_with_language(params, language);
+ let params = match share_code {
+ Some(code) => params_with_share(¶ms, code),
+ None => params,
+ };
+
if params.is_empty() {
format!("{}/api/screenshot?og=1", public_url.trim_end_matches('/'))
} else {
@@ -208,7 +237,7 @@ pub async fn post_shorten(
let pb_url = state.pocketbase_url.trim_end_matches('/');
let can_create_share_grant = user_can_create_share_grant(&user);
- let params = match sanitized_query_params(&req.params, !can_create_share_grant) {
+ let mut params = match sanitized_query_params(&req.params, !can_create_share_grant) {
Ok(params) => params,
Err(reason) => {
warn!("Rejected short URL params: {reason}");
@@ -226,7 +255,10 @@ pub async fn post_shorten(
let code = generate_code();
let share_bounds = if can_create_share_grant {
- share_bounds_from_params(¶ms)
+ share_params_and_bounds_from_params(¶ms).map(|(share_params, share_bounds)| {
+ params = share_params;
+ share_bounds
+ })
} else {
None
};
@@ -275,6 +307,7 @@ pub async fn post_shorten(
pub async fn get_share_links(
State(shared): State>,
Extension(user): Extension,
+ headers: HeaderMap,
) -> Response {
let state = shared.load_state();
let user = match user.0 {
@@ -328,6 +361,11 @@ pub async fn get_share_links(
};
let public_url = state.public_url.trim_end_matches('/');
+ let language = language_from_accept_language(
+ headers
+ .get(header::ACCEPT_LANGUAGE)
+ .and_then(|value| value.to_str().ok()),
+ );
let links: Vec = body["items"]
.as_array()
.map(|arr| {
@@ -335,10 +373,17 @@ pub async fn get_share_links(
.map(|item| {
let code = item["code"].as_str().unwrap_or("").to_string();
let params = item["params"].as_str().unwrap_or("").to_string();
+ let has_share_grant = record_share_bounds(item).is_some();
+ let og_image_url = og_image_url(
+ public_url,
+ ¶ms,
+ language,
+ has_share_grant.then_some(code.as_str()),
+ );
ShareLinkListItem {
url: format!("{public_url}/s/{code}"),
code,
- og_image_url: og_image_url(public_url, ¶ms),
+ og_image_url,
params,
click_count: json_number_as_u64(&item["click_count"]),
created: item["created"].as_str().unwrap_or("").to_string(),
@@ -354,8 +399,14 @@ pub async fn get_share_links(
pub async fn get_short_url(
State(shared): State>,
Path(code): Path,
+ headers: HeaderMap,
) -> Response {
let state = shared.load_state();
+ let language = language_from_accept_language(
+ headers
+ .get(header::ACCEPT_LANGUAGE)
+ .and_then(|value| value.to_str().ok()),
+ );
if code.is_empty() || code.len() > 20 || !code.bytes().all(|b| b.is_ascii_alphanumeric()) {
return StatusCode::BAD_REQUEST.into_response();
@@ -428,9 +479,14 @@ pub async fn get_short_url(
Err(err) => warn!("PocketBase click count update failed: {err}"),
}
}
- let redirect_url =
- dashboard_redirect_url(¶ms, &code, record_share_bounds(item).is_some());
- let og_image_url = og_image_url(&state.public_url, ¶ms);
+ let has_share_grant = record_share_bounds(item).is_some();
+ let redirect_url = dashboard_redirect_url(¶ms, &code, has_share_grant);
+ let og_image_url = og_image_url(
+ &state.public_url,
+ ¶ms,
+ language,
+ has_share_grant.then_some(code.as_str()),
+ );
let og_url = format!("{}/s/{code}", state.public_url.trim_end_matches('/'));
let og_title = "Perfect Postcode | Every neighbourhood in England";
let og_description = "Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map.";
@@ -454,6 +510,7 @@ pub async fn get_short_url(
+
{og_title}