Boring changes

This commit is contained in:
Andras Schmelczer 2026-07-12 15:10:26 +01:00
parent cfaf58dfba
commit 920119ff48
32 changed files with 912 additions and 88 deletions

View file

@ -8,6 +8,7 @@ import { createElectionVoteShareFilterKey } from './election-filter';
import { createEthnicityFilterKey } from './ethnicity-filter';
import { createQualificationFilterKey } from './qualification-filter';
import { createTenureFilterKey } from './tenure-filter';
import { createCouncilFilterKey } from './council-filter';
import {
POI_COUNT_2KM_FILTER_NAME,
TRANSPORT_DISTANCE_FILTER_NAME,
@ -180,6 +181,23 @@ describe('api utilities', () => {
).toBe('% Owner occupied:20:60;;% Private rent:0:25');
});
it('serializes council filters, incl. the shared social-rent "current" column', () => {
const features: FeatureMeta[] = [
{ name: '% Social rent', type: 'numeric', min: 0, max: 100 },
{ name: '% Ex-council', type: 'numeric', min: 0, max: 100 },
];
expect(
buildFilterString(
{
[createCouncilFilterKey('% Social rent', 1)]: [30, 100],
[createCouncilFilterKey('% Ex-council', 2)]: [10, 90],
},
features
)
).toBe('% Social rent:30:100;;% Ex-council:10:90');
});
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

