This commit is contained in:
Andras Schmelczer 2026-06-25 22:29:52 +01:00
parent 2efa4d9f47
commit 5e73287eaf
99 changed files with 6392 additions and 1462 deletions

View file

@ -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 },

View file

@ -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);

View file

@ -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',

View 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]);
});
});

View file

@ -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),
},
};

View 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)`);
}
});
});

View 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),
},
};
}

View file

@ -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);

View file

@ -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

View file

@ -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,
};

View 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,
};

View file

@ -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);

View file

@ -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);
}

View 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;
}