good
This commit is contained in:
parent
81a16f543c
commit
63713c3a2b
15 changed files with 492 additions and 159 deletions
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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] },
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
64
frontend/src/lib/poi-distance-filter.test.ts
Normal file
64
frontend/src/lib/poi-distance-filter.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue