perfect-postcode/frontend/src/lib/url-state.ts
2026-07-12 20:37:39 +01:00

790 lines
28 KiB
TypeScript

import type { FeatureMeta, FeatureFilters, ViewState } from '../types';
import {
MAX_TRAVEL_MINUTES,
parseServerMode,
resolveTransitVariant,
type TravelTimeEntry,
type TravelTimeInitial,
} from '../hooks/useTravelTime';
import { INITIAL_VIEW_STATE } from './consts';
import {
SCHOOL_FILTER_NAME,
createSchoolFilterKey,
getSchoolFilterConfig,
isSchoolFilterName,
type SchoolPhase,
type SchoolRating,
} from './school-filter';
import {
SPECIFIC_CRIMES_FILTER_NAME,
createSpecificCrimeFilterKey,
getSpecificCrimeFeatureName,
isSpecificCrimeFeatureName,
isSpecificCrimeFilterName,
} from './crime-filter';
import {
createCrimeSeverityFilterKey,
getCrimeSeverityFeatureName,
getCrimeSeverityFilterName,
isCrimeSeverityFeatureName,
isCrimeSeverityFilterName,
} from './crime-severity-filter';
import {
ELECTION_VOTE_SHARE_FILTER_NAME,
createElectionVoteShareFilterKey,
getElectionVoteShareFeatureName,
isElectionVoteShareFeatureName,
isElectionVoteShareFilterName,
} from './election-filter';
import {
ETHNICITIES_FILTER_NAME,
createEthnicityFilterKey,
getEthnicityFeatureName,
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 {
COUNCIL_FILTER_NAME,
createCouncilFilterKey,
getCouncilFeatureName,
getCouncilWindow,
isCouncilFeatureName,
isCouncilFilterName,
} from './council-filter';
import {
POI_DISTANCE_FILTER_NAME,
TRANSPORT_DISTANCE_FILTER_NAME,
POI_COUNT_2KM_FILTER_NAME,
POI_COUNT_5KM_FILTER_NAME,
createPoiFilterKey,
getPoiDistanceFeatureName,
getPoiFilterName,
isPoiDistanceFilterName,
type PoiFilterName,
} from './poi-distance-filter';
import { dedupeTravelTimeEntries } from './travel-params';
import { isOverlayId, DEFAULT_OVERLAY_IDS, type OverlayId } from './overlays';
import { CRIME_TYPES, isCrimeTypeValue } from './crime-types';
import { isBasemapId, type BasemapId } from './basemaps';
import {
DEFAULT_COLOR_OPACITY,
colorOpacityToPercent,
normalizeColorOpacity,
} from './color-opacity';
import { DEFAULT_LISTINGS_MODE, LISTINGS_MODES, type ListingsMode } from './listings';
const POI_NONE_PARAM = '__none';
const CRIME_TYPES_NONE_PARAM = '__none';
const OVERLAY_NONE_PARAM = '__none';
const CRIME_OVERLAY_ID: OverlayId = 'crime-hotspots';
/** URL param holding the currently focused postcode (from a search or a map
* click). It reflects a transient selection, not search criteria, so a live or
* shared link keeps it but a saved search must not bake it in. */
export const SELECTED_POSTCODE_PARAM = 'pc';
/**
* Drop the transient selected-postcode param from a serialized query string so a
* saved search captures the filter criteria, not whichever postcode the user
* last clicked. Takes and returns a query string without the leading '?'.
*/
export function stripSelectedPostcodeParam(params: string): string {
const parsed = new URLSearchParams(params);
if (!parsed.has(SELECTED_POSTCODE_PARAM)) return params;
parsed.delete(SELECTED_POSTCODE_PARAM);
return parsed.toString();
}
export interface UrlState {
viewState: ViewState;
/** True only when the URL carried explicit lat/lon/zoom (shared/dashboard link).
* False on a fresh visit, so the app may centre on the visitor's IP instead. */
hasExplicitView: boolean;
filters: FeatureFilters;
poiCategories: Set<string>;
overlays: Set<OverlayId>;
crimeTypes?: Set<string>;
basemap: BasemapId;
colorOpacity: number;
/** Which slice of the live for-sale listings to render (new-build only, etc.). */
listings: ListingsMode;
tab: 'properties' | 'area';
travelTime?: TravelTimeInitial;
postcode?: string;
share?: string;
}
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 councilParams = params.getAll('council');
const amenityDistanceParams = params.getAll('amenityDistance');
const transportDistanceParams = params.getAll('transportDistance');
const amenityCount2KmParams = params.getAll('amenityCount2km');
const amenityCount5KmParams = params.getAll('amenityCount5km');
if (
filterParams.length === 0 &&
schoolParams.length === 0 &&
crimeParams.length === 0 &&
crimeSeverityParams.length === 0 &&
voteShareParams.length === 0 &&
ethnicityParams.length === 0 &&
qualificationParams.length === 0 &&
tenureParams.length === 0 &&
councilParams.length === 0 &&
amenityDistanceParams.length === 0 &&
transportDistanceParams.length === 0 &&
amenityCount2KmParams.length === 0 &&
amenityCount5KmParams.length === 0
) {
return {};
}
const filters: FeatureFilters = {};
for (const entry of filterParams) {
const colonIdx = entry.indexOf(':');
if (colonIdx === -1) continue;
const name = entry.substring(0, colonIdx);
const rest = entry.substring(colonIdx + 1);
if (rest.includes(':')) {
const [minStr, maxStr] = rest.split(':');
const min = Number(minStr);
const max = Number(maxStr);
if (!isNaN(min) && !isNaN(max)) {
filters[name] = [min, max];
}
} else if (rest.includes('|')) {
filters[name] = rest.split('|');
} else {
filters[name] = [rest];
}
}
schoolParams.forEach((entry, index) => {
const parts = entry.split(':');
// 4 parts is the current phase:rating:min:max form; 5 parts is the legacy
// phase:rating:distance:min:max form, whose distance segment is ignored.
if (parts.length !== 4 && parts.length !== 5) return;
const phase = parts[0] as SchoolPhase;
const rating = parts[1] as SchoolRating;
const min = Number(parts[parts.length - 2]);
const max = Number(parts[parts.length - 1]);
if (
(phase !== 'primary' && phase !== 'secondary') ||
(rating !== 'good' && rating !== 'outstanding') ||
isNaN(min) ||
isNaN(max)
) {
return;
}
filters[createSchoolFilterKey(phase, rating, index)] = [min, max];
});
crimeParams.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 (!isSpecificCrimeFeatureName(featureName) || isNaN(min) || isNaN(max)) {
return;
}
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;
const featureName = parts.slice(0, -2).join(':');
const min = Number(parts[parts.length - 2]);
const max = Number(parts[parts.length - 1]);
if (!isElectionVoteShareFeatureName(featureName) || isNaN(min) || isNaN(max)) {
return;
}
filters[createElectionVoteShareFilterKey(featureName, index)] = [min, max];
});
ethnicityParams.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 (!isEthnicityFeatureName(featureName) || isNaN(min) || isNaN(max)) {
return;
}
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];
});
councilParams.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]);
// getCouncilWindow recognises all three pill columns, incl. the shared
// "% Social rent" (which isCouncilFeatureName excludes from bare folding).
if (getCouncilWindow(featureName) == null || isNaN(min) || isNaN(max)) {
return;
}
filters[createCouncilFilterKey(featureName, index)] = [min, max];
});
const parsePoiParams = (entries: string[], filterName: PoiFilterName, startIndex: number) => {
entries.forEach((entry, index) => {
const parts = entry.split(':');
if (parts.length < 3) return;
const featureName = decodeURIComponent(parts.slice(0, -2).join(':'));
const min = Number(parts[parts.length - 2]);
const max = Number(parts[parts.length - 1]);
const targetFilterName = getPoiFilterName(featureName);
if (!targetFilterName || targetFilterName !== filterName || isNaN(min) || isNaN(max)) {
return;
}
filters[createPoiFilterKey(targetFilterName, featureName, startIndex + index)] = [min, max];
});
};
const parsePoiCountParams = (
entries: string[],
filterName: PoiFilterName,
startIndex: number
) => {
parsePoiParams(entries, filterName, startIndex);
};
parsePoiParams(amenityDistanceParams, POI_DISTANCE_FILTER_NAME, 0);
parsePoiParams(
transportDistanceParams,
TRANSPORT_DISTANCE_FILTER_NAME,
amenityDistanceParams.length
);
parsePoiCountParams(
amenityCount2KmParams,
POI_COUNT_2KM_FILTER_NAME,
amenityDistanceParams.length + transportDistanceParams.length
);
parsePoiCountParams(
amenityCount5KmParams,
POI_COUNT_5KM_FILTER_NAME,
amenityDistanceParams.length + transportDistanceParams.length + amenityCount2KmParams.length
);
return filters;
}
export function parseUrlState(): UrlState {
const params = new URLSearchParams(window.location.search);
const result: UrlState = {
viewState: INITIAL_VIEW_STATE,
hasExplicitView: false,
filters: parseFilters(params),
poiCategories: new Set(),
overlays: new Set(DEFAULT_OVERLAY_IDS),
basemap: 'standard',
colorOpacity: DEFAULT_COLOR_OPACITY,
listings: DEFAULT_LISTINGS_MODE,
tab: 'area',
};
// Share-link code: may grant bbox-scoped access when the backend record
// contains an explicit server-created grant.
const share = params.get('share');
if (share && /^[a-z0-9]{1,20}$/i.test(share)) {
result.share = share;
}
// View state: separate lat/lon/zoom params
const lat = params.get('lat');
const lon = params.get('lon');
const zoom = params.get('zoom');
if (lat && lon && zoom) {
const latN = Number(lat);
const lonN = Number(lon);
const zoomN = Number(zoom);
if (!isNaN(latN) && !isNaN(lonN) && !isNaN(zoomN)) {
result.viewState = { latitude: latN, longitude: lonN, zoom: zoomN, pitch: 0 };
result.hasExplicitView = true;
}
}
// POI categories: repeated `poi` params
const poiParams = params.getAll('poi');
if (poiParams.length > 0) {
if (poiParams.includes(POI_NONE_PARAM)) {
result.poiCategories = new Set();
} else {
result.poiCategories = new Set(
poiParams.filter((value) => value && value !== POI_NONE_PARAM)
);
}
}
// Overlays default to DEFAULT_OVERLAY_IDS on a fresh visit; explicit `overlay`
// params override that, and the `__none` sentinel records "all off" so it
// survives a reload instead of silently restoring the defaults.
const overlayParams = params.getAll('overlay');
if (overlayParams.length > 0) {
result.overlays = overlayParams.includes(OVERLAY_NONE_PARAM)
? new Set()
: new Set(overlayParams.filter(isOverlayId));
}
// Crime-type filter: repeated `crimeType` params, or the `__none` sentinel.
// Absent means "all types" (handled downstream), so leave it undefined.
const crimeTypeParams = params.getAll('crimeType');
if (crimeTypeParams.length > 0) {
result.crimeTypes = crimeTypeParams.includes(CRIME_TYPES_NONE_PARAM)
? new Set()
: new Set(crimeTypeParams.filter(isCrimeTypeValue));
}
const basemap = params.get('basemap');
if (basemap && isBasemapId(basemap)) {
result.basemap = basemap;
}
const colorOpacity = params.get('colorOpacity');
if (colorOpacity != null) {
const colorOpacityPercent = Number(colorOpacity);
if (!isNaN(colorOpacityPercent)) {
result.colorOpacity = normalizeColorOpacity(colorOpacityPercent / 100);
}
}
// Listings mode: which slice of the for-sale listings is shown. Absent means
// the default ('all'); an explicit value survives a reload.
const listings = params.get('listings');
if (listings && (LISTINGS_MODES as readonly string[]).includes(listings)) {
result.listings = listings as ListingsMode;
}
// Tab: full name
const tab = params.get('tab');
if (tab === 'properties' || tab === 'area') {
result.tab = tab;
}
// Selected postcode. This is also accepted as the historical one-time
// navigate-to-postcode param used by saved-property links.
const pc = params.get(SELECTED_POSTCODE_PARAM);
if (pc) {
result.postcode = pc;
}
// Travel time: repeated `tt` params
// Format: serverMode:slug:label[:b][:min:max]
// serverMode is one of: car | bicycle | walking | transit | transit-no-bus
// | transit-no-change | transit-no-change-no-bus | transit-one-change
// | transit-one-change-no-bus. Unknown modes cause the entry to be dropped
// here (parseServerMode returns null) so we don't silently broaden the filter.
const ttParams = params.getAll('tt');
if (ttParams.length > 0) {
const entries: TravelTimeEntry[] = [];
for (const tt of ttParams) {
const parts = tt.split(':');
if (parts.length < 3) continue;
const parsedMode = parseServerMode(parts[0]);
if (!parsedMode) continue;
const slug = parts[1];
const label = decodeURIComponent(parts[2]);
const useBest = parts.length >= 4 && parts[3] === 'b';
const rangeOffset = useBest ? 1 : 0;
let timeRange: [number, number] | null = null;
if (parts.length >= 5 + rangeOffset) {
const min = Number(parts[3 + rangeOffset]);
const max = Number(parts[4 + rangeOffset]);
if (!isNaN(min) && !isNaN(max)) {
// Clamp loaded max-time to the data ceiling. Older shared URLs
// may have max=120 from the previous slider range; no data exists
// above MAX_TRAVEL_MINUTES so the result is identical.
timeRange = [min, Math.min(max, MAX_TRAVEL_MINUTES)];
}
}
entries.push({
mode: parsedMode.mode,
slug,
label,
timeRange,
useBest,
noChange: parsedMode.noChange,
oneChange: parsedMode.oneChange,
noBuses: parsedMode.noBuses,
});
}
if (entries.length > 0) {
result.travelTime = { entries: dedupeTravelTimeEntries(entries) };
}
}
return result;
}
export function stateToParams(
viewState: { latitude: number; longitude: number; zoom: number } | null,
filters: FeatureFilters,
features: FeatureMeta[],
selectedPOICategories: Set<string>,
rightPaneTab: 'properties' | 'area',
travelTimeEntries?: TravelTimeEntry[],
share?: string,
selectedOverlays?: Set<OverlayId>,
basemap?: BasemapId,
selectedCrimeTypes?: Set<string>,
selectedPostcode?: string,
colorOpacity?: number,
listingsMode?: ListingsMode
): URLSearchParams {
const params = new URLSearchParams();
if (share) {
params.set('share', share);
}
if (viewState) {
params.set('lat', viewState.latitude.toFixed(4));
params.set('lon', viewState.longitude.toFixed(4));
params.set('zoom', viewState.zoom.toFixed(1));
}
for (const [name, value] of Object.entries(filters)) {
const schoolConfig = getSchoolFilterConfig(name);
if (schoolConfig && isSchoolFilterName(name)) {
const [min, max] = value as [number, number];
params.append('school', `${schoolConfig.phase}:${schoolConfig.rating}:${min}:${max}`);
continue;
}
const specificCrimeFeatureName = getSpecificCrimeFeatureName(name);
if (specificCrimeFeatureName && isSpecificCrimeFilterName(name)) {
const [min, max] = value as [number, number];
params.append('crime', `${specificCrimeFeatureName}:${min}:${max}`);
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];
params.append('voteShare', `${electionVoteShareFeatureName}:${min}:${max}`);
continue;
}
const ethnicityFeatureName = getEthnicityFeatureName(name);
if (ethnicityFeatureName && isEthnicityFilterName(name)) {
const [min, max] = value as [number, number];
params.append('ethnicity', `${ethnicityFeatureName}:${min}:${max}`);
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;
}
// After tenure: a "Council housing:" key never matches isTenureFilterName,
// and a bare "% Social rent" is handled by the tenure branch above.
const councilFeatureName = getCouncilFeatureName(name);
if (councilFeatureName && isCouncilFilterName(name)) {
const [min, max] = value as [number, number];
params.append('council', `${councilFeatureName}:${min}:${max}`);
continue;
}
const amenityDistanceFeatureName = getPoiDistanceFeatureName(name);
if (amenityDistanceFeatureName && isPoiDistanceFilterName(name)) {
const [min, max] = value as [number, number];
const filterName = getPoiFilterName(name);
const paramName =
filterName === POI_COUNT_2KM_FILTER_NAME
? 'amenityCount2km'
: filterName === POI_COUNT_5KM_FILTER_NAME
? 'amenityCount5km'
: filterName === TRANSPORT_DISTANCE_FILTER_NAME
? 'transportDistance'
: 'amenityDistance';
params.append(paramName, `${encodeURIComponent(amenityDistanceFeatureName)}:${min}:${max}`);
continue;
}
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum' || typeof value[0] === 'string') {
params.append('filter', `${name}:${(value as string[]).join('|')}`);
} else {
const [min, max] = value as [number, number];
params.append('filter', `${name}:${min}:${max}`);
}
}
if (selectedPOICategories.size === 0) {
params.append('poi', POI_NONE_PARAM);
} else {
for (const category of selectedPOICategories) {
params.append('poi', category);
}
}
if (rightPaneTab === 'properties') {
params.set('tab', 'properties');
}
if (selectedPostcode) {
params.set('pc', selectedPostcode);
}
if (selectedOverlays) {
if (selectedOverlays.size === 0) {
// Distinguish a deliberate "all overlays off" from a fresh visit, which
// would otherwise re-apply the defaults on reload.
params.append('overlay', OVERLAY_NONE_PARAM);
} else {
for (const overlay of selectedOverlays) {
params.append('overlay', overlay);
}
}
}
// Crime-type selection only matters while the crime overlay is on. Emit it
// only when it deviates from the default (all types selected) to keep URLs
// clean; a fully-deselected state uses an explicit sentinel.
if (selectedOverlays?.has(CRIME_OVERLAY_ID) && selectedCrimeTypes) {
if (selectedCrimeTypes.size === 0) {
params.append('crimeType', CRIME_TYPES_NONE_PARAM);
} else if (selectedCrimeTypes.size < CRIME_TYPES.length) {
for (const crimeType of selectedCrimeTypes) {
params.append('crimeType', crimeType);
}
}
}
if (basemap && basemap !== 'standard') {
params.set('basemap', basemap);
}
if (colorOpacity != null) {
const normalizedColorOpacity = normalizeColorOpacity(colorOpacity);
if (normalizedColorOpacity !== DEFAULT_COLOR_OPACITY) {
params.set('colorOpacity', String(colorOpacityToPercent(normalizedColorOpacity)));
}
}
// Emit the listings mode only when it deviates from the default, matching the
// "only serialize non-default" convention above (keeps default URLs clean).
if (listingsMode && listingsMode !== DEFAULT_LISTINGS_MODE) {
params.set('listings', listingsMode);
}
// Travel time: repeated `tt` params
if (travelTimeEntries) {
for (const entry of dedupeTravelTimeEntries(travelTimeEntries)) {
if (!entry.slug) continue;
const serverMode = resolveTransitVariant(entry);
let val = `${serverMode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
if (entry.useBest) val += ':b';
if (entry.timeRange) {
val += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
}
params.append('tt', val);
}
}
return params;
}
export function summarizeParams(queryString: string): string {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const i18n = require('../i18n').default as {
t: (key: string, opts?: Record<string, unknown>) => string;
};
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { ts } = require('../i18n/server') as { ts: (v: string) => string };
const params = new URLSearchParams(queryString);
const parts: 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 councilParams = params.getAll('council');
const amenityDistanceParams = params.getAll('amenityDistance');
const transportDistanceParams = params.getAll('transportDistance');
const amenityCount2KmParams = params.getAll('amenityCount2km');
const amenityCount5KmParams = params.getAll('amenityCount5km');
if (
filterParams.length > 0 ||
schoolParams.length > 0 ||
crimeParams.length > 0 ||
crimeSeverityParams.length > 0 ||
voteShareParams.length > 0 ||
ethnicityParams.length > 0 ||
qualificationParams.length > 0 ||
tenureParams.length > 0 ||
councilParams.length > 0 ||
amenityDistanceParams.length > 0 ||
transportDistanceParams.length > 0 ||
amenityCount2KmParams.length > 0 ||
amenityCount5KmParams.length > 0
) {
const filterNames = filterParams
.map((entry) => {
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;
if (isCouncilFeatureName(name)) return COUNCIL_FILTER_NAME;
const poiFilterName = getPoiFilterName(name);
if (poiFilterName) return poiFilterName;
return name;
})
.filter((n) => n);
for (let i = 0; i < schoolParams.length; i++) filterNames.push(SCHOOL_FILTER_NAME);
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 < councilParams.length; i++) {
filterNames.push(COUNCIL_FILTER_NAME);
}
for (let i = 0; i < amenityDistanceParams.length; i++) {
filterNames.push(POI_DISTANCE_FILTER_NAME);
}
for (let i = 0; i < transportDistanceParams.length; i++) {
filterNames.push(TRANSPORT_DISTANCE_FILTER_NAME);
}
for (let i = 0; i < amenityCount2KmParams.length; i++) {
filterNames.push(POI_COUNT_2KM_FILTER_NAME);
}
for (let i = 0; i < amenityCount5KmParams.length; i++) {
filterNames.push(POI_COUNT_5KM_FILTER_NAME);
}
if (filterNames.length > 0) {
parts.push(
filterNames.length <= 2
? filterNames.map((n) => ts(n)).join(', ')
: i18n.t('format.nFilters', { count: filterNames.length })
);
}
}
const poiParams = params.getAll('poi');
if (poiParams.length > 0) {
const count = poiParams.filter((value) => value && value !== POI_NONE_PARAM).length;
if (count > 0) {
parts.push(
count === 1
? i18n.t('format.poiCategory', { count })
: i18n.t('format.poiCategories', { count })
);
}
}
const ttParams = params.getAll('tt');
if (ttParams.length > 0) {
const count = ttParams.filter(Boolean).length;
if (count > 0) {
parts.push(
count === 1
? i18n.t('format.travelDestination', { count })
: i18n.t('format.travelDestinations', { count })
);
}
}
return parts.length > 0 ? parts.join(' + ') : i18n.t('format.noFilters');
}