This commit is contained in:
Andras Schmelczer 2026-05-12 22:30:36 +01:00
parent 81a16f543c
commit 63713c3a2b
15 changed files with 492 additions and 159 deletions

View file

@ -141,19 +141,21 @@ describe('api utilities', () => {
it('serializes amenity distance filters using their selected backend feature', () => {
const features: FeatureMeta[] = [
{ name: 'Distance to nearest park (km)', type: 'numeric', min: 0, max: 2 },
{ name: 'Distance to nearest grocery store (km)', type: 'numeric', min: 0, max: 5 },
{ name: 'Distance to nearest amenity (Park) (km)', type: 'numeric', min: 0, max: 2 },
{ name: 'Distance to nearest amenity (Café) (km)', type: 'numeric', min: 0, max: 5 },
];
expect(
buildFilterString(
{
[createPoiDistanceFilterKey('Distance to nearest park (km)', 1)]: [0, 0.5],
[createPoiDistanceFilterKey('Distance to nearest grocery store (km)', 2)]: [0, 1],
[createPoiDistanceFilterKey('Distance to nearest amenity (Park) (km)', 1)]: [0, 0.5],
[createPoiDistanceFilterKey('Distance to nearest amenity (Café) (km)', 2)]: [0, 1],
},
features
)
).toBe('Distance to nearest park (km):0:0.5;;Distance to nearest grocery store (km):0:1');
).toBe(
'Distance to nearest amenity (Park) (km):0:0.5;;Distance to nearest amenity (Café) (km):0:1'
);
});
it('serializes amenity count filters using their selected backend feature', () => {

View file

@ -40,6 +40,7 @@ export const SMALLEST_VISIBLE_HEXAGON_RESOLUTION = Math.max(
);
export const POSTCODE_ZOOM_THRESHOLD = 15;
export const POSTCODE_SEARCH_ZOOM = 16;
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [46, 204, 113] },

View file

@ -102,7 +102,7 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</>
),
'Street tree density (%)': (
'Street tree density percentile': (
<>
<path d="M12 22V12" />
<path d="M6 22h12" />

View file

@ -1,39 +1,13 @@
import { describe, expect, it } from 'vitest';
import type { FeatureGroup, FeatureMeta } from '../types';
import { groupFeaturesByCategory, orderFilterGroups } from './features';
function group(name: string): FeatureGroup {
return { name, features: [] };
}
import type { FeatureMeta } from '../types';
import { groupFeaturesByCategory } from './features';
function feature(name: string, groupName: string): FeatureMeta {
return { name, group: groupName, type: 'numeric' };
}
describe('feature grouping utilities', () => {
it('orders filter groups around transport, property, amenities, and area development', () => {
const groups = [
group('Properties'),
group('Education'),
group('Area development'),
group('Property prices'),
group('Crime'),
group('Amenities'),
group('Transport'),
];
expect(orderFilterGroups(groups).map((item) => item.name)).toEqual([
'Transport',
'Property prices',
'Properties',
'Amenities',
'Education',
'Crime',
'Area development',
]);
});
it('keeps feature order inside grouped categories', () => {
const groups = groupFeaturesByCategory([
feature('A', 'Crime'),

View file

@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import type { FeatureMeta } from '../types';
import {
POI_COUNT_2KM_FILTER_NAME,
POI_DISTANCE_FILTER_NAME,
TRANSPORT_DISTANCE_FILTER_NAME,
getPoiFilterFeatureOptions,
getPoiFilterName,
} from './poi-distance-filter';
const numeric = (name: string): FeatureMeta => ({
name,
type: 'numeric',
min: 0,
max: 5,
});
describe('poi-distance-filter', () => {
it('splits public transport distance options out of amenity distance options', () => {
const features = [
numeric('Distance to nearest amenity (Cafe) (km)'),
numeric('Distance to nearest amenity (Park) (km)'),
numeric('Distance to nearest amenity (Bus stop) (km)'),
numeric('Distance to nearest amenity (Rail station) (km)'),
];
expect(
getPoiFilterFeatureOptions(features, POI_DISTANCE_FILTER_NAME).map((f) => f.name)
).toEqual(['Distance to nearest amenity (Cafe) (km)', 'Distance to nearest amenity (Park) (km)']);
expect(
getPoiFilterFeatureOptions(features, TRANSPORT_DISTANCE_FILTER_NAME).map((f) => f.name)
).toEqual([
'Distance to nearest amenity (Bus stop) (km)',
'Distance to nearest amenity (Rail station) (km)',
]);
});
it('excludes public transport categories from amenity count options', () => {
const features = [
numeric('Number of amenities (Cafe) within 2km'),
numeric('Number of amenities (Bus stop) within 2km'),
numeric('Number of amenities (Rail station) within 2km'),
];
expect(
getPoiFilterFeatureOptions(features, POI_COUNT_2KM_FILTER_NAME).map((f) => f.name)
).toEqual(['Number of amenities (Cafe) within 2km']);
});
it('classifies transport distance features without exposing transport counts', () => {
expect(getPoiFilterName('Distance to nearest amenity (Bus stop) (km)')).toBe(
TRANSPORT_DISTANCE_FILTER_NAME
);
expect(getPoiFilterName('Number of amenities (Bus stop) within 2km')).toBeNull();
});
it('recognizes the old static park distance name for URL migration only', () => {
expect(getPoiFilterName('Distance to nearest park (km)')).toBe(POI_DISTANCE_FILTER_NAME);
expect(
getPoiFilterFeatureOptions([numeric('Distance to nearest park (km)')], POI_DISTANCE_FILTER_NAME)
).toEqual([]);
});
});

View file

@ -1,10 +1,12 @@
import type { FeatureFilters, FeatureMeta } from '../types';
export const POI_DISTANCE_FILTER_NAME = 'Amenity distance';
export const TRANSPORT_DISTANCE_FILTER_NAME = 'Closest transport option';
export const POI_COUNT_2KM_FILTER_NAME = 'Amenities within 2km';
export const POI_COUNT_5KM_FILTER_NAME = 'Amenities within 5km';
export const POI_FILTER_NAMES = [
TRANSPORT_DISTANCE_FILTER_NAME,
POI_DISTANCE_FILTER_NAME,
POI_COUNT_2KM_FILTER_NAME,
POI_COUNT_5KM_FILTER_NAME,
@ -15,23 +17,15 @@ type PoiMetric = 'distance' | 'count_2km' | 'count_5km';
export const POI_DISTANCE_FILTER_KEY_PREFIX = `${POI_DISTANCE_FILTER_NAME}:`;
export const POI_DISTANCE_FEATURE_NAMES = [
'Distance to nearest park (km)',
'Distance to nearest grocery store (km)',
'Distance to nearest tube station (km)',
'Distance to nearest rail station (km)',
'Distance to nearest Waitrose (km)',
'Distance to nearest Tesco (km)',
'Distance to nearest cafe (km)',
'Distance to nearest pub (km)',
'Distance to nearest restaurant (km)',
] as const;
const STATIC_AMENITY_DISTANCE_FEATURE_NAME_SET = new Set<string>(POI_DISTANCE_FEATURE_NAMES);
const STATIC_AMENITY_DISTANCE_AGGREGATE_OPTIONS = [
'Distance to nearest park (km)',
'Distance to nearest grocery store (km)',
] as const;
const TRANSPORT_POI_CATEGORIES = new Set([
'Airport',
'Bus station',
'Bus stop',
'Ferry',
'Rail station',
'Taxi rank',
'Tube station',
]);
const DYNAMIC_DISTANCE_RE = /^Distance to nearest amenity \((.+)\) \(km\)$/;
const DYNAMIC_COUNT_RE = /^Number of amenities \((.+)\) within (2|5)km$/;
@ -57,6 +51,15 @@ const POI_FILTER_CONFIGS: Record<
step: 0.1,
suffix: ' km',
},
[TRANSPORT_DISTANCE_FILTER_NAME]: {
metric: 'distance',
keyPrefix: `${TRANSPORT_DISTANCE_FILTER_NAME}:`,
description: 'Distance to nearby transport stops',
detail: 'Filter by distance to one nearby public transport type at a time.',
defaultMax: 5,
step: 0.1,
suffix: ' km',
},
[POI_COUNT_2KM_FILTER_NAME]: {
metric: 'count_2km',
keyPrefix: `${POI_COUNT_2KM_FILTER_NAME}:`,
@ -86,10 +89,7 @@ function isDynamicPoiDistanceFeatureName(name: string): boolean {
}
function getPoiMetric(name: string): PoiMetric | null {
if (
isDynamicPoiDistanceFeatureName(name) ||
STATIC_AMENITY_DISTANCE_FEATURE_NAME_SET.has(name)
) {
if (DYNAMIC_DISTANCE_RE.test(name)) {
return 'distance';
}
@ -98,6 +98,24 @@ function getPoiMetric(name: string): PoiMetric | null {
return countMatch[2] === '2' ? 'count_2km' : 'count_5km';
}
export function isTransportPoiFeatureName(name: string): boolean {
const category = getPoiFeatureCategory(name);
return category ? TRANSPORT_POI_CATEGORIES.has(category) : false;
}
function getFilterNameForFeature(name: string): PoiFilterName | null {
const metric = getPoiMetric(name);
if (!metric) return null;
const isTransport = isTransportPoiFeatureName(name);
if (metric === 'distance' && isTransport) return TRANSPORT_DISTANCE_FILTER_NAME;
if (metric === 'distance') return POI_DISTANCE_FILTER_NAME;
if (isTransport) return null;
if (metric === 'count_2km') return POI_COUNT_2KM_FILTER_NAME;
if (metric === 'count_5km') return POI_COUNT_5KM_FILTER_NAME;
return null;
}
function getFilterNameForMetric(metric: PoiMetric): PoiFilterName {
if (metric === 'count_2km') return POI_COUNT_2KM_FILTER_NAME;
if (metric === 'count_5km') return POI_COUNT_5KM_FILTER_NAME;
@ -115,9 +133,7 @@ export function getPoiFeatureCategory(name: string): string | null {
}
export function isPoiDistanceFeatureName(name: string): boolean {
return (
isDynamicPoiDistanceFeatureName(name) || STATIC_AMENITY_DISTANCE_FEATURE_NAME_SET.has(name)
);
return isDynamicPoiDistanceFeatureName(name);
}
export function isPoiFilterFeatureName(name: string): boolean {
@ -128,8 +144,7 @@ export function getPoiFilterName(name: string): PoiFilterName | null {
for (const filterName of POI_FILTER_NAMES) {
if (name.startsWith(getConfig(filterName).keyPrefix)) return filterName;
}
const metric = getPoiMetric(name);
return metric ? getFilterNameForMetric(metric) : null;
return getFilterNameForFeature(name);
}
export function isPoiDistanceFilterName(name: string): boolean {
@ -172,8 +187,7 @@ export function parsePoiFilterKey(name: string): string | null {
if (lastColon === -1) return null;
const decoded = decodeURIComponent(rest.substring(0, lastColon));
const metric = getPoiMetric(decoded);
return metric === getConfig(filterName).metric ? decoded : null;
return getFilterNameForFeature(decoded) === filterName ? decoded : null;
}
export function parsePoiDistanceFilterKey(name: string): string | null {
@ -186,7 +200,10 @@ export function getPoiDistanceFeatureName(name: string): string | null {
}
export function replacePoiFilterKeySelection(key: string, featureName: string): string {
const filterName = getPoiFilterName(key) ?? getFilterNameForMetric(getPoiMetric(featureName)!);
const filterName =
getPoiFilterName(key) ??
getFilterNameForFeature(featureName) ??
getFilterNameForMetric(getPoiMetric(featureName)!);
const id = getPoiFilterKeyId(key) ?? '0';
return createPoiFilterKey(filterName, featureName, id);
}
@ -203,23 +220,18 @@ export function getPoiFilterFeatureOptions(
const dynamicOptions = features.filter((feature) => {
const featureMetric = getPoiMetric(feature.name);
if (featureMetric !== metric) return false;
return metric !== 'distance' || isDynamicPoiDistanceFeatureName(feature.name);
const isTransport = isTransportPoiFeatureName(feature.name);
if (filterName === TRANSPORT_DISTANCE_FILTER_NAME) {
return metric === 'distance' && isTransport;
}
if (isTransport) return false;
return metric !== 'distance' || DYNAMIC_DISTANCE_RE.test(feature.name);
});
if (dynamicOptions.length > 0 && metric === 'distance') {
const aggregateOptions = STATIC_AMENITY_DISTANCE_AGGREGATE_OPTIONS.map((name) =>
features.find((feature) => feature.name === name)
).filter((feature): feature is FeatureMeta => Boolean(feature));
return [...dynamicOptions, ...aggregateOptions];
}
if (dynamicOptions.length > 0 || metric !== 'distance') {
if (filterName === TRANSPORT_DISTANCE_FILTER_NAME) {
return dynamicOptions;
}
return POI_DISTANCE_FEATURE_NAMES.map((name) =>
features.find((feature) => feature.name === name)
).filter((feature): feature is FeatureMeta => Boolean(feature));
return dynamicOptions;
}
export function getDefaultPoiFilterFeatureName(
@ -243,7 +255,7 @@ export function getPoiFilterMeta(features: FeatureMeta[], filterName: PoiFilterN
return {
name: filterName,
type: 'numeric',
group: 'Amenities',
group: filterName === TRANSPORT_DISTANCE_FILTER_NAME ? 'Transport' : 'Amenities',
min: sourceFeature?.min ?? 0,
max: sourceFeature?.max ?? config.defaultMax,
step: config.step,
@ -264,7 +276,11 @@ export function normalizePoiDistanceFilters(filters: FeatureFilters): FeatureFil
for (const [name, value] of Object.entries(filters)) {
if (isPoiFilterFeatureName(name)) {
const filterName = getPoiFilterName(name) ?? POI_DISTANCE_FILTER_NAME;
const filterName = getPoiFilterName(name);
if (!filterName) {
changed = true;
continue;
}
next[createPoiFilterKey(filterName, name, Object.keys(next).length)] = value;
changed = true;
continue;

View file

@ -168,15 +168,7 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
const min = Number(parts[parts.length - 2]);
const max = Number(parts[parts.length - 1]);
const targetFilterName = getPoiFilterName(featureName);
const canMigrateTransportDistance =
filterName === POI_DISTANCE_FILTER_NAME &&
targetFilterName === TRANSPORT_DISTANCE_FILTER_NAME;
if (
!targetFilterName ||
(targetFilterName !== filterName && !canMigrateTransportDistance) ||
isNaN(min) ||
isNaN(max)
) {
if (!targetFilterName || targetFilterName !== filterName || isNaN(min) || isNaN(max)) {
return;
}
filters[createPoiFilterKey(targetFilterName, featureName, startIndex + index)] = [min, max];