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; overlays: Set; crimeTypes?: Set; 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, rightPaneTab: 'properties' | 'area', travelTimeEntries?: TravelTimeEntry[], share?: string, selectedOverlays?: Set, basemap?: BasemapId, selectedCrimeTypes?: Set, 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; }; // 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'); }