@ -8,6 +8,7 @@ import { getElectionVoteShareFeatureName } from './election-filter';
import { getEthnicityFeatureName } from './ethnicity-filter';
import { getQualificationFeatureName } from './qualification-filter';
import { getTenureFeatureName } from './tenure-filter';
import { getCouncilFeatureName } from './council-filter';
import { getPoiDistanceFeatureName } from './poi-distance-filter';
const SCREENSHOT_LANGUAGES = new Set(['en', 'fr', 'de', 'zh', 'hi', 'hu']);
@ -159,6 +160,7 @@ export function buildFilterString(
getEthnicityFeatureName(name) ??
getQualificationFeatureName(name) ??
getTenureFeatureName(name) ??
getCouncilFeatureName(name) ??
getPoiDistanceFeatureName(name) ??
name;
const prev = merged.get(backendName);

View file

@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import type { TFunction } from 'i18next';
import { resolveAuthError } from './auth-errors';
// Echo the key so we can assert which generic fallback path ran.
const t = ((key: string) => key) as unknown as TFunction;
// All copy resolves through t(); the mock echoes the key, so we assert on keys.
describe('resolveAuthError', () => {
it('maps 429 to a rate-limit message on every flow', () => {
const r = resolveAuthError({ status: 429 }, 'login', t);
expect(r.message).toBe('auth.errorRateLimited');
expect(r.action).toBeNull();
});
it('maps a duplicate-email signup to "email taken" with a log-in affordance', () => {
const err = { status: 400, response: { data: { email: { code: 'validation_not_unique' } } } };
const r = resolveAuthError(err, 'register', t);
expect(r.message).toBe('auth.errorEmailTaken');
expect(r.action).toBe('switchToLogin');
});
it('maps a 400 login failure to invalid credentials (not a raw PB string)', () => {
const r = resolveAuthError({ status: 400 }, 'login', t);
expect(r.message).toBe('auth.errorInvalidCredentials');
expect(r.message).not.toMatch(/Failed to authenticate/);
});
it('maps a weak-password signup', () => {
const err = {
status: 400,
response: { data: { password: { code: 'validation_length_out_of_range' } } },
};
const r = resolveAuthError(err, 'register', t);
expect(r.message).toBe('auth.errorPasswordWeak');
});
it('falls back to the existing generic key for unknown login errors', () => {
expect(resolveAuthError({ status: 500 }, 'login', t).message).toBe('auth.loginFailed');
});
it('treats a missing status as a network error', () => {
const r = resolveAuthError(new Error('boom'), 'login', t);
expect(r.message).toBe('auth.errorNetwork');
});
});

View file

@ -0,0 +1,79 @@
import type { TFunction } from 'i18next';
/**
* Affordance the auth modal can render next to an error message.
* `switchToLogin` is used both when an email already exists AND after a
* successful signup whose auto-login failed (the account is real, so the right
* recovery is to log in, not to register again).
*/
export type AuthErrorAction = 'switchToLogin' | null;
export type AuthContext = 'login' | 'register' | 'oauth' | 'reset';
export interface ResolvedAuthError {
/** User-facing message (already resolved to a string). */
message: string;
/** Optional affordance, e.g. a "Log in instead" button. */
action: AuthErrorAction;
}
interface PbErrorLike {
status?: number;
response?: {
data?: Record<string, { code?: string; message?: string } | undefined>;
};
}
function fieldCode(err: PbErrorLike, field: string): string | undefined {
return err.response?.data?.[field]?.code;
}
/**
* Translates a PocketBase auth failure into a friendly, localized message
* (+ optional affordance) instead of leaking raw strings like "Failed to create
* record.". All copy resolves through the passed-in t().
*/
export function resolveAuthError(
err: unknown,
context: AuthContext,
t: TFunction
): ResolvedAuthError {
const e = (err && typeof err === 'object' ? err : {}) as PbErrorLike;
const status = typeof e.status === 'number' ? e.status : 0;
// Rate limiting applies to every flow.
if (status === 429) {
return { message: t('auth.errorRateLimited'), action: null };
}
// No HTTP status → request never reached the server (offline / DNS / CORS).
if (status === 0) {
return { message: t('auth.errorNetwork'), action: null };
}
if (context === 'register') {
if (fieldCode(e, 'email') === 'validation_not_unique') {
return { message: t('auth.errorEmailTaken'), action: 'switchToLogin' };
}
if (fieldCode(e, 'password')) {
return { message: t('auth.errorPasswordWeak'), action: null };
}
if (fieldCode(e, 'email')) {
return { message: t('auth.errorEmailInvalid'), action: null };
}
return { message: t('auth.registrationFailed'), action: null };
}
if (context === 'login') {
if (status === 400 || status === 401 || status === 403) {
return { message: t('auth.errorInvalidCredentials'), action: null };
}
return { message: t('auth.loginFailed'), action: null };
}
if (context === 'oauth') {
return { message: t('auth.oauthFailed'), action: null };
}
return { message: t('auth.passwordResetFailed'), action: null };
}

View file

@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest';
import type { FeatureFilters, FeatureMeta } from '../types';
import {
COUNCIL_FILTER_NAME,
COUNCIL_VARIANT_CONFIG,
createCouncilFilterKey,
getCouncilFeatureName,
getCouncilFilterKeyId,
getCouncilWindow,
getDefaultCouncilFeatureName,
isCouncilFeatureName,
isCouncilFilterName,
normalizeCouncilFilters,
parseCouncilFilterKey,
replaceCouncilFilterKeySelection,
withCouncilWindow,
} from './council-filter';
const numeric = (name: string): FeatureMeta => ({ name, type: 'numeric', min: 0, max: 100 });
const ALL_FEATURES = [
numeric('% Social rent'),
numeric('% Ex-council'),
numeric('% Council housing'),
];
describe('council feature recognition', () => {
it('owns the two EPC columns for bare folding, but NOT the shared Census column', () => {
expect(isCouncilFeatureName('% Ex-council')).toBe(true);
expect(isCouncilFeatureName('% Council housing')).toBe(true);
// "% Social rent" belongs to Tenure when bare; council reaches it only via a key.
expect(isCouncilFeatureName('% Social rent')).toBe(false);
expect(isCouncilFeatureName('% Private rent')).toBe(false);
});
it('treats prefixed keys (including the current/Census one) as council filters', () => {
expect(isCouncilFilterName('Council housing:%25%20Ex-council:0')).toBe(true);
expect(isCouncilFilterName('Council housing:%25%20Social%20rent:0')).toBe(true);
expect(isCouncilFilterName('% Ex-council')).toBe(true);
expect(isCouncilFilterName('% Social rent')).toBe(false);
expect(isCouncilFilterName('Tenure:%25%20Social%20rent:0')).toBe(false);
});
});
describe('council pill (window) mapping', () => {
it('maps each column to its pill id and back', () => {
expect(getCouncilWindow('% Social rent')).toBe('current');
expect(getCouncilWindow('% Ex-council')).toBe('ex');
expect(getCouncilWindow('% Council housing')).toBe('both');
expect(getCouncilWindow('% Private rent')).toBeNull();
expect(withCouncilWindow('% Council housing', 'current')).toBe('% Social rent');
expect(withCouncilWindow('% Social rent', 'ex')).toBe('% Ex-council');
expect(withCouncilWindow('% Ex-council', 'both')).toBe('% Council housing');
});
});
describe('council filter keys', () => {
it('round-trips the key, including the shared Census column', () => {
const exKey = createCouncilFilterKey('% Ex-council', 2);
expect(getCouncilFilterKeyId(exKey)).toBe('2');
expect(parseCouncilFilterKey(exKey)).toBe('% Ex-council');
expect(getCouncilFeatureName(exKey)).toBe('% Ex-council');
const currentKey = createCouncilFilterKey('% Social rent', 0);
expect(parseCouncilFilterKey(currentKey)).toBe('% Social rent');
expect(getCouncilFeatureName(currentKey)).toBe('% Social rent');
});
it('does not resolve a bare "% Social rent" (leaves it for Tenure)', () => {
expect(getCouncilFeatureName('% Social rent')).toBeNull();
expect(getCouncilFeatureName('% Ex-council')).toBe('% Ex-council');
});
it('re-points a key to a different pill column while keeping its id', () => {
const key = createCouncilFilterKey('% Council housing', 5);
const repointed = replaceCouncilFilterKeySelection(key, '% Social rent');
expect(getCouncilFilterKeyId(repointed)).toBe('5');
expect(getCouncilFeatureName(repointed)).toBe('% Social rent');
});
});
describe('normalizeCouncilFilters', () => {
it('folds bare EPC columns into keys but leaves "% Social rent" untouched', () => {
const input: FeatureFilters = {
'% Ex-council': [10, 90],
'% Social rent': [0, 100],
};
const out = normalizeCouncilFilters(input);
// The Census column is preserved verbatim (Tenure will fold it elsewhere).
expect(out['% Social rent']).toEqual([0, 100]);
expect('% Ex-council' in out).toBe(false);
const folded = Object.keys(out).find((k) => k.startsWith('Council housing:'));
expect(folded).toBeTruthy();
expect(getCouncilFeatureName(folded!)).toBe('% Ex-council');
});
});
describe('council variant config', () => {
it('defaults to the "both" column and hides the dropdown (single variant)', () => {
expect(getDefaultCouncilFeatureName(ALL_FEATURES)).toBe('% Council housing');
expect(COUNCIL_VARIANT_CONFIG.featureNames).toEqual(['% Council housing']);
expect(COUNCIL_VARIANT_CONFIG.filterName).toBe(COUNCIL_FILTER_NAME);
expect(COUNCIL_VARIANT_CONFIG.window?.options.map((o) => o.id)).toEqual([
'current',
'ex',
'both',
]);
});
it('is not addable when the EPC columns are absent', () => {
expect(getDefaultCouncilFeatureName([numeric('% Social rent')])).toBeNull();
});
});

View file

@ -0,0 +1,187 @@
import type { FeatureFilters, FeatureMeta } from '../types';
import type { VariantFilterConfig } from './variant-filter';
export const COUNCIL_FILTER_NAME = 'Council housing';
export const COUNCIL_FILTER_KEY_PREFIX = `${COUNCIL_FILTER_NAME}:`;
/**
* The "Council housing" filter folds three neighbourhood percentages into one
* card whose pill toggle picks current / ex / both:
* - current -> "% Social rent" (Census TS054, the share renting socially now)
* - ex -> "% Ex-council" (EPC: dwellings once council, since sold off)
* - both -> "% Council housing" (EPC: dwellings ever recorded as council)
*
* The pill toggle is implemented as a single-variant window config (so the
* dropdown stays hidden, see VariantFilterCard) whose three "windows" re-point
* the filter key to a different backend column.
*
* "% Social rent" is ALSO owned by the Tenure filter. To keep the two filters
* disjoint we recognise it only when it appears inside an explicit
* `Council housing:` key (COUNCIL_COLUMN_SET), never as a bare name to fold
* (COUNCIL_OWNED_FEATURE_NAMES). A bare "% Social rent" always belongs to
* Tenure. The Census column is reached under this card solely via the toggle.
*/
export const COUNCIL_WINDOW_CURRENT = 'current';
export const COUNCIL_WINDOW_EX = 'ex';
export const COUNCIL_WINDOW_BOTH = 'both';
const COUNCIL_CURRENT_FEATURE = '% Social rent';
const COUNCIL_EX_FEATURE = '% Ex-council';
const COUNCIL_BOTH_FEATURE = '% Council housing';
/** The canonical (default-window) variant: "both", the most inclusive view. */
export const COUNCIL_FEATURE_NAMES = [COUNCIL_BOTH_FEATURE] as const;
/** Every column the pill toggle can point at (incl. the shared Census column). */
const COUNCIL_COLUMN_SET = new Set<string>([
COUNCIL_CURRENT_FEATURE,
COUNCIL_EX_FEATURE,
COUNCIL_BOTH_FEATURE,
]);
/**
* Columns the Council filter OWNS for bare-name folding: the two EPC columns
* only. "% Social rent" is deliberately excluded so it folds into Tenure.
*/
const COUNCIL_OWNED_FEATURE_NAMES = new Set<string>([COUNCIL_EX_FEATURE, COUNCIL_BOTH_FEATURE]);
const COUNCIL_WINDOW_BY_FEATURE: Record<string, string> = {
[COUNCIL_CURRENT_FEATURE]: COUNCIL_WINDOW_CURRENT,
[COUNCIL_EX_FEATURE]: COUNCIL_WINDOW_EX,
[COUNCIL_BOTH_FEATURE]: COUNCIL_WINDOW_BOTH,
};
const COUNCIL_FEATURE_BY_WINDOW: Record<string, string> = {
[COUNCIL_WINDOW_CURRENT]: COUNCIL_CURRENT_FEATURE,
[COUNCIL_WINDOW_EX]: COUNCIL_EX_FEATURE,
[COUNCIL_WINDOW_BOTH]: COUNCIL_BOTH_FEATURE,
};
/** A bare column owned by the Council filter (for folding / browser placement). */
export function isCouncilFeatureName(name: string): boolean {
return COUNCIL_OWNED_FEATURE_NAMES.has(name);
}
export function isCouncilFilterName(name: string): boolean {
return isCouncilFeatureName(name) || name.startsWith(COUNCIL_FILTER_KEY_PREFIX);
}
export function createCouncilFilterKey(featureName: string, id: number | string): string {
return `${COUNCIL_FILTER_KEY_PREFIX}${encodeURIComponent(featureName)}:${id}`;
}
export function getCouncilFilterKeyId(name: string): string | null {
if (!name.startsWith(COUNCIL_FILTER_KEY_PREFIX)) return null;
const rest = name.substring(COUNCIL_FILTER_KEY_PREFIX.length);
const lastColon = rest.lastIndexOf(':');
return lastColon === -1 ? null : rest.substring(lastColon + 1);
}
export function parseCouncilFilterKey(name: string): string | null {
if (!name.startsWith(COUNCIL_FILTER_KEY_PREFIX)) return null;
const rest = name.substring(COUNCIL_FILTER_KEY_PREFIX.length);
const lastColon = rest.lastIndexOf(':');
if (lastColon === -1) return null;
const decoded = decodeURIComponent(rest.substring(0, lastColon));
// Accept any of the three pill columns here, including the shared Census one.
return COUNCIL_COLUMN_SET.has(decoded) ? decoded : null;
}
export function getCouncilFeatureName(name: string): string | null {
const fromKey = parseCouncilFilterKey(name);
if (fromKey != null) return fromKey;
// Bare names: resolve only the owned EPC columns. A bare "% Social rent"
// returns null so it falls through to the Tenure resolver.
return isCouncilFeatureName(name) ? name : null;
}
export function replaceCouncilFilterKeySelection(key: string, featureName: string): string {
const id = getCouncilFilterKeyId(key) ?? '0';
return createCouncilFilterKey(featureName, id);
}
/** Window id (current/ex/both) for a backend column, or null if unrecognised. */
export function getCouncilWindow(featureName: string): string | null {
return COUNCIL_WINDOW_BY_FEATURE[featureName] ?? null;
}
/** The backend column for a given pill (window), independent of the input. */
export function withCouncilWindow(_featureName: string, windowId: string): string {
return COUNCIL_FEATURE_BY_WINDOW[windowId] ?? COUNCIL_BOTH_FEATURE;
}
export function getDefaultCouncilFeatureName(features: FeatureMeta[]): string | null {
return (
COUNCIL_FEATURE_NAMES.find((name) => features.some((feature) => feature.name === name)) ?? null
);
}
export function normalizeCouncilFilters(filters: FeatureFilters): FeatureFilters {
let changed = false;
const next: FeatureFilters = {};
for (const [name, value] of Object.entries(filters)) {
if (isCouncilFeatureName(name)) {
next[createCouncilFilterKey(name, Object.keys(next).length)] = value;
changed = true;
continue;
}
next[name] = value;
}
return changed ? next : filters;
}
export function getCouncilFilterMeta(features: FeatureMeta[]): FeatureMeta {
const sourceFeatureName = getDefaultCouncilFeatureName(features);
const sourceFeature = sourceFeatureName
? features.find((feature) => feature.name === sourceFeatureName)
: undefined;
return {
name: COUNCIL_FILTER_NAME,
type: 'numeric',
group: 'Neighbours',
min: sourceFeature?.min ?? 0,
max: sourceFeature?.max ?? 100,
step: 1,
description: 'Share of homes that are current or former council housing',
detail:
"Filter by an area's council-housing footprint. Toggle between homes currently rented socially (Census, neighbourhood level), homes that were council but have since been sold (ex-council, EPC records per postcode), or both combined.",
source: 'epc',
suffix: '%',
};
}
export function clampCouncilRange(
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 COUNCIL_VARIANT_CONFIG: VariantFilterConfig = {
filterName: COUNCIL_FILTER_NAME,
// One canonical variant (default window "both"); the dropdown stays hidden and
// the three pills below re-point the filter key to a different column.
featureNames: COUNCIL_FEATURE_NAMES,
dropdownLabelKey: 'filters.councilType',
getFilterMeta: getCouncilFilterMeta,
getDefaultFeatureName: getDefaultCouncilFeatureName,
getFeatureName: getCouncilFeatureName,
replaceFilterKeySelection: replaceCouncilFilterKeySelection,
clampRange: clampCouncilRange,
window: {
labelKey: 'filters.councilStatus',
options: [
{ id: COUNCIL_WINDOW_CURRENT, labelKey: 'filters.councilCurrent' },
{ id: COUNCIL_WINDOW_EX, labelKey: 'filters.councilEx' },
{ id: COUNCIL_WINDOW_BOTH, labelKey: 'filters.councilBoth' },
],
getWindow: getCouncilWindow,
withWindow: withCouncilWindow,
},
};

View file

@ -77,7 +77,7 @@ export function formatStationDistance(distanceKmValue: number): string {
*
* At Tube/DLR interchanges (Bank, Canning Town, Stratford, ) NaPTAN merges the
* Underground and DLR nodes into one POI whose category resolves to "Tube
* station" but whose name was taken from the DLR node so the raw name "Bank
* station" but whose name was taken from the DLR node, so the raw name "Bank
* DLR Station" would otherwise sit under a "Tube station" label. Dropping the
* mode word removes both that contradiction and the everyday redundancy of
* "Beckton DLR Station" shown above a "DLR station" subtitle.

View file

@ -37,7 +37,7 @@ export const OVERLAYS: OverlayDefinition[] = [
label: 'Trees & woodland',
description: 'Tree canopy and woodland polygons',
detail:
'Forest Research Trees Outside Woodland (TOW) v1 canopy polygons — lone trees and groups of trees — combined with National Forest Inventory (NFI) woodland blocks (≥0.5 ha) that TOW deliberately excludes. Together they cover both street trees and actual woods. Polygon opacity scales with canopy area.',
'Forest Research Trees Outside Woodland (TOW) v1 canopy polygons (lone trees and groups of trees) combined with National Forest Inventory (NFI) woodland blocks (≥0.5 ha) that TOW deliberately excludes. Together they cover both street trees and actual woods. Polygon opacity scales with canopy area.',
defaultEnabled: true,
},
{
@ -45,7 +45,7 @@ export const OVERLAYS: OverlayDefinition[] = [
label: 'Property borders',
description: 'Individual freehold property/land-parcel boundaries',
detail:
'HM Land Registry INSPIRE Index Polygons the position and indicative extent of freehold registered property in England & Wales, drawn as outlines at street level. These are “general boundaries” for guidance only, not the precise legal boundary of a property, and they exclude leasehold-only interests and unregistered land (roughly 8590% of freehold land is covered). This information is subject to Crown copyright and database rights 2026 and is reproduced with the permission of HM Land Registry. The polygons (including the associated geometry, namely x, y co-ordinates) are subject to Crown copyright and database rights 2026 Ordnance Survey AC0000851063. Licensed under the Open Government Licence v3.0.',
'HM Land Registry INSPIRE Index Polygons: the position and indicative extent of freehold registered property in England & Wales, drawn as outlines at street level. These are “general boundaries” for guidance only, not the precise legal boundary of a property, and they exclude leasehold-only interests and unregistered land (roughly 8590% of freehold land is covered). This information is subject to Crown copyright and database rights 2026 and is reproduced with the permission of HM Land Registry. The polygons (including the associated geometry, namely x, y co-ordinates) are subject to Crown copyright and database rights 2026 Ordnance Survey AC0000851063. Licensed under the Open Government Licence v3.0.',
defaultEnabled: true,
},
{
@ -53,7 +53,7 @@ export const OVERLAYS: OverlayDefinition[] = [
label: 'New developments',
description: 'Planned new-home sites (brownfield register + Homes England)',
detail:
'A pipeline of new housing. Blue markers show sites on the statutory MHCLG Brownfield Land registers — each carrying an estimated net-dwelling capacity and planning-permission status — together with Homes England Land Hub disposal sites. These show where new homes are planned, often years before they appear in EPC or sale records. Dwelling figures are capacity estimates, not commitments, and a site on a register is an opportunity rather than a guarantee of construction. Licensed under the Open Government Licence v3.0.',
'A pipeline of new housing. Blue markers show sites on the statutory MHCLG Brownfield Land registers (each carrying an estimated net-dwelling capacity and planning-permission status) together with Homes England Land Hub disposal sites. These show where new homes are planned, often years before they appear in EPC or sale records. Dwelling figures are capacity estimates, not commitments, and a site on a register is an opportunity rather than a guarantee of construction. Licensed under the Open Government Licence v3.0.',
defaultEnabled: true,
},
];
@ -72,7 +72,7 @@ export function isOverlayId(value: string): value is OverlayId {
/**
* Lowest zoom at which each overlay's tiles are generated by the pipeline
* (see the `--min-zoom` defaults in pipeline/transform/*_tiles.py). Used as
* the tile source's `minzoom` so we don't request tiles that don't exist
* the tile source's `minzoom` so we don't request tiles that don't exist:
* this is a data-availability floor, NOT the visibility limit. The limit at
* which overlays (and postcodes) start showing is the shared
* POSTCODE_ZOOM_THRESHOLD.

View file

@ -57,6 +57,14 @@ import {
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,
@ -77,6 +85,7 @@ import {
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';
@ -94,6 +103,8 @@ export interface UrlState {
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;
@ -109,6 +120,7 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
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');
@ -122,6 +134,7 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
ethnicityParams.length === 0 &&
qualificationParams.length === 0 &&
tenureParams.length === 0 &&
councilParams.length === 0 &&
amenityDistanceParams.length === 0 &&
transportDistanceParams.length === 0 &&
amenityCount2KmParams.length === 0 &&
@ -243,6 +256,20 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
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(':');
@ -295,6 +322,7 @@ export function parseUrlState(): UrlState {
overlays: new Set(DEFAULT_OVERLAY_IDS),
basemap: 'standard',
colorOpacity: DEFAULT_COLOR_OPACITY,
listings: DEFAULT_LISTINGS_MODE,
tab: 'area',
};
@ -363,6 +391,13 @@ export function parseUrlState(): UrlState {
}
}
// 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') {
@ -436,7 +471,8 @@ export function stateToParams(
basemap?: BasemapId,
selectedCrimeTypes?: Set<string>,
selectedPostcode?: string,
colorOpacity?: number
colorOpacity?: number,
listingsMode?: ListingsMode
): URLSearchParams {
const params = new URLSearchParams();
@ -500,6 +536,15 @@ export function stateToParams(
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];
@ -577,6 +622,12 @@ export function stateToParams(
}
}
// 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)) {
@ -612,6 +663,7 @@ export function summarizeParams(queryString: string): string {
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');
@ -625,6 +677,7 @@ export function summarizeParams(queryString: string): string {
ethnicityParams.length > 0 ||
qualificationParams.length > 0 ||
tenureParams.length > 0 ||
councilParams.length > 0 ||
amenityDistanceParams.length > 0 ||
transportDistanceParams.length > 0 ||
amenityCount2KmParams.length > 0 ||
@ -640,6 +693,7 @@ export function summarizeParams(queryString: string): string {
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;
@ -667,6 +721,9 @@ export function summarizeParams(queryString: string): string {
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);
}