lgtm
This commit is contained in:
parent
2efa4d9f47
commit
5e73287eaf
99 changed files with 6392 additions and 1462 deletions
|
|
@ -6,6 +6,8 @@ import { createSchoolFilterKey } from './school-filter';
|
|||
import { createSpecificCrimeFilterKey } from './crime-filter';
|
||||
import { createElectionVoteShareFilterKey } from './election-filter';
|
||||
import { createEthnicityFilterKey } from './ethnicity-filter';
|
||||
import { createQualificationFilterKey } from './qualification-filter';
|
||||
import { createTenureFilterKey } from './tenure-filter';
|
||||
import {
|
||||
POI_COUNT_2KM_FILTER_NAME,
|
||||
TRANSPORT_DISTANCE_FILTER_NAME,
|
||||
|
|
@ -98,19 +100,19 @@ describe('api utilities', () => {
|
|||
|
||||
it('serializes specific crime filters using their selected backend crime feature', () => {
|
||||
const features: FeatureMeta[] = [
|
||||
{ name: 'Burglary (avg/yr)', type: 'numeric', min: 0, max: 20 },
|
||||
{ name: 'Vehicle crime (avg/yr)', type: 'numeric', min: 0, max: 30 },
|
||||
{ name: 'Burglary (/yr, 7y)', type: 'numeric', min: 0, max: 20 },
|
||||
{ name: 'Vehicle crime (/yr, 7y)', type: 'numeric', min: 0, max: 30 },
|
||||
];
|
||||
|
||||
expect(
|
||||
buildFilterString(
|
||||
{
|
||||
[createSpecificCrimeFilterKey('Burglary (avg/yr)', 1)]: [0, 5],
|
||||
[createSpecificCrimeFilterKey('Vehicle crime (avg/yr)', 2)]: [1, 10],
|
||||
[createSpecificCrimeFilterKey('Burglary (/yr, 7y)', 1)]: [0, 5],
|
||||
[createSpecificCrimeFilterKey('Vehicle crime (/yr, 7y)', 2)]: [1, 10],
|
||||
},
|
||||
features
|
||||
)
|
||||
).toBe('Burglary (avg/yr):0:5;;Vehicle crime (avg/yr):1:10');
|
||||
).toBe('Burglary (/yr, 7y):0:5;;Vehicle crime (/yr, 7y):1:10');
|
||||
});
|
||||
|
||||
it('serializes election vote-share filters using their selected backend party feature', () => {
|
||||
|
|
@ -144,6 +146,40 @@ describe('api utilities', () => {
|
|||
).toBe('% White:20:80');
|
||||
});
|
||||
|
||||
it('serializes qualification filters using their selected backend band feature', () => {
|
||||
const features: FeatureMeta[] = [
|
||||
{ name: '% Degree or higher', type: 'numeric', min: 0, max: 100 },
|
||||
{ name: '% No qualifications', type: 'numeric', min: 0, max: 100 },
|
||||
];
|
||||
|
||||
expect(
|
||||
buildFilterString(
|
||||
{
|
||||
[createQualificationFilterKey('% Degree or higher', 1)]: [20, 60],
|
||||
[createQualificationFilterKey('% No qualifications', 2)]: [0, 25],
|
||||
},
|
||||
features
|
||||
)
|
||||
).toBe('% Degree or higher:20:60;;% No qualifications:0:25');
|
||||
});
|
||||
|
||||
it('serializes tenure filters using their selected backend band feature', () => {
|
||||
const features: FeatureMeta[] = [
|
||||
{ name: '% Owner occupied', type: 'numeric', min: 0, max: 100 },
|
||||
{ name: '% Private rent', type: 'numeric', min: 0, max: 100 },
|
||||
];
|
||||
|
||||
expect(
|
||||
buildFilterString(
|
||||
{
|
||||
[createTenureFilterKey('% Owner occupied', 1)]: [20, 60],
|
||||
[createTenureFilterKey('% Private rent', 2)]: [0, 25],
|
||||
},
|
||||
features
|
||||
)
|
||||
).toBe('% Owner occupied:20:60;;% Private rent:0:25');
|
||||
});
|
||||
|
||||
it('serializes amenity distance filters using their selected backend feature', () => {
|
||||
const features: FeatureMeta[] = [
|
||||
{ name: 'Distance to nearest amenity (Park) (km)', type: 'numeric', min: 0, max: 2 },
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||
import type { FeatureMeta, FeatureFilters, CrimeRecordsResponse } from '../types';
|
||||
import { INITIAL_RETRY_MS, MAX_RETRY_MS } from './consts';
|
||||
import pb from './pocketbase';
|
||||
import { getSchoolBackendFeatureName } from './school-filter';
|
||||
import { getSpecificCrimeFeatureName } from './crime-filter';
|
||||
import { getCrimeSeverityFeatureName } from './crime-severity-filter';
|
||||
import { getElectionVoteShareFeatureName } from './election-filter';
|
||||
import { getEthnicityFeatureName } from './ethnicity-filter';
|
||||
import { getQualificationFeatureName } from './qualification-filter';
|
||||
import { getTenureFeatureName } from './tenure-filter';
|
||||
import { getPoiDistanceFeatureName } from './poi-distance-filter';
|
||||
|
||||
const SCREENSHOT_LANGUAGES = new Set(['en', 'fr', 'de', 'zh', 'hi', 'hu']);
|
||||
|
|
@ -61,6 +64,7 @@ const DEMO_GATED_ENDPOINTS = new Set([
|
|||
'postcode-stats',
|
||||
'postcode-properties',
|
||||
'hexagon-properties',
|
||||
'crime-records',
|
||||
'journey',
|
||||
'actual-listings',
|
||||
'developments',
|
||||
|
|
@ -186,6 +190,26 @@ export async function shortenUrl(params: string, language?: string): Promise<str
|
|||
return `${window.location.origin}${data.url}`;
|
||||
}
|
||||
|
||||
/** Fetch one page of individual crime records for a hexagon or a postcode. The
|
||||
* list is independent of property filters, so no filter string is sent. */
|
||||
export async function fetchCrimeRecords(
|
||||
selection: { h3: string; resolution: number } | { postcode: string },
|
||||
offset: number,
|
||||
shareCode?: string
|
||||
): Promise<CrimeRecordsResponse> {
|
||||
const params = new URLSearchParams({ offset: offset.toString() });
|
||||
if ('postcode' in selection) {
|
||||
params.set('postcode', selection.postcode);
|
||||
} else {
|
||||
params.set('h3', selection.h3);
|
||||
params.set('resolution', selection.resolution.toString());
|
||||
}
|
||||
if (shareCode) params.set('share', shareCode);
|
||||
const res = await fetch(apiUrl('crime-records', params), authHeaders());
|
||||
assertOk(res, 'crime-records');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function buildFilterString(
|
||||
filters: FeatureFilters,
|
||||
features: FeatureMeta[],
|
||||
|
|
@ -200,8 +224,11 @@ export function buildFilterString(
|
|||
const backendName =
|
||||
getSchoolBackendFeatureName(name) ??
|
||||
getSpecificCrimeFeatureName(name) ??
|
||||
getCrimeSeverityFeatureName(name) ??
|
||||
getElectionVoteShareFeatureName(name) ??
|
||||
getEthnicityFeatureName(name) ??
|
||||
getQualificationFeatureName(name) ??
|
||||
getTenureFeatureName(name) ??
|
||||
getPoiDistanceFeatureName(name) ??
|
||||
name;
|
||||
const prev = merged.get(backendName);
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export const SMALLEST_VISIBLE_HEXAGON_RESOLUTION = Math.max(
|
|||
// past the finest hexagon level so detail appears while still relatively
|
||||
// zoomed out. (Each overlay additionally can't render below its own tile-data
|
||||
// floor, OVERLAY_MIN_ZOOM, regardless of this limit.)
|
||||
export const POSTCODE_ZOOM_THRESHOLD = 14;
|
||||
export const POSTCODE_ZOOM_THRESHOLD = 13.5;
|
||||
export const POSTCODE_SEARCH_ZOOM = 16;
|
||||
|
||||
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
|
|
@ -264,7 +264,7 @@ export const STACKED_GROUPS: Record<
|
|||
label: string;
|
||||
/** If set, use this feature's stats for the total and info popup. Otherwise sum components. */
|
||||
feature?: string;
|
||||
/** If set, display this feature's mean as the primary value (e.g. per-1k rate) instead of the absolute total. */
|
||||
/** If set, display this feature's mean as the primary value (e.g. a percentage) instead of the absolute total. */
|
||||
rateFeature?: string;
|
||||
/** Suffix shown after the total value (e.g. "avg/yr") */
|
||||
unit?: string;
|
||||
|
|
@ -275,30 +275,30 @@ export const STACKED_GROUPS: Record<
|
|||
Crime: [
|
||||
{
|
||||
label: 'Serious crime',
|
||||
feature: 'Serious crime (avg/yr)',
|
||||
feature: 'Serious crime (/yr, 7y)',
|
||||
unit: '',
|
||||
components: [
|
||||
'Violence and sexual offences (avg/yr)',
|
||||
'Robbery (avg/yr)',
|
||||
'Burglary (avg/yr)',
|
||||
'Possession of weapons (avg/yr)',
|
||||
'Violence and sexual offences (/yr, 7y)',
|
||||
'Robbery (/yr, 7y)',
|
||||
'Burglary (/yr, 7y)',
|
||||
'Possession of weapons (/yr, 7y)',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Minor crime',
|
||||
feature: 'Minor crime (avg/yr)',
|
||||
feature: 'Minor crime (/yr, 7y)',
|
||||
unit: '',
|
||||
components: [
|
||||
'Anti-social behaviour (avg/yr)',
|
||||
'Criminal damage and arson (avg/yr)',
|
||||
'Shoplifting (avg/yr)',
|
||||
'Bicycle theft (avg/yr)',
|
||||
'Theft from the person (avg/yr)',
|
||||
'Other theft (avg/yr)',
|
||||
'Vehicle crime (avg/yr)',
|
||||
'Public order (avg/yr)',
|
||||
'Drugs (avg/yr)',
|
||||
'Other crime (avg/yr)',
|
||||
'Anti-social behaviour (/yr, 7y)',
|
||||
'Criminal damage and arson (/yr, 7y)',
|
||||
'Shoplifting (/yr, 7y)',
|
||||
'Bicycle theft (/yr, 7y)',
|
||||
'Theft from the person (/yr, 7y)',
|
||||
'Other theft (/yr, 7y)',
|
||||
'Vehicle crime (/yr, 7y)',
|
||||
'Public order (/yr, 7y)',
|
||||
'Drugs (/yr, 7y)',
|
||||
'Other crime (/yr, 7y)',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
@ -493,20 +493,20 @@ export function getEnumValueColor(
|
|||
|
||||
/** Explicit colors for stacked bar segments. */
|
||||
export const STACKED_SEGMENT_COLORS: Record<string, string> = {
|
||||
'Violence and sexual offences (avg/yr)': '#ef4444',
|
||||
'Robbery (avg/yr)': '#f97316',
|
||||
'Burglary (avg/yr)': '#eab308',
|
||||
'Possession of weapons (avg/yr)': '#8b5cf6',
|
||||
'Anti-social behaviour (avg/yr)': '#14b8a6',
|
||||
'Criminal damage and arson (avg/yr)': '#f97316',
|
||||
'Shoplifting (avg/yr)': '#ec4899',
|
||||
'Bicycle theft (avg/yr)': '#22c55e',
|
||||
'Theft from the person (avg/yr)': '#d946ef',
|
||||
'Other theft (avg/yr)': '#06b6d4',
|
||||
'Vehicle crime (avg/yr)': '#3b82f6',
|
||||
'Public order (avg/yr)': '#8b5cf6',
|
||||
'Drugs (avg/yr)': '#22c55e',
|
||||
'Other crime (avg/yr)': '#6b7280',
|
||||
'Violence and sexual offences (/yr, 7y)': '#ef4444',
|
||||
'Robbery (/yr, 7y)': '#f97316',
|
||||
'Burglary (/yr, 7y)': '#eab308',
|
||||
'Possession of weapons (/yr, 7y)': '#8b5cf6',
|
||||
'Anti-social behaviour (/yr, 7y)': '#14b8a6',
|
||||
'Criminal damage and arson (/yr, 7y)': '#f97316',
|
||||
'Shoplifting (/yr, 7y)': '#ec4899',
|
||||
'Bicycle theft (/yr, 7y)': '#22c55e',
|
||||
'Theft from the person (/yr, 7y)': '#d946ef',
|
||||
'Other theft (/yr, 7y)': '#06b6d4',
|
||||
'Vehicle crime (/yr, 7y)': '#3b82f6',
|
||||
'Public order (/yr, 7y)': '#8b5cf6',
|
||||
'Drugs (/yr, 7y)': '#22c55e',
|
||||
'Other crime (/yr, 7y)': '#6b7280',
|
||||
'% White': '#3b82f6',
|
||||
'% South Asian': '#f97316',
|
||||
'% East Asian': '#eab308',
|
||||
|
|
|
|||
79
frontend/src/lib/crime-filter.test.ts
Normal file
79
frontend/src/lib/crime-filter.test.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { FeatureMeta } from '../types';
|
||||
import {
|
||||
SPECIFIC_CRIME_FEATURE_NAMES,
|
||||
createSpecificCrimeFilterKey,
|
||||
getDefaultSpecificCrimeFeatureName,
|
||||
getSpecificCrimeFeatureName,
|
||||
getSpecificCrimeFilterKeyId,
|
||||
getSpecificCrimeType,
|
||||
getSpecificCrimeWindow,
|
||||
isSpecificCrimeFeatureName,
|
||||
isSpecificCrimeFilterName,
|
||||
normalizeSpecificCrimeFilters,
|
||||
specificCrimeFeatureName,
|
||||
withSpecificCrimeWindow,
|
||||
} from './crime-filter';
|
||||
|
||||
const numeric = (name: string): FeatureMeta => ({ name, type: 'numeric', min: 0, max: 10 });
|
||||
|
||||
describe('specific-crime window helpers', () => {
|
||||
it('recognizes both the 7-year and 2-year rate features', () => {
|
||||
expect(isSpecificCrimeFeatureName('Burglary (/yr, 7y)')).toBe(true);
|
||||
expect(isSpecificCrimeFeatureName('Burglary (/yr, 2y)')).toBe(true);
|
||||
expect(isSpecificCrimeFeatureName('Burglary (avg/yr)')).toBe(false);
|
||||
// The "Serious crime" / "Minor crime" aggregates stay separate sliders.
|
||||
expect(isSpecificCrimeFeatureName('Serious crime (/yr, 7y)')).toBe(false);
|
||||
});
|
||||
|
||||
it('extracts the window and bare type', () => {
|
||||
expect(getSpecificCrimeWindow('Burglary (/yr, 2y)')).toBe('2y');
|
||||
expect(getSpecificCrimeWindow('Burglary (/yr, 7y)')).toBe('7y');
|
||||
expect(getSpecificCrimeWindow('Burglary')).toBeNull();
|
||||
expect(getSpecificCrimeType('Violence and sexual offences (/yr, 2y)')).toBe(
|
||||
'Violence and sexual offences'
|
||||
);
|
||||
expect(getSpecificCrimeType('not a crime')).toBeNull();
|
||||
});
|
||||
|
||||
it('swaps a feature name between windows and round-trips', () => {
|
||||
expect(withSpecificCrimeWindow('Burglary (/yr, 7y)', '2y')).toBe(
|
||||
'Burglary (/yr, 2y)'
|
||||
);
|
||||
expect(withSpecificCrimeWindow('Burglary (/yr, 2y)', '7y')).toBe(
|
||||
'Burglary (/yr, 7y)'
|
||||
);
|
||||
// Unrecognized names pass through untouched.
|
||||
expect(withSpecificCrimeWindow('Burglary', '2y')).toBe('Burglary');
|
||||
expect(specificCrimeFeatureName('Drugs', '2y')).toBe('Drugs (/yr, 2y)');
|
||||
});
|
||||
|
||||
it('defaults to the 7-year window and enumerates 7-year dropdown names', () => {
|
||||
const features = [numeric('Burglary (/yr, 7y)'), numeric('Burglary (/yr, 2y)')];
|
||||
expect(getDefaultSpecificCrimeFeatureName(features)).toBe('Burglary (/yr, 7y)');
|
||||
expect(SPECIFIC_CRIME_FEATURE_NAMES.every((n) => n.endsWith('(/yr, 7y)'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('specific-crime filter keys', () => {
|
||||
it('resolves a 2-year filter key back to its backend column', () => {
|
||||
const key = createSpecificCrimeFilterKey('Burglary (/yr, 2y)', 3);
|
||||
expect(isSpecificCrimeFilterName(key)).toBe(true);
|
||||
expect(getSpecificCrimeFilterKeyId(key)).toBe('3');
|
||||
expect(getSpecificCrimeFeatureName(key)).toBe('Burglary (/yr, 2y)');
|
||||
});
|
||||
|
||||
it('folds a bare 2-year crime feature into a multi-instance key', () => {
|
||||
const normalized = normalizeSpecificCrimeFilters({
|
||||
'Burglary (/yr, 2y)': [1, 5],
|
||||
'Median price (£)': [0, 100],
|
||||
});
|
||||
const keys = Object.keys(normalized);
|
||||
expect(keys.some((k) => isSpecificCrimeFilterName(k))).toBe(true);
|
||||
const crimeKey = keys.find((k) => isSpecificCrimeFilterName(k))!;
|
||||
expect(getSpecificCrimeFeatureName(crimeKey)).toBe('Burglary (/yr, 2y)');
|
||||
expect(normalized[crimeKey]).toEqual([1, 5]);
|
||||
// Non-crime filters are left untouched.
|
||||
expect(normalized['Median price (£)']).toEqual([0, 100]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,26 +1,74 @@
|
|||
import type { FeatureFilters, FeatureMeta } from '../types';
|
||||
import type { VariantFilterConfig } from './variant-filter';
|
||||
|
||||
export const SPECIFIC_CRIMES_FILTER_NAME = 'Specific crimes';
|
||||
export const SPECIFIC_CRIMES_FILTER_KEY_PREFIX = `${SPECIFIC_CRIMES_FILTER_NAME}:`;
|
||||
|
||||
export const SPECIFIC_CRIME_FEATURE_NAMES = [
|
||||
'Violence and sexual offences (avg/yr)',
|
||||
'Burglary (avg/yr)',
|
||||
'Robbery (avg/yr)',
|
||||
'Vehicle crime (avg/yr)',
|
||||
'Anti-social behaviour (avg/yr)',
|
||||
'Criminal damage and arson (avg/yr)',
|
||||
'Other theft (avg/yr)',
|
||||
'Theft from the person (avg/yr)',
|
||||
'Shoplifting (avg/yr)',
|
||||
'Bicycle theft (avg/yr)',
|
||||
'Drugs (avg/yr)',
|
||||
'Possession of weapons (avg/yr)',
|
||||
'Public order (avg/yr)',
|
||||
'Other crime (avg/yr)',
|
||||
// The "pick one crime type" filter selects a single street-level category, with
|
||||
// a toggle to measure it over the 7-year or the recent 2-year window. The 7-year
|
||||
// window is the default/primary one (also headlined in the area pane).
|
||||
export const SPECIFIC_CRIME_TYPES = [
|
||||
'Violence and sexual offences',
|
||||
'Burglary',
|
||||
'Robbery',
|
||||
'Vehicle crime',
|
||||
'Anti-social behaviour',
|
||||
'Criminal damage and arson',
|
||||
'Other theft',
|
||||
'Theft from the person',
|
||||
'Shoplifting',
|
||||
'Bicycle theft',
|
||||
'Drugs',
|
||||
'Possession of weapons',
|
||||
'Public order',
|
||||
'Other crime',
|
||||
] as const;
|
||||
|
||||
const SPECIFIC_CRIME_FEATURE_NAME_SET = new Set<string>(SPECIFIC_CRIME_FEATURE_NAMES);
|
||||
export const SPECIFIC_CRIME_WINDOW_DEFAULT = '7y';
|
||||
export const SPECIFIC_CRIME_WINDOWS = ['7y', '2y'] as const;
|
||||
export type SpecificCrimeWindow = (typeof SPECIFIC_CRIME_WINDOWS)[number];
|
||||
|
||||
/** The backend rate-feature column for a crime type in a given window. */
|
||||
export function specificCrimeFeatureName(type: string, window: SpecificCrimeWindow): string {
|
||||
return `${type} (/yr, ${window})`;
|
||||
}
|
||||
|
||||
// Canonical dropdown enumeration: one feature name per crime type, in the
|
||||
// default (7-year) window. The window toggle re-points these to the chosen window.
|
||||
export const SPECIFIC_CRIME_FEATURE_NAMES = SPECIFIC_CRIME_TYPES.map((type) =>
|
||||
specificCrimeFeatureName(type, SPECIFIC_CRIME_WINDOW_DEFAULT)
|
||||
);
|
||||
|
||||
// Every recognized specific-crime feature, across all windows. Used to detect /
|
||||
// resolve a filter key's backend column for either window.
|
||||
const SPECIFIC_CRIME_FEATURE_NAME_SET = new Set<string>(
|
||||
SPECIFIC_CRIME_TYPES.flatMap((type) =>
|
||||
SPECIFIC_CRIME_WINDOWS.map((window) => specificCrimeFeatureName(type, window))
|
||||
)
|
||||
);
|
||||
|
||||
const SPECIFIC_CRIME_WINDOW_RE = / \(\/yr, (7y|2y)\)$/;
|
||||
|
||||
/** The window suffix of a specific-crime feature name (e.g. "2y"), or null. */
|
||||
export function getSpecificCrimeWindow(featureName: string): SpecificCrimeWindow | null {
|
||||
const match = featureName.match(SPECIFIC_CRIME_WINDOW_RE);
|
||||
return match ? (match[1] as SpecificCrimeWindow) : null;
|
||||
}
|
||||
|
||||
/** The bare crime type of a specific-crime feature name (e.g. "Burglary"), or null. */
|
||||
export function getSpecificCrimeType(featureName: string): string | null {
|
||||
const match = featureName.match(SPECIFIC_CRIME_WINDOW_RE);
|
||||
return match ? featureName.slice(0, match.index) : null;
|
||||
}
|
||||
|
||||
/** The same crime type's feature name in a different window (no-op if unrecognized). */
|
||||
export function withSpecificCrimeWindow(
|
||||
featureName: string,
|
||||
window: SpecificCrimeWindow
|
||||
): string {
|
||||
const type = getSpecificCrimeType(featureName);
|
||||
return type ? specificCrimeFeatureName(type, window) : featureName;
|
||||
}
|
||||
|
||||
export function isSpecificCrimeFeatureName(name: string): boolean {
|
||||
return SPECIFIC_CRIME_FEATURE_NAME_SET.has(name);
|
||||
|
|
@ -101,7 +149,7 @@ export function getSpecificCrimeFilterMeta(features: FeatureMeta[]): FeatureMeta
|
|||
description:
|
||||
'Violence, burglary, robbery, drugs, shoplifting, vehicle crime, anti-social behaviour, public order, theft, and other crime types',
|
||||
detail:
|
||||
'Filter by one street-level crime category at a time using an area-normalised crime density near each postcode (not a count of incidents per year).',
|
||||
'Filter by one street-level crime category at a time, as the average number of recorded incidents per year near the postcode. Toggle between the 7-year and the recent 2-year average.',
|
||||
source: 'crime',
|
||||
suffix: '',
|
||||
};
|
||||
|
|
@ -115,3 +163,26 @@ export function clampSpecificCrimeRange(
|
|||
const max = feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]);
|
||||
return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))];
|
||||
}
|
||||
|
||||
export const SPECIFIC_CRIME_VARIANT_CONFIG: VariantFilterConfig = {
|
||||
filterName: SPECIFIC_CRIMES_FILTER_NAME,
|
||||
featureNames: SPECIFIC_CRIME_FEATURE_NAMES,
|
||||
dropdownLabelKey: 'filters.crimeType',
|
||||
getFilterMeta: getSpecificCrimeFilterMeta,
|
||||
getDefaultFeatureName: getDefaultSpecificCrimeFeatureName,
|
||||
getFeatureName: getSpecificCrimeFeatureName,
|
||||
replaceFilterKeySelection: replaceSpecificCrimeFilterKeySelection,
|
||||
clampRange: clampSpecificCrimeRange,
|
||||
// Dropdown shows the bare crime type; the window toggle carries the 7y/2y suffix.
|
||||
getOptionLabelSource: (name) => getSpecificCrimeType(name) ?? name,
|
||||
window: {
|
||||
labelKey: 'filters.crimeWindow',
|
||||
options: [
|
||||
{ id: '7y', labelKey: 'filters.crimeWindow7y' },
|
||||
{ id: '2y', labelKey: 'filters.crimeWindow2y' },
|
||||
],
|
||||
getWindow: getSpecificCrimeWindow,
|
||||
withWindow: (name, windowId) =>
|
||||
withSpecificCrimeWindow(name, windowId as SpecificCrimeWindow),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
85
frontend/src/lib/crime-severity-filter.test.ts
Normal file
85
frontend/src/lib/crime-severity-filter.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { FeatureMeta } from '../types';
|
||||
import {
|
||||
CRIME_SEVERITY_FILTER_NAMES,
|
||||
createCrimeSeverityFilterKey,
|
||||
getCrimeSeverityFeatureName,
|
||||
getCrimeSeverityFilterKeyId,
|
||||
getCrimeSeverityFilterName,
|
||||
getCrimeSeverityVariantConfig,
|
||||
getDefaultCrimeSeverityFeatureName,
|
||||
isCrimeSeverityFeatureName,
|
||||
isCrimeSeverityFilterName,
|
||||
normalizeCrimeSeverityFilters,
|
||||
} from './crime-severity-filter';
|
||||
|
||||
const numeric = (name: string): FeatureMeta => ({ name, type: 'numeric', min: 0, max: 10 });
|
||||
|
||||
describe('crime-severity feature recognition', () => {
|
||||
it('recognizes Serious/Minor crime in both windows only', () => {
|
||||
expect(isCrimeSeverityFeatureName('Serious crime (/yr, 7y)')).toBe(true);
|
||||
expect(isCrimeSeverityFeatureName('Serious crime (/yr, 2y)')).toBe(true);
|
||||
expect(isCrimeSeverityFeatureName('Minor crime (/yr, 2y)')).toBe(true);
|
||||
// Detailed categories belong to "Specific crimes", not severity.
|
||||
expect(isCrimeSeverityFeatureName('Burglary (/yr, 7y)')).toBe(false);
|
||||
// Bare names without a window suffix are not feature columns.
|
||||
expect(isCrimeSeverityFeatureName('Serious crime')).toBe(false);
|
||||
});
|
||||
|
||||
it('maps a feature or key back to its severity filter name', () => {
|
||||
expect(getCrimeSeverityFilterName('Serious crime (/yr, 2y)')).toBe('Serious crime');
|
||||
expect(getCrimeSeverityFilterName('Minor crime (/yr, 7y)')).toBe('Minor crime');
|
||||
const key = createCrimeSeverityFilterKey('Serious crime', 'Serious crime (/yr, 7y)', 0);
|
||||
expect(getCrimeSeverityFilterName(key)).toBe('Serious crime');
|
||||
expect(getCrimeSeverityFilterName('Burglary (/yr, 7y)')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('crime-severity filter keys', () => {
|
||||
it('resolves a 2-year key back to its backend column and id', () => {
|
||||
const key = createCrimeSeverityFilterKey('Minor crime', 'Minor crime (/yr, 2y)', 4);
|
||||
expect(isCrimeSeverityFilterName(key)).toBe(true);
|
||||
expect(getCrimeSeverityFilterKeyId(key)).toBe('4');
|
||||
expect(getCrimeSeverityFeatureName(key)).toBe('Minor crime (/yr, 2y)');
|
||||
});
|
||||
|
||||
it('defaults to the 7-year window when present', () => {
|
||||
const features = [
|
||||
numeric('Serious crime (/yr, 7y)'),
|
||||
numeric('Serious crime (/yr, 2y)'),
|
||||
];
|
||||
expect(getDefaultCrimeSeverityFeatureName(features, 'Serious crime')).toBe(
|
||||
'Serious crime (/yr, 7y)'
|
||||
);
|
||||
expect(getDefaultCrimeSeverityFeatureName(features, 'Minor crime')).toBeNull();
|
||||
});
|
||||
|
||||
it('folds bare severity features but leaves specific crimes and others alone', () => {
|
||||
const normalized = normalizeCrimeSeverityFilters({
|
||||
'Serious crime (/yr, 2y)': [1, 5],
|
||||
'Burglary (/yr, 7y)': [0, 3],
|
||||
'Median price (£)': [0, 100],
|
||||
});
|
||||
const keys = Object.keys(normalized);
|
||||
const severityKey = keys.find((k) => isCrimeSeverityFilterName(k))!;
|
||||
expect(getCrimeSeverityFeatureName(severityKey)).toBe('Serious crime (/yr, 2y)');
|
||||
expect(normalized[severityKey]).toEqual([1, 5]);
|
||||
// Specific-crime + plain features pass through untouched.
|
||||
expect(normalized['Burglary (/yr, 7y)']).toEqual([0, 3]);
|
||||
expect(normalized['Median price (£)']).toEqual([0, 100]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('crime-severity variant config', () => {
|
||||
it('exposes a single variant per severity with a 7y/2y window toggle', () => {
|
||||
for (const filterName of CRIME_SEVERITY_FILTER_NAMES) {
|
||||
const config = getCrimeSeverityVariantConfig(filterName);
|
||||
expect(config.filterName).toBe(filterName);
|
||||
expect(config.featureNames).toEqual([`${filterName} (/yr, 7y)`]);
|
||||
expect(config.window?.options.map((o) => o.id)).toEqual(['7y', '2y']);
|
||||
// Switching window re-points to the same severity, other window.
|
||||
const sevenYear = `${filterName} (/yr, 7y)`;
|
||||
expect(config.window?.withWindow(sevenYear, '2y')).toBe(`${filterName} (/yr, 2y)`);
|
||||
}
|
||||
});
|
||||
});
|
||||
193
frontend/src/lib/crime-severity-filter.ts
Normal file
193
frontend/src/lib/crime-severity-filter.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import type { FeatureFilters, FeatureMeta } from '../types';
|
||||
import type { VariantFilterConfig } from './variant-filter';
|
||||
import {
|
||||
SPECIFIC_CRIME_WINDOW_DEFAULT,
|
||||
clampSpecificCrimeRange,
|
||||
getSpecificCrimeType,
|
||||
getSpecificCrimeWindow,
|
||||
specificCrimeFeatureName,
|
||||
withSpecificCrimeWindow,
|
||||
type SpecificCrimeWindow,
|
||||
} from './crime-filter';
|
||||
|
||||
// "Serious crime" and "Minor crime" are aggregate severity rollups (they overlap
|
||||
// the 14 detailed categories, so they must stay OUT of the "Specific crimes"
|
||||
// dropdown). Each is a single feature with a 7-year/2-year window toggle — the
|
||||
// same folding the specific-crimes card has, but with no variant dropdown (one
|
||||
// variant). Modeled on the multi-filter-name POI pattern: one lib, two filter
|
||||
// names, distinguished by their key prefix / bare crime type.
|
||||
|
||||
export const SERIOUS_CRIME_FILTER_NAME = 'Serious crime';
|
||||
export const MINOR_CRIME_FILTER_NAME = 'Minor crime';
|
||||
|
||||
export const CRIME_SEVERITY_FILTER_NAMES = [
|
||||
SERIOUS_CRIME_FILTER_NAME,
|
||||
MINOR_CRIME_FILTER_NAME,
|
||||
] as const;
|
||||
export type CrimeSeverityFilterName = (typeof CRIME_SEVERITY_FILTER_NAMES)[number];
|
||||
|
||||
const CRIME_SEVERITY_TYPE_SET = new Set<string>(CRIME_SEVERITY_FILTER_NAMES);
|
||||
|
||||
const CRIME_SEVERITY_DETAILS: Record<CrimeSeverityFilterName, { description: string; detail: string }> = {
|
||||
[SERIOUS_CRIME_FILTER_NAME]: {
|
||||
description: 'Violence, robbery, burglary and weapons possession near the postcode',
|
||||
detail:
|
||||
'Combined count of the more serious street-level categories (violence and sexual offences, robbery, burglary, possession of weapons), as the average number of recorded incidents per year near the postcode. Toggle between the 7-year and the recent 2-year average.',
|
||||
},
|
||||
[MINOR_CRIME_FILTER_NAME]: {
|
||||
description: 'Anti-social behaviour, theft, criminal damage, drugs and public order near the postcode',
|
||||
detail:
|
||||
'Combined count of the lower-severity street-level categories (anti-social behaviour, theft, criminal damage and arson, drugs, public order), as the average number of recorded incidents per year near the postcode. Toggle between the 7-year and the recent 2-year average.',
|
||||
},
|
||||
};
|
||||
|
||||
function keyPrefix(filterName: CrimeSeverityFilterName): string {
|
||||
return `${filterName}:`;
|
||||
}
|
||||
|
||||
/** The severity filter a name belongs to (from its key prefix or bare feature name), or null. */
|
||||
export function getCrimeSeverityFilterName(name: string): CrimeSeverityFilterName | null {
|
||||
for (const filterName of CRIME_SEVERITY_FILTER_NAMES) {
|
||||
if (name.startsWith(keyPrefix(filterName))) return filterName;
|
||||
}
|
||||
const type = getSpecificCrimeType(name);
|
||||
return type && CRIME_SEVERITY_TYPE_SET.has(type) ? (type as CrimeSeverityFilterName) : null;
|
||||
}
|
||||
|
||||
export function isCrimeSeverityFeatureName(name: string): boolean {
|
||||
const type = getSpecificCrimeType(name);
|
||||
return type != null && CRIME_SEVERITY_TYPE_SET.has(type);
|
||||
}
|
||||
|
||||
export function isCrimeSeverityFilterName(name: string): boolean {
|
||||
return getCrimeSeverityFilterName(name) != null;
|
||||
}
|
||||
|
||||
export function createCrimeSeverityFilterKey(
|
||||
filterName: CrimeSeverityFilterName,
|
||||
featureName: string,
|
||||
id: number | string
|
||||
): string {
|
||||
return `${keyPrefix(filterName)}${encodeURIComponent(featureName)}:${id}`;
|
||||
}
|
||||
|
||||
export function getCrimeSeverityFilterKeyId(name: string): string | null {
|
||||
const filterName = getCrimeSeverityFilterName(name);
|
||||
if (!filterName) return null;
|
||||
const prefix = keyPrefix(filterName);
|
||||
if (!name.startsWith(prefix)) return null; // a bare feature name has no id
|
||||
const rest = name.substring(prefix.length);
|
||||
const lastColon = rest.lastIndexOf(':');
|
||||
return lastColon === -1 ? null : rest.substring(lastColon + 1);
|
||||
}
|
||||
|
||||
export function parseCrimeSeverityFilterKey(name: string): string | null {
|
||||
const filterName = getCrimeSeverityFilterName(name);
|
||||
if (!filterName) return null;
|
||||
const prefix = keyPrefix(filterName);
|
||||
if (!name.startsWith(prefix)) return null;
|
||||
const rest = name.substring(prefix.length);
|
||||
const lastColon = rest.lastIndexOf(':');
|
||||
if (lastColon === -1) return null;
|
||||
|
||||
const decoded = decodeURIComponent(rest.substring(0, lastColon));
|
||||
return isCrimeSeverityFeatureName(decoded) ? decoded : null;
|
||||
}
|
||||
|
||||
export function getCrimeSeverityFeatureName(name: string): string | null {
|
||||
if (isCrimeSeverityFeatureName(name)) return name;
|
||||
return parseCrimeSeverityFilterKey(name);
|
||||
}
|
||||
|
||||
export function replaceCrimeSeverityFilterKeySelection(key: string, featureName: string): string {
|
||||
const filterName = getCrimeSeverityFilterName(key) ?? getCrimeSeverityFilterName(featureName);
|
||||
const id = getCrimeSeverityFilterKeyId(key) ?? '0';
|
||||
// filterName is always resolvable here: the key carries a prefix and a window
|
||||
// switch keeps the same severity type. Fall back defensively to the key as-is.
|
||||
if (!filterName) return key;
|
||||
return createCrimeSeverityFilterKey(filterName, featureName, id);
|
||||
}
|
||||
|
||||
/** The default (7-year) backend feature for a severity, if present in `features`. */
|
||||
export function getDefaultCrimeSeverityFeatureName(
|
||||
features: FeatureMeta[],
|
||||
filterName: CrimeSeverityFilterName
|
||||
): string | null {
|
||||
const name = specificCrimeFeatureName(filterName, SPECIFIC_CRIME_WINDOW_DEFAULT);
|
||||
return features.some((feature) => feature.name === name) ? name : null;
|
||||
}
|
||||
|
||||
export function normalizeCrimeSeverityFilters(filters: FeatureFilters): FeatureFilters {
|
||||
let changed = false;
|
||||
const next: FeatureFilters = {};
|
||||
|
||||
for (const [name, value] of Object.entries(filters)) {
|
||||
if (isCrimeSeverityFeatureName(name)) {
|
||||
const filterName = getCrimeSeverityFilterName(name)!;
|
||||
next[createCrimeSeverityFilterKey(filterName, name, Object.keys(next).length)] = value;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next[name] = value;
|
||||
}
|
||||
|
||||
return changed ? next : filters;
|
||||
}
|
||||
|
||||
export function getCrimeSeverityFilterMeta(
|
||||
features: FeatureMeta[],
|
||||
filterName: CrimeSeverityFilterName
|
||||
): FeatureMeta {
|
||||
const sourceFeatureName = getDefaultCrimeSeverityFeatureName(features, filterName);
|
||||
const sourceFeature = sourceFeatureName
|
||||
? features.find((feature) => feature.name === sourceFeatureName)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
name: filterName,
|
||||
type: 'numeric',
|
||||
group: 'Crime',
|
||||
min: sourceFeature?.min ?? 0,
|
||||
max: sourceFeature?.max ?? 100,
|
||||
step: 1,
|
||||
description: CRIME_SEVERITY_DETAILS[filterName].description,
|
||||
detail: CRIME_SEVERITY_DETAILS[filterName].detail,
|
||||
source: 'crime',
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function clampCrimeSeverityRange(
|
||||
value: [number, number],
|
||||
feature?: FeatureMeta
|
||||
): [number, number] {
|
||||
return clampSpecificCrimeRange(value, feature);
|
||||
}
|
||||
|
||||
/** The variant-filter config for one severity (single variant + 7y/2y window toggle). */
|
||||
export function getCrimeSeverityVariantConfig(
|
||||
filterName: CrimeSeverityFilterName
|
||||
): VariantFilterConfig {
|
||||
return {
|
||||
filterName,
|
||||
// One variant: the severity itself, in the default window. The card hides the
|
||||
// (single-option) dropdown and exposes only the window toggle.
|
||||
featureNames: [specificCrimeFeatureName(filterName, SPECIFIC_CRIME_WINDOW_DEFAULT)],
|
||||
dropdownLabelKey: 'filters.crimeType',
|
||||
getFilterMeta: (features) => getCrimeSeverityFilterMeta(features, filterName),
|
||||
getDefaultFeatureName: (features) => getDefaultCrimeSeverityFeatureName(features, filterName),
|
||||
getFeatureName: getCrimeSeverityFeatureName,
|
||||
replaceFilterKeySelection: replaceCrimeSeverityFilterKeySelection,
|
||||
clampRange: clampCrimeSeverityRange,
|
||||
window: {
|
||||
labelKey: 'filters.crimeWindow',
|
||||
options: [
|
||||
{ id: '7y', labelKey: 'filters.crimeWindow7y' },
|
||||
{ id: '2y', labelKey: 'filters.crimeWindow2y' },
|
||||
],
|
||||
getWindow: getSpecificCrimeWindow,
|
||||
withWindow: (name, windowId) =>
|
||||
withSpecificCrimeWindow(name, windowId as SpecificCrimeWindow),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -6,24 +6,47 @@
|
|||
|
||||
export interface CrimeTypeDef {
|
||||
value: string;
|
||||
/** English label (fallback / reference). */
|
||||
label: string;
|
||||
/** i18n key under `crimeTypes.*` used to render the selector label. */
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export const CRIME_TYPES: readonly CrimeTypeDef[] = [
|
||||
{ value: 'Violence and sexual offences', label: 'Violence & sexual offences' },
|
||||
{ value: 'Anti-social behaviour', label: 'Anti-social behaviour' },
|
||||
{ value: 'Criminal damage and arson', label: 'Criminal damage & arson' },
|
||||
{ value: 'Public order', label: 'Public order' },
|
||||
{ value: 'Shoplifting', label: 'Shoplifting' },
|
||||
{ value: 'Vehicle crime', label: 'Vehicle crime' },
|
||||
{ value: 'Burglary', label: 'Burglary' },
|
||||
{ value: 'Other theft', label: 'Other theft' },
|
||||
{ value: 'Theft from the person', label: 'Theft from the person' },
|
||||
{ value: 'Bicycle theft', label: 'Bicycle theft' },
|
||||
{ value: 'Drugs', label: 'Drugs' },
|
||||
{ value: 'Robbery', label: 'Robbery' },
|
||||
{ value: 'Possession of weapons', label: 'Possession of weapons' },
|
||||
{ value: 'Other crime', label: 'Other crime' },
|
||||
{
|
||||
value: 'Violence and sexual offences',
|
||||
label: 'Violence & sexual offences',
|
||||
labelKey: 'crimeTypes.violenceAndSexualOffences',
|
||||
},
|
||||
{
|
||||
value: 'Anti-social behaviour',
|
||||
label: 'Anti-social behaviour',
|
||||
labelKey: 'crimeTypes.antiSocialBehaviour',
|
||||
},
|
||||
{
|
||||
value: 'Criminal damage and arson',
|
||||
label: 'Criminal damage & arson',
|
||||
labelKey: 'crimeTypes.criminalDamageAndArson',
|
||||
},
|
||||
{ value: 'Public order', label: 'Public order', labelKey: 'crimeTypes.publicOrder' },
|
||||
{ value: 'Shoplifting', label: 'Shoplifting', labelKey: 'crimeTypes.shoplifting' },
|
||||
{ value: 'Vehicle crime', label: 'Vehicle crime', labelKey: 'crimeTypes.vehicleCrime' },
|
||||
{ value: 'Burglary', label: 'Burglary', labelKey: 'crimeTypes.burglary' },
|
||||
{ value: 'Other theft', label: 'Other theft', labelKey: 'crimeTypes.otherTheft' },
|
||||
{
|
||||
value: 'Theft from the person',
|
||||
label: 'Theft from the person',
|
||||
labelKey: 'crimeTypes.theftFromThePerson',
|
||||
},
|
||||
{ value: 'Bicycle theft', label: 'Bicycle theft', labelKey: 'crimeTypes.bicycleTheft' },
|
||||
{ value: 'Drugs', label: 'Drugs', labelKey: 'crimeTypes.drugs' },
|
||||
{ value: 'Robbery', label: 'Robbery', labelKey: 'crimeTypes.robbery' },
|
||||
{
|
||||
value: 'Possession of weapons',
|
||||
label: 'Possession of weapons',
|
||||
labelKey: 'crimeTypes.possessionOfWeapons',
|
||||
},
|
||||
{ value: 'Other crime', label: 'Other crime', labelKey: 'crimeTypes.otherCrime' },
|
||||
] as const;
|
||||
|
||||
export const CRIME_TYPE_VALUES: readonly string[] = CRIME_TYPES.map((c) => c.value);
|
||||
|
|
|
|||
|
|
@ -404,7 +404,12 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
|
|||
* Returns a complete SVG icon element for a given feature name, or null if unmapped.
|
||||
*/
|
||||
export function getFeatureIcon(featureName: string, className: string): ReactElement | null {
|
||||
const paths = FEATURE_ICON_PATHS[featureName];
|
||||
// Crime features ("Burglary (/yr, 7y)") share the bare type's legacy
|
||||
// "(avg/yr)" icon key; both windows use the same icon.
|
||||
const rateMatch = featureName.match(/^(.*) \(\/yr, \d+y\)$/);
|
||||
const paths =
|
||||
FEATURE_ICON_PATHS[featureName] ??
|
||||
(rateMatch ? FEATURE_ICON_PATHS[`${rateMatch[1]} (avg/yr)`] : undefined);
|
||||
if (!paths) return null;
|
||||
return (
|
||||
<svg
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
import type { FeatureFilters, FeatureMeta } from '../types';
|
||||
import type { VariantFilterConfig } from './variant-filter';
|
||||
|
||||
export const QUALIFICATIONS_FILTER_NAME = 'Qualifications';
|
||||
export const QUALIFICATIONS_FILTER_KEY_PREFIX = `${QUALIFICATIONS_FILTER_NAME}:`;
|
||||
|
||||
/**
|
||||
* The Census 2021 qualification-breakdown features (TS067). These render as a
|
||||
* single stacked "Qualifications" composition in the area pane (see
|
||||
* STACKED_GROUPS["Neighbours"] in consts.ts) and are display-only: they are
|
||||
* hidden from the filter browser rather than offered as seven individual
|
||||
* sliders, so the breakdown reads as a ratio without cluttering the filter list.
|
||||
* The Census 2021 "highest level of qualification" bands (TS067). They sum to
|
||||
* 100% per neighbourhood and render as a single stacked "Qualifications"
|
||||
* composition in the area pane (see STACKED_GROUPS["Neighbours"] in consts.ts).
|
||||
* In the filter browser they fold into one "Qualifications" filter whose
|
||||
* dropdown selects a band — including "% Degree or higher" — rather than seven
|
||||
* separate sliders.
|
||||
*/
|
||||
export const QUALIFICATION_FEATURE_NAMES = [
|
||||
'% No qualifications',
|
||||
|
|
@ -20,3 +27,103 @@ const QUALIFICATION_FEATURE_NAME_SET = new Set<string>(QUALIFICATION_FEATURE_NAM
|
|||
export function isQualificationFeatureName(name: string): boolean {
|
||||
return QUALIFICATION_FEATURE_NAME_SET.has(name);
|
||||
}
|
||||
|
||||
export function isQualificationFilterName(name: string): boolean {
|
||||
return isQualificationFeatureName(name) || name.startsWith(QUALIFICATIONS_FILTER_KEY_PREFIX);
|
||||
}
|
||||
|
||||
export function createQualificationFilterKey(featureName: string, id: number | string): string {
|
||||
return `${QUALIFICATIONS_FILTER_KEY_PREFIX}${encodeURIComponent(featureName)}:${id}`;
|
||||
}
|
||||
|
||||
export function getQualificationFilterKeyId(name: string): string | null {
|
||||
if (!name.startsWith(QUALIFICATIONS_FILTER_KEY_PREFIX)) return null;
|
||||
const rest = name.substring(QUALIFICATIONS_FILTER_KEY_PREFIX.length);
|
||||
const lastColon = rest.lastIndexOf(':');
|
||||
return lastColon === -1 ? null : rest.substring(lastColon + 1);
|
||||
}
|
||||
|
||||
export function parseQualificationFilterKey(name: string): string | null {
|
||||
if (!name.startsWith(QUALIFICATIONS_FILTER_KEY_PREFIX)) return null;
|
||||
const rest = name.substring(QUALIFICATIONS_FILTER_KEY_PREFIX.length);
|
||||
const lastColon = rest.lastIndexOf(':');
|
||||
if (lastColon === -1) return null;
|
||||
|
||||
const decoded = decodeURIComponent(rest.substring(0, lastColon));
|
||||
return isQualificationFeatureName(decoded) ? decoded : null;
|
||||
}
|
||||
|
||||
export function getQualificationFeatureName(name: string): string | null {
|
||||
if (isQualificationFeatureName(name)) return name;
|
||||
return parseQualificationFilterKey(name);
|
||||
}
|
||||
|
||||
export function replaceQualificationFilterKeySelection(key: string, featureName: string): string {
|
||||
const id = getQualificationFilterKeyId(key) ?? '0';
|
||||
return createQualificationFilterKey(featureName, id);
|
||||
}
|
||||
|
||||
export function getDefaultQualificationFeatureName(features: FeatureMeta[]): string | null {
|
||||
return (
|
||||
QUALIFICATION_FEATURE_NAMES.find((name) => features.some((feature) => feature.name === name)) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeQualificationFilters(filters: FeatureFilters): FeatureFilters {
|
||||
let changed = false;
|
||||
const next: FeatureFilters = {};
|
||||
|
||||
for (const [name, value] of Object.entries(filters)) {
|
||||
if (isQualificationFeatureName(name)) {
|
||||
next[createQualificationFilterKey(name, Object.keys(next).length)] = value;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next[name] = value;
|
||||
}
|
||||
|
||||
return changed ? next : filters;
|
||||
}
|
||||
|
||||
export function getQualificationFilterMeta(features: FeatureMeta[]): FeatureMeta {
|
||||
const sourceFeatureName = getDefaultQualificationFeatureName(features);
|
||||
const sourceFeature = sourceFeatureName
|
||||
? features.find((feature) => feature.name === sourceFeatureName)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
name: QUALIFICATIONS_FILTER_NAME,
|
||||
type: 'numeric',
|
||||
group: 'Neighbours',
|
||||
min: sourceFeature?.min ?? 0,
|
||||
max: sourceFeature?.max ?? 100,
|
||||
step: 1,
|
||||
description:
|
||||
'Share of residents (16+) by highest qualification, from no qualifications to a degree or higher',
|
||||
detail:
|
||||
'Filter by one Census 2021 (TS067) highest-qualification band at a time, e.g. the percentage of residents whose highest qualification is a degree or higher.',
|
||||
source: 'census-2021',
|
||||
suffix: '%',
|
||||
};
|
||||
}
|
||||
|
||||
export function clampQualificationRange(
|
||||
value: [number, number],
|
||||
feature?: FeatureMeta
|
||||
): [number, number] {
|
||||
const min = feature?.histogram?.min ?? feature?.min ?? 0;
|
||||
const max = feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]);
|
||||
return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))];
|
||||
}
|
||||
|
||||
export const QUALIFICATION_VARIANT_CONFIG: VariantFilterConfig = {
|
||||
filterName: QUALIFICATIONS_FILTER_NAME,
|
||||
featureNames: QUALIFICATION_FEATURE_NAMES,
|
||||
dropdownLabelKey: 'filters.qualificationLevel',
|
||||
getFilterMeta: getQualificationFilterMeta,
|
||||
getDefaultFeatureName: getDefaultQualificationFeatureName,
|
||||
getFeatureName: getQualificationFeatureName,
|
||||
replaceFilterKeySelection: replaceQualificationFilterKeySelection,
|
||||
clampRange: clampQualificationRange,
|
||||
};
|
||||
|
|
|
|||
121
frontend/src/lib/tenure-filter.ts
Normal file
121
frontend/src/lib/tenure-filter.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import type { FeatureFilters, FeatureMeta } from '../types';
|
||||
import type { VariantFilterConfig } from './variant-filter';
|
||||
|
||||
export const TENURE_FILTER_NAME = 'Tenure';
|
||||
export const TENURE_FILTER_KEY_PREFIX = `${TENURE_FILTER_NAME}:`;
|
||||
|
||||
/**
|
||||
* The Census 2021 housing tenure categories (TS054). They sum to 100% per
|
||||
* neighbourhood and render as a single stacked "Tenure" composition in the area
|
||||
* pane (see STACKED_GROUPS["Neighbours"] in consts.ts). In the filter browser
|
||||
* they fold into one "Tenure" filter whose dropdown selects a category —
|
||||
* owner-occupied, social rent or private rent — rather than three separate
|
||||
* sliders.
|
||||
*/
|
||||
export const TENURE_FEATURE_NAMES = [
|
||||
'% Owner occupied',
|
||||
'% Social rent',
|
||||
'% Private rent',
|
||||
] as const;
|
||||
|
||||
const TENURE_FEATURE_NAME_SET = new Set<string>(TENURE_FEATURE_NAMES);
|
||||
|
||||
export function isTenureFeatureName(name: string): boolean {
|
||||
return TENURE_FEATURE_NAME_SET.has(name);
|
||||
}
|
||||
|
||||
export function isTenureFilterName(name: string): boolean {
|
||||
return isTenureFeatureName(name) || name.startsWith(TENURE_FILTER_KEY_PREFIX);
|
||||
}
|
||||
|
||||
export function createTenureFilterKey(featureName: string, id: number | string): string {
|
||||
return `${TENURE_FILTER_KEY_PREFIX}${encodeURIComponent(featureName)}:${id}`;
|
||||
}
|
||||
|
||||
export function getTenureFilterKeyId(name: string): string | null {
|
||||
if (!name.startsWith(TENURE_FILTER_KEY_PREFIX)) return null;
|
||||
const rest = name.substring(TENURE_FILTER_KEY_PREFIX.length);
|
||||
const lastColon = rest.lastIndexOf(':');
|
||||
return lastColon === -1 ? null : rest.substring(lastColon + 1);
|
||||
}
|
||||
|
||||
export function parseTenureFilterKey(name: string): string | null {
|
||||
if (!name.startsWith(TENURE_FILTER_KEY_PREFIX)) return null;
|
||||
const rest = name.substring(TENURE_FILTER_KEY_PREFIX.length);
|
||||
const lastColon = rest.lastIndexOf(':');
|
||||
if (lastColon === -1) return null;
|
||||
|
||||
const decoded = decodeURIComponent(rest.substring(0, lastColon));
|
||||
return isTenureFeatureName(decoded) ? decoded : null;
|
||||
}
|
||||
|
||||
export function getTenureFeatureName(name: string): string | null {
|
||||
if (isTenureFeatureName(name)) return name;
|
||||
return parseTenureFilterKey(name);
|
||||
}
|
||||
|
||||
export function replaceTenureFilterKeySelection(key: string, featureName: string): string {
|
||||
const id = getTenureFilterKeyId(key) ?? '0';
|
||||
return createTenureFilterKey(featureName, id);
|
||||
}
|
||||
|
||||
export function getDefaultTenureFeatureName(features: FeatureMeta[]): string | null {
|
||||
return (
|
||||
TENURE_FEATURE_NAMES.find((name) => features.some((feature) => feature.name === name)) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeTenureFilters(filters: FeatureFilters): FeatureFilters {
|
||||
let changed = false;
|
||||
const next: FeatureFilters = {};
|
||||
|
||||
for (const [name, value] of Object.entries(filters)) {
|
||||
if (isTenureFeatureName(name)) {
|
||||
next[createTenureFilterKey(name, Object.keys(next).length)] = value;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next[name] = value;
|
||||
}
|
||||
|
||||
return changed ? next : filters;
|
||||
}
|
||||
|
||||
export function getTenureFilterMeta(features: FeatureMeta[]): FeatureMeta {
|
||||
const sourceFeatureName = getDefaultTenureFeatureName(features);
|
||||
const sourceFeature = sourceFeatureName
|
||||
? features.find((feature) => feature.name === sourceFeatureName)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
name: TENURE_FILTER_NAME,
|
||||
type: 'numeric',
|
||||
group: 'Neighbours',
|
||||
min: sourceFeature?.min ?? 0,
|
||||
max: sourceFeature?.max ?? 100,
|
||||
step: 1,
|
||||
description:
|
||||
'Share of households that own their home, rent from a social landlord, or rent privately',
|
||||
detail:
|
||||
'Filter by one Census 2021 (TS054) housing tenure category at a time, e.g. the percentage of households that own their home.',
|
||||
source: 'census-2021',
|
||||
suffix: '%',
|
||||
};
|
||||
}
|
||||
|
||||
export function clampTenureRange(value: [number, number], feature?: FeatureMeta): [number, number] {
|
||||
const min = feature?.histogram?.min ?? feature?.min ?? 0;
|
||||
const max = feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]);
|
||||
return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))];
|
||||
}
|
||||
|
||||
export const TENURE_VARIANT_CONFIG: VariantFilterConfig = {
|
||||
filterName: TENURE_FILTER_NAME,
|
||||
featureNames: TENURE_FEATURE_NAMES,
|
||||
dropdownLabelKey: 'filters.tenureType',
|
||||
getFilterMeta: getTenureFilterMeta,
|
||||
getDefaultFeatureName: getDefaultTenureFeatureName,
|
||||
getFeatureName: getTenureFeatureName,
|
||||
replaceFilterKeySelection: replaceTenureFilterKeySelection,
|
||||
clampRange: clampTenureRange,
|
||||
};
|
||||
|
|
@ -5,8 +5,11 @@ import { parseUrlState, stateToParams } from './url-state';
|
|||
import { INITIAL_VIEW_STATE } from './consts';
|
||||
import { createSchoolFilterKey } from './school-filter';
|
||||
import { createSpecificCrimeFilterKey } from './crime-filter';
|
||||
import { createCrimeSeverityFilterKey } from './crime-severity-filter';
|
||||
import { createElectionVoteShareFilterKey } from './election-filter';
|
||||
import { createEthnicityFilterKey } from './ethnicity-filter';
|
||||
import { createQualificationFilterKey } from './qualification-filter';
|
||||
import { createTenureFilterKey } from './tenure-filter';
|
||||
import {
|
||||
POI_COUNT_2KM_FILTER_NAME,
|
||||
TRANSPORT_DISTANCE_FILTER_NAME,
|
||||
|
|
@ -405,8 +408,8 @@ describe('url-state', () => {
|
|||
});
|
||||
|
||||
it('round-trips repeated specific crime filters with dedicated URL params', () => {
|
||||
const burglary = createSpecificCrimeFilterKey('Burglary (avg/yr)', 1);
|
||||
const vehicleCrime = createSpecificCrimeFilterKey('Vehicle crime (avg/yr)', 2);
|
||||
const burglary = createSpecificCrimeFilterKey('Burglary (/yr, 7y)', 1);
|
||||
const vehicleCrime = createSpecificCrimeFilterKey('Vehicle crime (/yr, 7y)', 2);
|
||||
|
||||
const params = stateToParams(
|
||||
null,
|
||||
|
|
@ -420,8 +423,8 @@ describe('url-state', () => {
|
|||
);
|
||||
|
||||
expect(params.getAll('crime')).toEqual([
|
||||
'Burglary (avg/yr):0:5',
|
||||
'Vehicle crime (avg/yr):1:10',
|
||||
'Burglary (/yr, 7y):0:5',
|
||||
'Vehicle crime (/yr, 7y):1:10',
|
||||
]);
|
||||
expect(params.getAll('filter')).toEqual([]);
|
||||
|
||||
|
|
@ -429,8 +432,42 @@ describe('url-state', () => {
|
|||
const state = parseUrlState();
|
||||
|
||||
expect(state.filters).toEqual({
|
||||
[createSpecificCrimeFilterKey('Burglary (avg/yr)', 0)]: [0, 5],
|
||||
[createSpecificCrimeFilterKey('Vehicle crime (avg/yr)', 1)]: [1, 10],
|
||||
[createSpecificCrimeFilterKey('Burglary (/yr, 7y)', 0)]: [0, 5],
|
||||
[createSpecificCrimeFilterKey('Vehicle crime (/yr, 7y)', 1)]: [1, 10],
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips serious/minor crime severity filters with dedicated URL params', () => {
|
||||
const serious = createCrimeSeverityFilterKey(
|
||||
'Serious crime',
|
||||
'Serious crime (/yr, 7y)',
|
||||
0
|
||||
);
|
||||
const minor = createCrimeSeverityFilterKey('Minor crime', 'Minor crime (/yr, 2y)', 1);
|
||||
|
||||
const params = stateToParams(
|
||||
null,
|
||||
{
|
||||
[serious]: [0, 12],
|
||||
[minor]: [3, 40],
|
||||
},
|
||||
[],
|
||||
new Set(),
|
||||
'area'
|
||||
);
|
||||
|
||||
expect(params.getAll('crimeSeverity')).toEqual([
|
||||
'Serious crime (/yr, 7y):0:12',
|
||||
'Minor crime (/yr, 2y):3:40',
|
||||
]);
|
||||
expect(params.getAll('filter')).toEqual([]);
|
||||
|
||||
window.history.replaceState({}, '', `/?${params.toString()}`);
|
||||
const state = parseUrlState();
|
||||
|
||||
expect(state.filters).toEqual({
|
||||
[createCrimeSeverityFilterKey('Serious crime', 'Serious crime (/yr, 7y)', 0)]: [0, 12],
|
||||
[createCrimeSeverityFilterKey('Minor crime', 'Minor crime (/yr, 2y)', 1)]: [3, 40],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -488,6 +525,65 @@ describe('url-state', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('round-trips repeated qualification filters with dedicated URL params', () => {
|
||||
// "% Degree or higher" exercises the leading-`%` + space encoding path.
|
||||
const degree = createQualificationFilterKey('% Degree or higher', 1);
|
||||
const noQuals = createQualificationFilterKey('% No qualifications', 2);
|
||||
|
||||
const params = stateToParams(
|
||||
null,
|
||||
{
|
||||
[degree]: [20, 60],
|
||||
[noQuals]: [0, 25],
|
||||
},
|
||||
[],
|
||||
new Set(),
|
||||
'area'
|
||||
);
|
||||
|
||||
expect(params.getAll('qualification')).toEqual([
|
||||
'% Degree or higher:20:60',
|
||||
'% No qualifications:0:25',
|
||||
]);
|
||||
expect(params.getAll('filter')).toEqual([]);
|
||||
|
||||
window.history.replaceState({}, '', `/?${params.toString()}`);
|
||||
const state = parseUrlState();
|
||||
|
||||
expect(state.filters).toEqual({
|
||||
[createQualificationFilterKey('% Degree or higher', 0)]: [20, 60],
|
||||
[createQualificationFilterKey('% No qualifications', 1)]: [0, 25],
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips repeated tenure filters with dedicated URL params', () => {
|
||||
// "% Owner occupied" exercises the leading-`%` + space encoding path.
|
||||
const owner = createTenureFilterKey('% Owner occupied', 1);
|
||||
const privateRent = createTenureFilterKey('% Private rent', 2);
|
||||
|
||||
const params = stateToParams(
|
||||
null,
|
||||
{
|
||||
[owner]: [20, 60],
|
||||
[privateRent]: [0, 25],
|
||||
},
|
||||
[],
|
||||
new Set(),
|
||||
'area'
|
||||
);
|
||||
|
||||
expect(params.getAll('tenure')).toEqual(['% Owner occupied:20:60', '% Private rent:0:25']);
|
||||
expect(params.getAll('filter')).toEqual([]);
|
||||
|
||||
window.history.replaceState({}, '', `/?${params.toString()}`);
|
||||
const state = parseUrlState();
|
||||
|
||||
expect(state.filters).toEqual({
|
||||
[createTenureFilterKey('% Owner occupied', 0)]: [20, 60],
|
||||
[createTenureFilterKey('% Private rent', 1)]: [0, 25],
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips repeated amenity distance filters with dedicated URL params', () => {
|
||||
const park = createPoiDistanceFilterKey('Distance to nearest amenity (Park) (km)', 3);
|
||||
const cafe = createPoiDistanceFilterKey('Distance to nearest amenity (Café) (km)', 4);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,13 @@ import {
|
|||
isSpecificCrimeFeatureName,
|
||||
isSpecificCrimeFilterName,
|
||||
} from './crime-filter';
|
||||
import {
|
||||
createCrimeSeverityFilterKey,
|
||||
getCrimeSeverityFeatureName,
|
||||
getCrimeSeverityFilterName,
|
||||
isCrimeSeverityFeatureName,
|
||||
isCrimeSeverityFilterName,
|
||||
} from './crime-severity-filter';
|
||||
import {
|
||||
ELECTION_VOTE_SHARE_FILTER_NAME,
|
||||
createElectionVoteShareFilterKey,
|
||||
|
|
@ -36,6 +43,20 @@ import {
|
|||
isEthnicityFeatureName,
|
||||
isEthnicityFilterName,
|
||||
} from './ethnicity-filter';
|
||||
import {
|
||||
QUALIFICATIONS_FILTER_NAME,
|
||||
createQualificationFilterKey,
|
||||
getQualificationFeatureName,
|
||||
isQualificationFeatureName,
|
||||
isQualificationFilterName,
|
||||
} from './qualification-filter';
|
||||
import {
|
||||
TENURE_FILTER_NAME,
|
||||
createTenureFilterKey,
|
||||
getTenureFeatureName,
|
||||
isTenureFeatureName,
|
||||
isTenureFilterName,
|
||||
} from './tenure-filter';
|
||||
import {
|
||||
POI_DISTANCE_FILTER_NAME,
|
||||
TRANSPORT_DISTANCE_FILTER_NAME,
|
||||
|
|
@ -83,8 +104,11 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
|
|||
const filterParams = params.getAll('filter');
|
||||
const schoolParams = params.getAll('school');
|
||||
const crimeParams = params.getAll('crime');
|
||||
const crimeSeverityParams = params.getAll('crimeSeverity');
|
||||
const voteShareParams = params.getAll('voteShare');
|
||||
const ethnicityParams = params.getAll('ethnicity');
|
||||
const qualificationParams = params.getAll('qualification');
|
||||
const tenureParams = params.getAll('tenure');
|
||||
const amenityDistanceParams = params.getAll('amenityDistance');
|
||||
const transportDistanceParams = params.getAll('transportDistance');
|
||||
const amenityCount2KmParams = params.getAll('amenityCount2km');
|
||||
|
|
@ -93,8 +117,11 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
|
|||
filterParams.length === 0 &&
|
||||
schoolParams.length === 0 &&
|
||||
crimeParams.length === 0 &&
|
||||
crimeSeverityParams.length === 0 &&
|
||||
voteShareParams.length === 0 &&
|
||||
ethnicityParams.length === 0 &&
|
||||
qualificationParams.length === 0 &&
|
||||
tenureParams.length === 0 &&
|
||||
amenityDistanceParams.length === 0 &&
|
||||
transportDistanceParams.length === 0 &&
|
||||
amenityCount2KmParams.length === 0 &&
|
||||
|
|
@ -155,6 +182,19 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
|
|||
filters[createSpecificCrimeFilterKey(featureName, index)] = [min, max];
|
||||
});
|
||||
|
||||
crimeSeverityParams.forEach((entry, index) => {
|
||||
const parts = entry.split(':');
|
||||
if (parts.length < 3) return;
|
||||
const featureName = parts.slice(0, -2).join(':');
|
||||
const min = Number(parts[parts.length - 2]);
|
||||
const max = Number(parts[parts.length - 1]);
|
||||
const filterName = getCrimeSeverityFilterName(featureName);
|
||||
if (!isCrimeSeverityFeatureName(featureName) || !filterName || isNaN(min) || isNaN(max)) {
|
||||
return;
|
||||
}
|
||||
filters[createCrimeSeverityFilterKey(filterName, featureName, index)] = [min, max];
|
||||
});
|
||||
|
||||
voteShareParams.forEach((entry, index) => {
|
||||
const parts = entry.split(':');
|
||||
if (parts.length < 3) return;
|
||||
|
|
@ -179,6 +219,30 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
|
|||
filters[createEthnicityFilterKey(featureName, index)] = [min, max];
|
||||
});
|
||||
|
||||
qualificationParams.forEach((entry, index) => {
|
||||
const parts = entry.split(':');
|
||||
if (parts.length < 3) return;
|
||||
const featureName = parts.slice(0, -2).join(':');
|
||||
const min = Number(parts[parts.length - 2]);
|
||||
const max = Number(parts[parts.length - 1]);
|
||||
if (!isQualificationFeatureName(featureName) || isNaN(min) || isNaN(max)) {
|
||||
return;
|
||||
}
|
||||
filters[createQualificationFilterKey(featureName, index)] = [min, max];
|
||||
});
|
||||
|
||||
tenureParams.forEach((entry, index) => {
|
||||
const parts = entry.split(':');
|
||||
if (parts.length < 3) return;
|
||||
const featureName = parts.slice(0, -2).join(':');
|
||||
const min = Number(parts[parts.length - 2]);
|
||||
const max = Number(parts[parts.length - 1]);
|
||||
if (!isTenureFeatureName(featureName) || isNaN(min) || isNaN(max)) {
|
||||
return;
|
||||
}
|
||||
filters[createTenureFilterKey(featureName, index)] = [min, max];
|
||||
});
|
||||
|
||||
const parsePoiParams = (entries: string[], filterName: PoiFilterName, startIndex: number) => {
|
||||
entries.forEach((entry, index) => {
|
||||
const parts = entry.split(':');
|
||||
|
|
@ -401,6 +465,13 @@ export function stateToParams(
|
|||
continue;
|
||||
}
|
||||
|
||||
const crimeSeverityFeatureName = getCrimeSeverityFeatureName(name);
|
||||
if (crimeSeverityFeatureName && isCrimeSeverityFilterName(name)) {
|
||||
const [min, max] = value as [number, number];
|
||||
params.append('crimeSeverity', `${crimeSeverityFeatureName}:${min}:${max}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const electionVoteShareFeatureName = getElectionVoteShareFeatureName(name);
|
||||
if (electionVoteShareFeatureName && isElectionVoteShareFilterName(name)) {
|
||||
const [min, max] = value as [number, number];
|
||||
|
|
@ -415,6 +486,20 @@ export function stateToParams(
|
|||
continue;
|
||||
}
|
||||
|
||||
const qualificationFeatureName = getQualificationFeatureName(name);
|
||||
if (qualificationFeatureName && isQualificationFilterName(name)) {
|
||||
const [min, max] = value as [number, number];
|
||||
params.append('qualification', `${qualificationFeatureName}:${min}:${max}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tenureFeatureName = getTenureFeatureName(name);
|
||||
if (tenureFeatureName && isTenureFilterName(name)) {
|
||||
const [min, max] = value as [number, number];
|
||||
params.append('tenure', `${tenureFeatureName}:${min}:${max}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const amenityDistanceFeatureName = getPoiDistanceFeatureName(name);
|
||||
if (amenityDistanceFeatureName && isPoiDistanceFilterName(name)) {
|
||||
const [min, max] = value as [number, number];
|
||||
|
|
@ -522,8 +607,11 @@ export function summarizeParams(queryString: string): string {
|
|||
const filterParams = params.getAll('filter');
|
||||
const schoolParams = params.getAll('school');
|
||||
const crimeParams = params.getAll('crime');
|
||||
const crimeSeverityParams = params.getAll('crimeSeverity');
|
||||
const voteShareParams = params.getAll('voteShare');
|
||||
const ethnicityParams = params.getAll('ethnicity');
|
||||
const qualificationParams = params.getAll('qualification');
|
||||
const tenureParams = params.getAll('tenure');
|
||||
const amenityDistanceParams = params.getAll('amenityDistance');
|
||||
const transportDistanceParams = params.getAll('transportDistance');
|
||||
const amenityCount2KmParams = params.getAll('amenityCount2km');
|
||||
|
|
@ -532,8 +620,11 @@ export function summarizeParams(queryString: string): string {
|
|||
filterParams.length > 0 ||
|
||||
schoolParams.length > 0 ||
|
||||
crimeParams.length > 0 ||
|
||||
crimeSeverityParams.length > 0 ||
|
||||
voteShareParams.length > 0 ||
|
||||
ethnicityParams.length > 0 ||
|
||||
qualificationParams.length > 0 ||
|
||||
tenureParams.length > 0 ||
|
||||
amenityDistanceParams.length > 0 ||
|
||||
transportDistanceParams.length > 0 ||
|
||||
amenityCount2KmParams.length > 0 ||
|
||||
|
|
@ -544,8 +635,11 @@ export function summarizeParams(queryString: string): string {
|
|||
const colonIdx = entry.indexOf(':');
|
||||
const name = colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
|
||||
if (isSpecificCrimeFeatureName(name)) return SPECIFIC_CRIMES_FILTER_NAME;
|
||||
if (isCrimeSeverityFeatureName(name)) return getCrimeSeverityFilterName(name) ?? name;
|
||||
if (isElectionVoteShareFeatureName(name)) return ELECTION_VOTE_SHARE_FILTER_NAME;
|
||||
if (isEthnicityFeatureName(name)) return ETHNICITIES_FILTER_NAME;
|
||||
if (isQualificationFeatureName(name)) return QUALIFICATIONS_FILTER_NAME;
|
||||
if (isTenureFeatureName(name)) return TENURE_FILTER_NAME;
|
||||
const poiFilterName = getPoiFilterName(name);
|
||||
if (poiFilterName) return poiFilterName;
|
||||
return name;
|
||||
|
|
@ -555,12 +649,24 @@ export function summarizeParams(queryString: string): string {
|
|||
for (let i = 0; i < crimeParams.length; i++) {
|
||||
filterNames.push(SPECIFIC_CRIMES_FILTER_NAME);
|
||||
}
|
||||
for (const entry of crimeSeverityParams) {
|
||||
const colonIdx = entry.indexOf(':');
|
||||
const featureName = colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
|
||||
const severityFilterName = getCrimeSeverityFilterName(featureName);
|
||||
if (severityFilterName) filterNames.push(severityFilterName);
|
||||
}
|
||||
for (let i = 0; i < voteShareParams.length; i++) {
|
||||
filterNames.push(ELECTION_VOTE_SHARE_FILTER_NAME);
|
||||
}
|
||||
for (let i = 0; i < ethnicityParams.length; i++) {
|
||||
filterNames.push(ETHNICITIES_FILTER_NAME);
|
||||
}
|
||||
for (let i = 0; i < qualificationParams.length; i++) {
|
||||
filterNames.push(QUALIFICATIONS_FILTER_NAME);
|
||||
}
|
||||
for (let i = 0; i < tenureParams.length; i++) {
|
||||
filterNames.push(TENURE_FILTER_NAME);
|
||||
}
|
||||
for (let i = 0; i < amenityDistanceParams.length; i++) {
|
||||
filterNames.push(POI_DISTANCE_FILTER_NAME);
|
||||
}
|
||||
|
|
|
|||
80
frontend/src/lib/variant-filter.ts
Normal file
80
frontend/src/lib/variant-filter.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import type { FeatureMeta } from '../types';
|
||||
|
||||
/** i18n keys (typed for the strict `t()`) usable as a variant dropdown label. */
|
||||
type VariantDropdownLabelKey =
|
||||
| 'filters.crimeType'
|
||||
| 'filters.qualificationLevel'
|
||||
| 'filters.tenureType';
|
||||
|
||||
/** i18n keys (typed for the strict `t()`) usable as a window-toggle label. */
|
||||
type VariantWindowLabelKey =
|
||||
| 'filters.crimeWindow'
|
||||
| 'filters.crimeWindow7y'
|
||||
| 'filters.crimeWindow2y';
|
||||
|
||||
/** One option in a variant filter's secondary window/period toggle. */
|
||||
export interface VariantWindowOption {
|
||||
/** Stable id encoded inside the feature name (e.g. "7y"). */
|
||||
id: string;
|
||||
/** i18n key for the toggle button label. */
|
||||
labelKey: VariantWindowLabelKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional secondary axis for a variant filter: the same variant measured over
|
||||
* a different time window (e.g. crime rates over 7 vs 2 years). The dropdown
|
||||
* still picks the variant; this toggle picks the window. Both ultimately select
|
||||
* a single backend feature name, so switching either re-points the filter key.
|
||||
*/
|
||||
export interface VariantWindowConfig {
|
||||
/** Toggle options in display order. */
|
||||
options: VariantWindowOption[];
|
||||
/** Optional i18n key for a small label above the toggle. */
|
||||
labelKey?: VariantWindowLabelKey;
|
||||
/** Window id of a feature name (e.g. "7y"), or null if unrecognized. */
|
||||
getWindow: (featureName: string) => string | null;
|
||||
/** The same variant's feature name in a different window. */
|
||||
withWindow: (featureName: string, windowId: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared shape for the "pick one backend feature variant, filter its range"
|
||||
* family of client-side aggregate filters. Several Census/police breakdowns
|
||||
* (specific crimes, qualifications, …) are dozens of individual percentage
|
||||
* features that would clutter the filter browser as separate sliders. Instead
|
||||
* each is folded into a single named filter whose card carries a dropdown to
|
||||
* choose the variant, reusing one component ([`VariantFilterCard`]).
|
||||
*
|
||||
* Each filter library (e.g. `crime-filter`, `qualification-filter`) exports a
|
||||
* config so the card stays variant-agnostic.
|
||||
*/
|
||||
export interface VariantFilterConfig {
|
||||
/** Display name of the aggregate filter, e.g. "Specific crimes". */
|
||||
filterName: string;
|
||||
/**
|
||||
* Backend feature names enumerating the dropdown options, in display order.
|
||||
* With a [`window`] config these are the canonical (default-window) names;
|
||||
* the card re-points each to the currently selected window.
|
||||
*/
|
||||
featureNames: readonly string[];
|
||||
/** i18n key for the dropdown label, e.g. "filters.crimeType". */
|
||||
dropdownLabelKey: VariantDropdownLabelKey;
|
||||
/** Synthetic [`FeatureMeta`] used for the aggregate filter's own label/info. */
|
||||
getFilterMeta: (features: FeatureMeta[]) => FeatureMeta;
|
||||
/** First selectable variant present in `features`, or null if none. */
|
||||
getDefaultFeatureName: (features: FeatureMeta[]) => string | null;
|
||||
/** Backend feature name for a filter key (or a bare feature name). */
|
||||
getFeatureName: (name: string) => string | null;
|
||||
/** Rewrite a filter key to point at a different variant, keeping its id. */
|
||||
replaceFilterKeySelection: (key: string, featureName: string) => string;
|
||||
/** Clamp a [min, max] range into the selected feature's bounds. */
|
||||
clampRange: (value: [number, number], feature?: FeatureMeta) => [number, number];
|
||||
/**
|
||||
* Server-translatable source value for a dropdown option's label; the card
|
||||
* renders `ts(...)` of it. Defaults to the feature name itself. Useful with a
|
||||
* [`window`] toggle so the label can be the bare variant (no window suffix).
|
||||
*/
|
||||
getOptionLabelSource?: (featureName: string) => string;
|
||||
/** Optional secondary window/period toggle (e.g. 7- vs 2-year crime rates). */
|
||||
window?: VariantWindowConfig;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue