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

@ -70,7 +70,7 @@ export function useActualListings(
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
abortControllerRef.current?.abort(); abortControllerRef.current?.abort();
}; };
// listings intentionally excluded it's internal state, not an input. // listings intentionally excluded: it's internal state, not an input.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [bounds, filterParam, travelParam, shareCode]); }, [bounds, filterParam, travelParam, shareCode]);

View file

@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import pb from '../lib/pocketbase'; import pb from '../lib/pocketbase';
import { trackEvent } from '../lib/analytics'; import { trackEvent } from '../lib/analytics';
import { resolveAuthError, type AuthErrorAction } from '../lib/auth-errors';
export interface AuthUser { export interface AuthUser {
id: string; id: string;
@ -41,6 +42,7 @@ export function useAuth() {
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [errorAction, setErrorAction] = useState<AuthErrorAction>(null);
// Sync with authStore changes (cross-tab, external updates) // Sync with authStore changes (cross-tab, external updates)
useEffect(() => { useEffect(() => {
@ -58,13 +60,15 @@ export function useAuth() {
async (email: string, password: string) => { async (email: string, password: string) => {
setLoading(true); setLoading(true);
setError(null); setError(null);
setErrorAction(null);
try { try {
const result = await pb.collection('users').authWithPassword(email, password); const result = await pb.collection('users').authWithPassword(email, password);
setUser(recordToUser(result.record)); setUser(recordToUser(result.record));
trackEvent('Login', { method: 'email' }); trackEvent('Login', { method: 'email' });
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : t('auth.loginFailed'); const { message, action } = resolveAuthError(err, 'login', t);
setError(msg); setError(message);
setErrorAction(action);
throw err; throw err;
} finally { } finally {
setLoading(false); setLoading(false);
@ -77,6 +81,10 @@ export function useAuth() {
async (email: string, password: string) => { async (email: string, password: string) => {
setLoading(true); setLoading(true);
setError(null); setError(null);
setErrorAction(null);
try {
// Step 1: create the account. A failure here means nothing was created,
// so surface a friendly, field-aware message (e.g. "email already exists").
try { try {
await pb.collection('users').create({ await pb.collection('users').create({
email, email,
@ -84,14 +92,30 @@ export function useAuth() {
passwordConfirm: password, passwordConfirm: password,
newsletter: true, newsletter: true,
}); });
// Auto-login after registration } catch (err) {
const { message, action } = resolveAuthError(err, 'register', t);
setError(message);
setErrorAction(action);
throw err;
}
// Step 2: the account now EXISTS. If auto-login fails (commonly a 429
// rate limit from the two back-to-back writes) the account is NOT
// orphaned: tell the user it was created and let them log in with the
// same credentials, instead of a misleading "registration failed".
try {
const result = await pb.collection('users').authWithPassword(email, password); const result = await pb.collection('users').authWithPassword(email, password);
setUser(recordToUser(result.record)); setUser(recordToUser(result.record));
trackEvent('Register'); trackEvent('Register');
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : t('auth.registrationFailed'); const status = (err as { status?: number } | null)?.status;
setError(msg); setError(
status === 429
? t('auth.accountCreatedRateLimited')
: t('auth.accountCreatedPleaseLogIn')
);
setErrorAction('switchToLogin');
throw err; throw err;
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -103,6 +127,7 @@ export function useAuth() {
async (provider: string) => { async (provider: string) => {
setLoading(true); setLoading(true);
setError(null); setError(null);
setErrorAction(null);
try { try {
const result = await pb.collection('users').authWithOAuth2({ const result = await pb.collection('users').authWithOAuth2({
provider, provider,
@ -111,8 +136,9 @@ export function useAuth() {
setUser(recordToUser(result.record)); setUser(recordToUser(result.record));
trackEvent('Login', { method: provider }); trackEvent('Login', { method: provider });
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : t('auth.oauthFailed'); const { message, action } = resolveAuthError(err, 'oauth', t);
setError(msg); setError(message);
setErrorAction(action);
throw err; throw err;
} finally { } finally {
setLoading(false); setLoading(false);
@ -131,11 +157,13 @@ export function useAuth() {
async (email: string) => { async (email: string) => {
setLoading(true); setLoading(true);
setError(null); setError(null);
setErrorAction(null);
try { try {
await pb.collection('users').requestPasswordReset(email); await pb.collection('users').requestPasswordReset(email);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : t('auth.passwordResetFailed'); const { message, action } = resolveAuthError(err, 'reset', t);
setError(msg); setError(message);
setErrorAction(action);
throw err; throw err;
} finally { } finally {
setLoading(false); setLoading(false);
@ -167,12 +195,14 @@ export function useAuth() {
const clearError = useCallback(() => { const clearError = useCallback(() => {
setError(null); setError(null);
setErrorAction(null);
}, []); }, []);
return { return {
user, user,
loading, loading,
error, error,
errorAction,
login, login,
register, register,
loginWithOAuth, loginWithOAuth,

View file

@ -45,7 +45,7 @@ describe('useClickedListings', () => {
act(() => result.current.markClicked('https://example.com/b')); act(() => result.current.markClicked('https://example.com/b'));
// Visible immediately — no need to await the network round-trip. // Visible immediately. No need to await the network round-trip.
expect(result.current.clickedUrls.has('https://example.com/b')).toBe(true); expect(result.current.clickedUrls.has('https://example.com/b')).toBe(true);
expect(mocks.create).toHaveBeenCalledWith({ expect(mocks.create).toHaveBeenCalledWith({
user: 'user-1', user: 'user-1',

View file

@ -12,7 +12,7 @@ function currentUserId(): string | null {
/** Tracks which listings the signed-in user has opened. /** Tracks which listings the signed-in user has opened.
* *
* Reads the user from the shared PocketBase authStore so it stays self-contained no need to * Reads the user from the shared PocketBase authStore so it stays self-contained, with no need to
* thread a user id down through the map layer tree. Returns a Set for O(1) membership checks in * thread a user id down through the map layer tree. Returns a Set for O(1) membership checks in
* the map's colour accessor; a fresh Set instance is produced on every change so it can double * the map's colour accessor; a fresh Set instance is produced on every change so it can double
* as a deck.gl `updateTrigger` (identity-compared). * as a deck.gl `updateTrigger` (identity-compared).
@ -27,7 +27,7 @@ export function useClickedListings() {
const userIdRef = useRef(userId); const userIdRef = useRef(userId);
userIdRef.current = userId; userIdRef.current = userId;
// Mirrors the latest committed set so markClicked can decide (synchronously) whether a url is // Mirrors the latest committed set so markClicked can decide (synchronously) whether a url is
// new without depending on the async state updater having run yet. // new, without depending on the async state updater having run yet.
const clickedUrlsRef = useRef(clickedUrls); const clickedUrlsRef = useRef(clickedUrls);
clickedUrlsRef.current = clickedUrls; clickedUrlsRef.current = clickedUrls;
@ -73,7 +73,7 @@ export function useClickedListings() {
pb.collection(CLICKED_LISTINGS_COLLECTION) pb.collection(CLICKED_LISTINGS_COLLECTION)
.create({ user: uid, url }) .create({ user: uid, url })
.catch(() => { .catch(() => {
// Ignore duplicate-index conflicts and transient failures local state already // Ignore duplicate-index conflicts and transient failures; local state already
// reflects the click, and a missed write only means it won't persist to the next visit. // reflects the click, and a missed write only means it won't persist to the next visit.
}); });
}, []); }, []);

View file

@ -0,0 +1,103 @@
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { CollisionFilterExtension } from '@deck.gl/extensions';
import type { PostcodeFeature } from '../types';
import { useDeckLayers } from './useDeckLayers';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock('../lib/analytics', () => ({
trackEvent: vi.fn(),
}));
const SQUARE: [number, number][] = [
[0, 0],
[0, 0.001],
[0.001, 0.001],
[0.001, 0],
[0, 0],
];
function postcode(code: string, count: number, centroid: [number, number]): PostcodeFeature {
return {
type: 'Feature',
geometry: { type: 'Polygon', coordinates: [SQUARE] },
properties: { postcode: code, count, centroid },
};
}
function layerById(layers: readonly unknown[], id: string) {
const layer = layers.find((item) => (item as { id?: string }).id === id);
if (!layer) throw new Error(`Layer ${id} not found`);
return layer as { props: Record<string, unknown> };
}
describe('useDeckLayers postcode label decluttering', () => {
it('attaches the collision filter extension to postcode labels at z16', () => {
const { result } = renderHook(() =>
useDeckLayers({
data: [],
postcodeData: [
postcode('SW1A 1AA', 9, [-0.1, 51.5]),
postcode('SW1A 2AA', 3, [-0.1001, 51.5001]),
],
usePostcodeView: true,
zoom: 16,
pois: [],
actualListings: [],
developments: [],
viewFeature: null,
colorRange: null,
filterRange: null,
features: [],
selectedHexagonId: null,
hoveredHexagonId: null,
onHexagonClick: () => {},
onHexagonHover: () => {},
theme: 'light',
mapDataBeforeId: '',
})
);
const labels = layerById(result.current.layers, 'postcode-labels');
expect(labels.props.collisionEnabled).toBe(true);
expect(labels.props.collisionGroup).toBe('postcode-labels');
expect(labels.props.collisionTestProps).toEqual({ sizeScale: 2 });
expect(
(labels.props.extensions as unknown[]).some((ext) => ext instanceof CollisionFilterExtension)
).toBe(true);
const getPriority = labels.props.getCollisionPriority as (f: PostcodeFeature) => number;
expect(getPriority(postcode('A', 9, [0, 0]))).toBe(9);
expect(getPriority(postcode('B', 5000, [0, 0]))).toBe(1000);
});
it('omits postcode labels below z16', () => {
const { result } = renderHook(() =>
useDeckLayers({
data: [],
postcodeData: [postcode('SW1A 1AA', 9, [-0.1, 51.5])],
usePostcodeView: true,
zoom: 15,
pois: [],
actualListings: [],
developments: [],
viewFeature: null,
colorRange: null,
filterRange: null,
features: [],
selectedHexagonId: null,
hoveredHexagonId: null,
onHexagonClick: () => {},
onHexagonHover: () => {},
theme: 'light',
mapDataBeforeId: '',
})
);
const ids = result.current.layers.map((l: unknown) => (l as { id?: string }).id);
expect(ids).not.toContain('postcode-labels');
});
});

View file

@ -1,6 +1,8 @@
import { useCallback, useRef, useState, useMemo, useEffect } from 'react'; import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
import { H3HexagonLayer } from '@deck.gl/geo-layers'; import { H3HexagonLayer } from '@deck.gl/geo-layers';
import { GeoJsonLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers'; import { GeoJsonLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers';
import { CollisionFilterExtension } from '@deck.gl/extensions';
import type { CollisionFilterExtensionProps } from '@deck.gl/extensions';
import { cellToBoundary, isValidCell } from 'h3-js'; import { cellToBoundary, isValidCell } from 'h3-js';
import type { PickingInfo } from '@deck.gl/core'; import type { PickingInfo } from '@deck.gl/core';
import type { import type {
@ -644,7 +646,7 @@ export function useDeckLayers({
const postcodeLabelsLayer = useMemo( const postcodeLabelsLayer = useMemo(
() => () =>
new TextLayer<PostcodeFeature>({ new TextLayer<PostcodeFeature, CollisionFilterExtensionProps<PostcodeFeature>>({
id: 'postcode-labels', id: 'postcode-labels',
data: labeledPostcodeData, data: labeledPostcodeData,
getPosition: (f) => f.properties.centroid, getPosition: (f) => f.properties.centroid,
@ -662,6 +664,18 @@ export function useDeckLayers({
sizeMaxPixels: 14, sizeMaxPixels: 14,
billboard: false, billboard: false,
pickable: false, pickable: false,
// Declutter overlapping postcode labels in dense areas (z16+). The
// CollisionFilterExtension renders an offscreen collision map and hides
// any label whose (enlarged) footprint overlaps a higher-priority one,
// so adjacent postcodes stop smearing into an unreadable cluster.
extensions: [new CollisionFilterExtension()],
collisionEnabled: true,
collisionGroup: 'postcode-labels',
// Enlarge each label's footprint while testing for collisions so the
// labels that survive keep visible breathing room (acts like padding).
collisionTestProps: { sizeScale: 2 },
// When two labels collide, keep the postcode covering more properties.
getCollisionPriority: (f) => Math.max(-1000, Math.min(1000, f.properties.count)),
}), }),
[labeledPostcodeData, theme] [labeledPostcodeData, theme]
); );

View file

@ -33,8 +33,8 @@ function radiusForDwellings(d: Development): number {
/** /**
* Renders development sites as blue point markers (a soft shadow, a pin, and a * Renders development sites as blue point markers (a soft shadow, a pin, and a
* dwelling-count label at higher zoom). Sparse data the endpoint caps the * dwelling-count label at higher zoom). Sparse data (the endpoint caps the
* viewport at a few thousand points so no clustering is needed, unlike the * viewport at a few thousand points) so no clustering is needed, unlike the
* listings layer. Hover shows a popup; click opens the planning record. * listings layer. Hover shows a popup; click opens the planning record.
*/ */
export function useDevelopmentLayers({ developments, zoom, isDark }: UseDevelopmentLayersProps) { export function useDevelopmentLayers({ developments, zoom, isDark }: UseDevelopmentLayersProps) {

View file

@ -73,7 +73,7 @@ export function useDevelopments(
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
abortControllerRef.current?.abort(); abortControllerRef.current?.abort();
}; };
// developments intentionally excluded it's internal state, not an input. // developments intentionally excluded: it's internal state, not an input.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, bounds, shareCode]); }, [enabled, bounds, shareCode]);

View file

@ -15,7 +15,7 @@ interface FilterCountsResponse {
* Single source of truth for the in-view property count. Returns: * Single source of truth for the in-view property count. Returns:
* - `total`: exact count of in-bounds properties passing the active filters * - `total`: exact count of in-bounds properties passing the active filters
* (or the full in-bounds total when there are none); null until first load. * (or the full in-bounds total when there are none); null until first load.
* - `impacts`: per-filter rejection counts how many in-bounds properties * - `impacts`: per-filter rejection counts, how many in-bounds properties
* each active filter removes. * each active filter removes.
*/ */
export function useFilterCounts( export function useFilterCounts(
@ -28,7 +28,7 @@ export function useFilterCounts(
const [impacts, setImpacts] = useState<Record<string, number>>({}); const [impacts, setImpacts] = useState<Record<string, number>>({});
// null = not loaded yet for the current bounds (hide the counter until the // null = not loaded yet for the current bounds (hide the counter until the
// first response). The number is the exact count of in-bounds properties // first response). The number is the exact count of in-bounds properties
// passing the active filters — and, with no filters, the total in bounds. // passing the active filters (and, with no filters, the total in bounds).
const [total, setTotal] = useState<number | null>(null); const [total, setTotal] = useState<number | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);

View file

@ -58,6 +58,14 @@ import {
getTenureFilterKeyId, getTenureFilterKeyId,
normalizeTenureFilters, normalizeTenureFilters,
} from '../lib/tenure-filter'; } from '../lib/tenure-filter';
import {
COUNCIL_FILTER_NAME,
createCouncilFilterKey,
getCouncilFeatureName,
getCouncilFilterKeyId,
getDefaultCouncilFeatureName,
normalizeCouncilFilters,
} from '../lib/council-filter';
import { import {
POI_FILTER_NAMES, POI_FILTER_NAMES,
createPoiFilterKey, createPoiFilterKey,
@ -87,6 +95,8 @@ interface UseFiltersOptions {
function normalizeFilters(filters: FeatureFilters): FeatureFilters { function normalizeFilters(filters: FeatureFilters): FeatureFilters {
return normalizePoiDistanceFilters( return normalizePoiDistanceFilters(
// Council folds AFTER tenure so a bare "% Social rent" is claimed by tenure.
normalizeCouncilFilters(
normalizeTenureFilters( normalizeTenureFilters(
normalizeQualificationFilters( normalizeQualificationFilters(
normalizeEthnicityFilters( normalizeEthnicityFilters(
@ -98,6 +108,7 @@ function normalizeFilters(filters: FeatureFilters): FeatureFilters {
) )
) )
) )
)
); );
} }
@ -110,6 +121,7 @@ function getBackendFeatureName(name: string): string {
getEthnicityFeatureName(name) ?? getEthnicityFeatureName(name) ??
getQualificationFeatureName(name) ?? getQualificationFeatureName(name) ??
getTenureFeatureName(name) ?? getTenureFeatureName(name) ??
getCouncilFeatureName(name) ??
getPoiDistanceFeatureName(name) ?? getPoiDistanceFeatureName(name) ??
name name
); );
@ -195,8 +207,8 @@ export function useFilters({
const originalNormalizedRef = useRef<FeatureFilters | null>(null); const originalNormalizedRef = useRef<FeatureFilters | null>(null);
if (!initialFiltersRef.current) { if (!initialFiltersRef.current) {
// Clamp an incoming filter set (e.g. from a bookmarked or shared URL) to the // Clamp an incoming filter set (e.g. from a bookmarked or shared URL) to the
// user's remaining budget the cap minus slots already taken by travel-time // user's remaining budget (the cap minus slots already taken by travel-time
// filters so a non-paying user never starts above the cap, which the server // filters) so a non-paying user never starts above the cap, which the server
// would reject (400), blanking the map on first load. Unknown keys are dropped // would reject (400), blanking the map on first load. Unknown keys are dropped
// first WHEN the feature list is already loaded (in-app navigation); on a cold load // first WHEN the feature list is already loaded (in-app navigation); on a cold load
// features arrive after this runs, so the prune effect below re-derives from source. // features arrive after this runs, so the prune effect below re-derives from source.
@ -246,6 +258,9 @@ export function useFilters({
const tenureFilterIdRef = useRef( const tenureFilterIdRef = useRef(
getNextNumericKeyId(initialFiltersRef.current!, getTenureFilterKeyId) getNextNumericKeyId(initialFiltersRef.current!, getTenureFilterKeyId)
); );
const councilFilterIdRef = useRef(
getNextNumericKeyId(initialFiltersRef.current!, getCouncilFilterKeyId)
);
const poiDistanceFilterIdRef = useRef( const poiDistanceFilterIdRef = useRef(
getNextNumericKeyId(initialFiltersRef.current!, getPoiDistanceFilterKeyId) getNextNumericKeyId(initialFiltersRef.current!, getPoiDistanceFilterKeyId)
); );
@ -318,6 +333,7 @@ export function useFilters({
name !== ETHNICITIES_FILTER_NAME && name !== ETHNICITIES_FILTER_NAME &&
name !== QUALIFICATIONS_FILTER_NAME && name !== QUALIFICATIONS_FILTER_NAME &&
name !== TENURE_FILTER_NAME && name !== TENURE_FILTER_NAME &&
name !== COUNCIL_FILTER_NAME &&
!POI_FILTER_NAMES.includes(name as PoiFilterName) && !POI_FILTER_NAMES.includes(name as PoiFilterName) &&
!meta !meta
) { ) {
@ -329,7 +345,7 @@ export function useFilters({
// Re-applying an existing regular feature replaces it (no new key), so it's // Re-applying an existing regular feature replaces it (no new key), so it's
// always allowed; the special/generated filters always add a new key. // always allowed; the special/generated filters always add a new key.
// `lockAdds` (a non-paying user on a shared link) blocks every new key // `lockAdds` (a non-paying user on a shared link) blocks every new key
// regardless of count they can adjust the shared set but not build on it. // regardless of count: they can adjust the shared set but not build on it.
const limit = filterLimitRef.current; const limit = filterLimitRef.current;
if (limit != null || lockAddsRef.current) { if (limit != null || lockAddsRef.current) {
const current = filtersRef.current; const current = filtersRef.current;
@ -341,6 +357,7 @@ export function useFilters({
name === ETHNICITIES_FILTER_NAME || name === ETHNICITIES_FILTER_NAME ||
name === QUALIFICATIONS_FILTER_NAME || name === QUALIFICATIONS_FILTER_NAME ||
name === TENURE_FILTER_NAME || name === TENURE_FILTER_NAME ||
name === COUNCIL_FILTER_NAME ||
POI_FILTER_NAMES.includes(name as PoiFilterName) || POI_FILTER_NAMES.includes(name as PoiFilterName) ||
!(name in current); !(name in current);
const overLimit = const overLimit =
@ -479,6 +496,21 @@ export function useFilters({
], ],
}; };
} }
if (name === COUNCIL_FILTER_NAME) {
const defaultCouncilFeatureName = getDefaultCouncilFeatureName(features);
const defaultCouncilFeature = defaultCouncilFeatureName
? features.find((feature) => feature.name === defaultCouncilFeatureName)
: undefined;
if (!defaultCouncilFeatureName) return prev;
return {
...prev,
[createCouncilFilterKey(defaultCouncilFeatureName, councilFilterIdRef.current++)]: [
defaultCouncilFeature?.histogram?.min ?? defaultCouncilFeature?.min ?? 0,
defaultCouncilFeature?.histogram?.max ?? defaultCouncilFeature?.max ?? 100,
],
};
}
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) { if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
const poiFilterName = name as PoiFilterName; const poiFilterName = name as PoiFilterName;
const defaultPoiFeatureName = getDefaultPoiFilterFeatureName(features, poiFilterName); const defaultPoiFeatureName = getDefaultPoiFilterFeatureName(features, poiFilterName);
@ -647,6 +679,23 @@ export function useFilters({
if (replaced) return normalizeFilters(next); if (replaced) return normalizeFilters(next);
} }
const councilKeyId = getCouncilFilterKeyId(name);
if (councilKeyId != null) {
let replaced = false;
const next: FeatureFilters = {};
for (const [existingName, existingValue] of Object.entries(prev)) {
if (getCouncilFilterKeyId(existingName) === councilKeyId) {
if (!replaced) {
next[name] = value;
replaced = true;
}
continue;
}
next[existingName] = existingValue;
}
if (replaced) return normalizeFilters(next);
}
const electionVoteShareKeyId = getElectionVoteShareFilterKeyId(name); const electionVoteShareKeyId = getElectionVoteShareFilterKeyId(name);
if (electionVoteShareKeyId != null) { if (electionVoteShareKeyId != null) {
let replaced = false; let replaced = false;
@ -719,7 +768,7 @@ export function useFilters({
const handleDragEnd = useCallback(() => { const handleDragEnd = useCallback(() => {
if (pendingDragRef.current) { if (pendingDragRef.current) {
// Click without drag no filter value was changed, just clear preview state. // Click without drag: no filter value was changed, just clear preview state.
pendingDragRef.current = null; pendingDragRef.current = null;
setActiveFeature(null); setActiveFeature(null);
setDragValue(null); setDragValue(null);
@ -737,7 +786,7 @@ export function useFilters({
dragValueRef.current = null; dragValueRef.current = null;
}, []); }, []);
/** End drag without committing to filters caller handles the commit (e.g. travel time). */ /** End drag without committing to filters; caller handles the commit (e.g. travel time). */
const handleDragEndNoCommit = useCallback((): [number, number] | null => { const handleDragEndNoCommit = useCallback((): [number, number] | null => {
if (pendingDragRef.current) { if (pendingDragRef.current) {
pendingDragRef.current = null; pendingDragRef.current = null;
@ -760,7 +809,7 @@ export function useFilters({
// which the server then rejects (400), breaking the map. // which the server then rejects (400), breaking the map.
let next = newFilters; let next = newFilters;
// On a shared link (lockAdds), a non-paying user may adjust the shared filters // On a shared link (lockAdds), a non-paying user may adjust the shared filters
// but not introduce new keys enforce that on bulk sets too, not just in the // but not introduce new keys: enforce that on bulk sets too, not just in the
// per-add path, so the AI/bulk path can't be used to build a fresh set on a // per-add path, so the AI/bulk path can't be used to build a fresh set on a
// shared link. Keep only keys already present; signal if any were dropped. // shared link. Keep only keys already present; signal if any were dropped.
if (lockAddsRef.current) { if (lockAddsRef.current) {

View file

@ -59,7 +59,7 @@ interface UseHexagonSelectionOptions {
travelTimeEntries: TravelTimeEntry[]; travelTimeEntries: TravelTimeEntry[];
areaStatsFields?: string[]; areaStatsFields?: string[];
shareCode?: string; shareCode?: string;
/** First transit destination used to pick the best central_postcode for journey display. */ /** First transit destination, used to pick the best central_postcode for journey display. */
journeyDest?: JourneyDest | null; journeyDest?: JourneyDest | null;
} }
@ -336,7 +336,7 @@ export function useHexagonSelection({
// Lazily fetch a page of individual crimes for the current selection. The // Lazily fetch a page of individual crimes for the current selection. The
// records are an attribute of the area (filter-independent), so this carries // records are an attribute of the area (filter-independent), so this carries
// no filter string only the selection identity and share code. // no filter string, only the selection identity and share code.
const loadCrimeRecords = useCallback( const loadCrimeRecords = useCallback(
(offset: number) => { (offset: number) => {
if (!selectedHexagon) { if (!selectedHexagon) {

View file

@ -84,10 +84,19 @@ function getClusterListings(
clusterId: number, clusterId: number,
limit: number limit: number
): ActualListing[] { ): ActualListing[] {
return index // A background refetch (every pan/zoom rebuilds the index) can leave a picked
.getLeaves(clusterId, limit, 0) // cluster object — or a still-selected cluster in state — holding a clusterId
.map((feature) => feature.properties) // that was minted by a now-superseded index. Supercluster decodes an origin
.sort(compareListingsForDisplay); // id/zoom from the id and throws "No cluster with the specified id." when it
// doesn't resolve against this index. The fresh clusters are already on screen,
// so degrade to an empty result instead of crashing to the error boundary.
let leaves: Supercluster.PointFeature<ActualListing>[];
try {
leaves = index.getLeaves(clusterId, limit, 0);
} catch {
return [];
}
return leaves.map((feature) => feature.properties).sort(compareListingsForDisplay);
} }
function offsetLngLat( function offsetLngLat(
@ -124,7 +133,7 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
useEffect(() => { useEffect(() => {
// Each refetch returns a fresh listings array and rebuilds the cluster index, so a // Each refetch returns a fresh listings array and rebuilds the cluster index, so a
// previously selected cluster's id is no longer valid (getLeaves would throw) // previously selected cluster's id is no longer valid (getLeaves would throw), so
// always collapse the spiderfy. A locked popup, however, holds a self-contained // always collapse the spiderfy. A locked popup, however, holds a self-contained
// snapshot of the listings captured at click time, so preserve it across background // snapshot of the listings captured at click time, so preserve it across background
// refetches instead of dismissing it (mirrors clearUnlockedPopup). // refetches instead of dismissing it (mirrors clearUnlockedPopup).
@ -313,6 +322,10 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
cluster.clusterId, cluster.clusterId,
LISTING_CLUSTER_POPUP_LIMIT LISTING_CLUSTER_POPUP_LIMIT
); );
// Stale clusterId against a rebuilt index: ignore the click rather than
// opening an empty popup with a phantom count. The user's next click lands
// on a cluster from the current index.
if (clusterListings.length === 0) return;
setSelectedCluster(cluster); setSelectedCluster(cluster);
setPopupInfo({ setPopupInfo({
mode: 'cluster', mode: 'cluster',

View file

@ -6,7 +6,7 @@ const RECENT_SEARCHES_STORAGE_KEY = 'perfect-postcode.locationSearch.recent';
const RECENT_SEARCH_LIMIT = 10; const RECENT_SEARCH_LIMIT = 10;
/** Matches a full UK postcode with complete inward code (e.g. "E14 2DG", "SW1A1AA"). /** Matches a full UK postcode with complete inward code (e.g. "E14 2DG", "SW1A1AA").
* Outcodes like "E14" or "SW1A" intentionally do NOT match they go through /api/places instead. */ * Outcodes like "E14" or "SW1A" intentionally do NOT match; they go through /api/places instead. */
const FULL_POSTCODE_RE = /^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$/i; const FULL_POSTCODE_RE = /^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$/i;
function looksLikePostcode(s: string) { function looksLikePostcode(s: string) {
@ -331,7 +331,7 @@ export function useLocationSearch(mode?: string, getViewportCenter?: () => Viewp
return; return;
} }
lastResultsRef.current = combinedResults; lastResultsRef.current = combinedResults;
// Trust the server's unified ranking re-filtering here previously dropped valid // Trust the server's unified ranking: re-filtering here previously dropped valid
// alias and partial-postcode matches. The optimistic pre-fetch path still filters. // alias and partial-postcode matches. The optimistic pre-fetch path still filters.
setResults(combinedResults); setResults(combinedResults);
setOpen(true); setOpen(true);

View file

@ -23,6 +23,7 @@ import { getElectionVoteShareFeatureName } from '../lib/election-filter';
import { getEthnicityFeatureName } from '../lib/ethnicity-filter'; import { getEthnicityFeatureName } from '../lib/ethnicity-filter';
import { getQualificationFeatureName } from '../lib/qualification-filter'; import { getQualificationFeatureName } from '../lib/qualification-filter';
import { getTenureFeatureName } from '../lib/tenure-filter'; import { getTenureFeatureName } from '../lib/tenure-filter';
import { getCouncilFeatureName } from '../lib/council-filter';
import { getPoiDistanceFeatureName } from '../lib/poi-distance-filter'; import { getPoiDistanceFeatureName } from '../lib/poi-distance-filter';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts'; import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts'; import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
@ -32,7 +33,7 @@ import pb from '../lib/pocketbase';
/** When a data request is rejected with the filter cap while we still hold a token, the /** When a data request is rejected with the filter cap while we still hold a token, the
* server didn't accept our session (deleted/banned account, rotated key) and applied the * server didn't accept our session (deleted/banned account, rotated key) and applied the
* lower anonymous cap. Surface that so the caller can re-validate auth which, if the * lower anonymous cap. Surface that so the caller can re-validate auth, which, if the
* session is gone, drops the user, lowers the cap, and re-clamps the filters, recovering * session is gone, drops the user, lowers the cap, and re-clamps the filters, recovering
* instead of looping on an empty map. A logged-in client only ever sends its own cap, * instead of looping on an empty map. A logged-in client only ever sends its own cap,
* so a filter-cap rejection while authed is a reliable desync signal. */ * so a filter-cap rejection while authed is a reliable desync signal. */
@ -72,7 +73,7 @@ interface UseMapDataOptions {
* longer grants any special access (the region lock was removed). */ * longer grants any special access (the region lock was removed). */
shareCode?: string; shareCode?: string;
/** Called when the server rejects a data request with the filter cap while the client /** Called when the server rejects a data request with the filter cap while the client
* still holds a (locally-valid) token i.e. a client/server session desync. */ * still holds a (locally-valid) token, i.e. a client/server session desync. */
onSessionRejected?: () => void; onSessionRejected?: () => void;
} }
@ -149,6 +150,7 @@ export function useMapData({
getEthnicityFeatureName(name) ?? getEthnicityFeatureName(name) ??
getQualificationFeatureName(name) ?? getQualificationFeatureName(name) ??
getTenureFeatureName(name) ?? getTenureFeatureName(name) ??
getCouncilFeatureName(name) ??
getPoiDistanceFeatureName(name) ?? getPoiDistanceFeatureName(name) ??
name, name,
[] []
@ -568,7 +570,7 @@ export function useMapData({
if (vals.length === 0) return null; if (vals.length === 0) return null;
// Typed-array sort uses the engine's optimized numeric sort with no // Typed-array sort uses the engine's optimized numeric sort with no
// per-element comparator call measurably faster than `vals.sort((a,b)=>a-b)` // per-element comparator call, measurably faster than `vals.sort((a,b)=>a-b)`
// for the 5k10k samples a busy viewport produces. // for the 5k10k samples a busy viewport produces.
const sorted = Float64Array.from(vals); const sorted = Float64Array.from(vals);
sorted.sort(); sorted.sort();
@ -733,7 +735,7 @@ export function useMapData({
); );
// Treat the map as loading whenever the rendered hexagons don't match the // Treat the map as loading whenever the rendered hexagons don't match the
// current request — covers the brief window between a slider release and // current request, covering the brief window between a slider release and
// the main fetch effect actually firing setLoading(true). // the main fetch effect actually firing setLoading(true).
const isLoading = loading || (bounds != null && loadedDataKey !== dataRequestKey); const isLoading = loading || (bounds != null && loadedDataKey !== dataRequestKey);

View file

@ -83,7 +83,7 @@ export function usePaneResize(
if (!draggingRef.current) return; if (!draggingRef.current) return;
liveSizeRef.current = computeSize(e); liveSizeRef.current = computeSize(e);
if (targetRef.current) { if (targetRef.current) {
// Batch DOM updates to once per animation frame on mobile, pointermove // Batch DOM updates to once per animation frame: on mobile, pointermove
// can fire multiple times per frame, and each direct style.height write // can fire multiple times per frame, and each direct style.height write
// forces a synchronous reflow that desynchronises MapLibre and deck.gl. // forces a synchronous reflow that desynchronises MapLibre and deck.gl.
if (rafRef.current == null) { if (rafRef.current == null) {

View file

@ -212,7 +212,7 @@ describe('usePoiLayers', () => {
}); });
expect(result.current.popupInfo?.id).toBe(supermarket.id); expect(result.current.popupInfo?.id).toBe(supermarket.id);
// Disabling POIs empties the list the popup must not stay stuck on screen. // Disabling POIs empties the list, so the popup must not stay stuck on screen.
rerender({ pois: [] }); rerender({ pois: [] });
expect(result.current.popupInfo).toBeNull(); expect(result.current.popupInfo).toBeNull();
}); });

View file

@ -69,7 +69,7 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null); const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
// Dismiss a lingering hover/cluster popup once the POIs behind it are gone e.g. // Dismiss a lingering hover/cluster popup once the POIs behind it are gone, e.g.
// after the user clears the POI categories. Without this the card stays stuck on // after the user clears the POI categories. Without this the card stays stuck on
// screen because it is only otherwise cleared on mouse-leave or the close button. // screen because it is only otherwise cleared on mouse-leave or the close button.
useEffect(() => { useEffect(() => {

View file

@ -104,7 +104,7 @@ export function useSavedSearches(userId: string | null) {
if (!mapped.some((s) => !s.screenshotUrl)) return; if (!mapped.some((s) => !s.screenshotUrl)) return;
scheduleNext(); scheduleNext();
} catch { } catch {
// Silent background poll errors don't surface to UI; keep trying. // Silent: background poll errors don't surface to UI; keep trying.
if (isMountedRef.current) scheduleNext(); if (isMountedRef.current) scheduleNext();
} finally { } finally {
pollInFlightRef.current = false; pollInFlightRef.current = false;

View file

@ -3,6 +3,7 @@ import type { FeatureMeta, FeatureFilters } from '../types';
import { stateToParams } from '../lib/url-state'; import { stateToParams } from '../lib/url-state';
import type { OverlayId } from '../lib/overlays'; import type { OverlayId } from '../lib/overlays';
import type { BasemapId } from '../lib/basemaps'; import type { BasemapId } from '../lib/basemaps';
import type { ListingsMode } from '../lib/listings';
import type { TravelTimeEntry } from './useTravelTime'; import type { TravelTimeEntry } from './useTravelTime';
const URL_DEBOUNCE_MS = 300; const URL_DEBOUNCE_MS = 300;
@ -19,7 +20,8 @@ export function useUrlSync(
basemap?: BasemapId, basemap?: BasemapId,
selectedCrimeTypes?: Set<string>, selectedCrimeTypes?: Set<string>,
selectedPostcode?: string, selectedPostcode?: string,
colorOpacity?: number colorOpacity?: number,
listingsMode?: ListingsMode
) { ) {
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -40,7 +42,8 @@ export function useUrlSync(
basemap, basemap,
selectedCrimeTypes, selectedCrimeTypes,
selectedPostcode, selectedPostcode,
colorOpacity colorOpacity,
listingsMode
); );
const search = params.toString(); const search = params.toString();
const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname; const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname;
@ -63,5 +66,6 @@ export function useUrlSync(
selectedCrimeTypes, selectedCrimeTypes,
selectedPostcode, selectedPostcode,
colorOpacity, colorOpacity,
listingsMode,
]); ]);
} }

View file

@ -4,7 +4,7 @@ import { details } from './details';
/** /**
* Feature description translations, keyed by feature name. * Feature description translations, keyed by feature name.
* *
* English descriptions are NOT here the server is the single source of truth * English descriptions are NOT here. The server is the single source of truth
* for English. Fix a typo in features.rs and it propagates automatically. * for English. Fix a typo in features.rs and it propagates automatically.
* *
* Non-English translations are keyed by the stable feature name, so theyre * Non-English translations are keyed by the stable feature name, so theyre
@ -95,6 +95,9 @@ const descriptions: Record<string, Record<string, string>> = {
'% Social rent': '% Social rent':
"Part des ménages locataires auprès d'une collectivité locale ou d'un bailleur social", "Part des ménages locataires auprès d'une collectivité locale ou d'un bailleur social",
'% Private rent': 'Part des ménages locataires dans le privé ou logés à titre gratuit', '% Private rent': 'Part des ménages locataires dans le privé ou logés à titre gratuit',
'% Council housing':
'Part des logements du code postal déjà enregistrés comme logement municipal ou social',
'% Ex-council': 'Part des logements du code postal autrefois municipaux mais qui ne le sont plus',
'% White': 'Part de la population sidentifiant comme blanche', '% White': 'Part de la population sidentifiant comme blanche',
'% South Asian': 'Part de la population sidentifiant comme sud-asiatique', '% South Asian': 'Part de la population sidentifiant comme sud-asiatique',
'% Black': 'Part de la population sidentifiant comme noire', '% Black': 'Part de la population sidentifiant comme noire',
@ -204,6 +207,10 @@ const descriptions: Record<string, Record<string, string>> = {
'% Social rent': '% Social rent':
'Anteil der Haushalte, die von einer Kommune oder Wohnungsbaugesellschaft mieten', 'Anteil der Haushalte, die von einer Kommune oder Wohnungsbaugesellschaft mieten',
'% Private rent': 'Anteil der Haushalte, die privat mieten oder mietfrei wohnen', '% Private rent': 'Anteil der Haushalte, die privat mieten oder mietfrei wohnen',
'% Council housing':
'Anteil der Wohnungen im Postcode, die jemals als Kommunal- oder Sozialwohnung erfasst wurden',
'% Ex-council':
'Anteil der Wohnungen im Postcode, die früher Kommunalwohnungen waren, es aber nicht mehr sind',
'% White': 'Anteil der Personen, die sich als weiß identifizieren', '% White': 'Anteil der Personen, die sich als weiß identifizieren',
'% South Asian': 'Anteil der Personen, die sich als südasiatisch identifizieren', '% South Asian': 'Anteil der Personen, die sich als südasiatisch identifizieren',
'% Black': 'Anteil der Personen, die sich als schwarz identifizieren', '% Black': 'Anteil der Personen, die sich als schwarz identifizieren',
@ -289,6 +296,8 @@ const descriptions: Record<string, Record<string, string>> = {
'% Owner occupied': '拥有自住房(全款或按揭)的家庭比例', '% Owner occupied': '拥有自住房(全款或按揭)的家庭比例',
'% Social rent': '向地方政府或住房协会租房的家庭比例', '% Social rent': '向地方政府或住房协会租房的家庭比例',
'% Private rent': '私人租房或免租居住的家庭比例', '% Private rent': '私人租房或免租居住的家庭比例',
'% Council housing': '邮编内曾被记录为公共住房或社会住房的房屋比例',
'% Ex-council': '邮编内曾是公共住房但现在不再是的房屋比例',
'% White': '白人人口比例', '% White': '白人人口比例',
'% South Asian': '南亚裔人口比例', '% South Asian': '南亚裔人口比例',
'% Black': '黑人人口比例', '% Black': '黑人人口比例',
@ -380,6 +389,9 @@ const descriptions: Record<string, Record<string, string>> = {
'% Owner occupied': 'अपने घर के स्वामी परिवारों का हिस्सा, चाहे पूर्ण रूप से या बंधक के साथ', '% Owner occupied': 'अपने घर के स्वामी परिवारों का हिस्सा, चाहे पूर्ण रूप से या बंधक के साथ',
'% Social rent': 'काउंसिल या हाउसिंग एसोसिएशन से किराए पर रहने वाले परिवारों का हिस्सा', '% Social rent': 'काउंसिल या हाउसिंग एसोसिएशन से किराए पर रहने वाले परिवारों का हिस्सा',
'% Private rent': 'निजी रूप से किराए पर या बिना किराए के रहने वाले परिवारों का हिस्सा', '% Private rent': 'निजी रूप से किराए पर या बिना किराए के रहने वाले परिवारों का हिस्सा',
'% Council housing':
'पोस्टकोड के उन घरों का हिस्सा जिन्हें कभी न कभी काउंसिल या सामाजिक आवास के रूप में दर्ज किया गया',
'% Ex-council': 'पोस्टकोड के उन घरों का हिस्सा जो पहले काउंसिल आवास थे लेकिन अब नहीं हैं',
'% White': 'श्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत', '% White': 'श्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत',
'% South Asian': 'दक्षिण एशियाई के रूप में पहचान करने वाली आबादी का प्रतिशत', '% South Asian': 'दक्षिण एशियाई के रूप में पहचान करने वाली आबादी का प्रतिशत',
'% Black': 'अश्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत', '% Black': 'अश्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत',
@ -481,6 +493,10 @@ const descriptions: Record<string, Record<string, string>> = {
'Azon háztartások aránya, amelyek önkormányzattól vagy lakásszövetkezettől bérelnek', 'Azon háztartások aránya, amelyek önkormányzattól vagy lakásszövetkezettől bérelnek',
'% Private rent': '% Private rent':
'Azon háztartások aránya, amelyek magánbérleményben élnek vagy lakbér nélkül laknak', 'Azon háztartások aránya, amelyek magánbérleményben élnek vagy lakbér nélkül laknak',
'% Council housing':
'Az irányítószámon belüli lakások aránya, amelyeket valaha önkormányzati vagy szociális lakásként rögzítettek',
'% Ex-council':
'Az irányítószámon belüli lakások aránya, amelyek korábban önkormányzati lakások voltak, de már nem azok',
'% White': 'A fehérként azonosított lakosság aránya', '% White': 'A fehérként azonosított lakosság aránya',
'% South Asian': 'A dél-ázsiaiként azonosított lakosság aránya', '% South Asian': 'A dél-ázsiaiként azonosított lakosság aránya',
'% Black': 'A feketeként azonosított lakosság aránya', '% Black': 'A feketeként azonosított lakosság aránya',
@ -514,7 +530,7 @@ const descriptions: Record<string, Record<string, string>> = {
/** /**
* The crime features carry a window suffix ("Burglary (/yr, 7y)"), but the * The crime features carry a window suffix ("Burglary (/yr, 7y)"), but the
* per-locale description/detail/icon maps are keyed by the bare type's legacy * per-locale description/detail/icon maps are keyed by the bare type's legacy
* "(avg/yr)" name and the text is the same for both windows. Map a crime * "(avg/yr)" name, and the text is the same for both windows. Map a crime
* feature name back to that legacy key so the existing translations resolve * feature name back to that legacy key so the existing translations resolve
* without duplicating every entry per window. * without duplicating every entry per window.
*/ */

View file

@ -1,7 +1,7 @@
/** /**
* Feature detail translations (the longer explanatory paragraph in the info card). * Feature detail translations (the longer explanatory paragraph in the info card).
* Same structure as descriptions: keyed by language, then by feature name. * Same structure as descriptions: keyed by language, then by feature name.
* English details come from the server NOT duplicated here. * English details come from the server (not duplicated here).
*/ */
export const details: Record<string, Record<string, string>> = { export const details: Record<string, Record<string, string>> = {
fr: { fr: {
@ -104,17 +104,21 @@ export const details: Record<string, Record<string, string>> = {
'% Apprenticeship': '% Apprenticeship':
"D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est un apprentissage.", "D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est un apprentissage.",
'% A-levels': '% A-levels':
"D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est le A-levels, AS-levels, T-levels, un apprentissage avancé ou équivalent généralement étudié après 16 ans et avant un diplôme universitaire.", "D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est le A-levels, AS-levels, T-levels, un apprentissage avancé ou équivalent, généralement étudié après 16 ans et avant un diplôme universitaire.",
'% Degree or higher': '% Degree or higher':
"D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est de niveau universitaire ou supérieur licence, master ou doctorat, foundation degree, HNC/HND, NVQ 45 ou qualification professionnelle supérieure. Le recensement ne distingue pas les diplômes de premier cycle de ceux de troisième cycle.", "D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est de niveau universitaire ou supérieur : licence, master ou doctorat, foundation degree, HNC/HND, NVQ 45 ou qualification professionnelle supérieure. Le recensement ne distingue pas les diplômes de premier cycle de ceux de troisième cycle.",
'% Other qualifications': '% Other qualifications':
"D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est classé « autre » qualifications professionnelles ou techniques non rattachées à un niveau britannique, et diplômes obtenus hors du Royaume-Uni.", "D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est classé « autre » : qualifications professionnelles ou techniques non rattachées à un niveau britannique, et diplômes obtenus hors du Royaume-Uni.",
'% Owner occupied': '% Owner occupied':
"D'après le recensement de 2021 (TS054). Pourcentage des ménages du quartier (LSOA) qui possèdent leur logement sans crédit, le possèdent avec un emprunt ou un crédit, ou le détiennent en propriété partagée.", "D'après le recensement de 2021 (TS054). Pourcentage des ménages du quartier (LSOA) qui possèdent leur logement sans crédit, le possèdent avec un emprunt ou un crédit, ou le détiennent en propriété partagée.",
'% Social rent': '% Social rent':
"D'après le recensement de 2021 (TS054). Pourcentage des ménages du quartier (LSOA) locataires auprès d'une collectivité ou d'une autorité locale, ou d'un bailleur social ou autre bailleur à vocation sociale.", "D'après le recensement de 2021 (TS054). Pourcentage des ménages du quartier (LSOA) locataires auprès d'une collectivité ou d'une autorité locale, ou d'un bailleur social ou autre bailleur à vocation sociale.",
'% Private rent': '% Private rent':
"D'après le recensement de 2021 (TS054). Pourcentage des ménages du quartier (LSOA) locataires auprès d'un propriétaire privé ou d'une agence de location, plus la faible part logée à titre gratuit.", "D'après le recensement de 2021 (TS054). Pourcentage des ménages du quartier (LSOA) locataires auprès d'un propriétaire privé ou d'une agence de location, plus la faible part logée à titre gratuit.",
'% Council housing':
"Estimé à partir des données d'occupation EPC au sein du code postal : la part de ses logements dont l'historique du certificat de performance énergétique indique que le bien a été à un moment un logement municipal ou social, qu'il soit encore un logement social aujourd'hui ou qu'il ait depuis été vendu (par exemple dans le cadre du Right to Buy). Les logements sans EPC sont comptés comme non municipaux, et un code postal compte peu de logements ; il s'agit donc d'une borne inférieure approximative qui reflète la couverture EPC et n'est pas directement comparable à la part de logements sociaux du recensement.",
'% Ex-council':
"Estimé à partir des données d'occupation EPC au sein du code postal : la part de ses logements autrefois enregistrés comme logement municipal ou social dont le certificat de performance énergétique le plus récent indique une autre forme d'occupation, généralement des logements vendus dans le cadre du Right to Buy. Les logements sans EPC sont comptés comme non municipaux, et un code postal compte peu de logements ; il s'agit donc d'une borne inférieure approximative qui reflète la couverture EPC et n'est pas directement comparable à la part de logements sociaux du recensement.",
'% White': '% White':
"Provient du Census 2021. Pourcentage de la population du quartier (LSOA) s'identifiant comme Blanc (anglais, gallois, écossais, nord-irlandais, britannique, irlandais, Gitan ou Voyageur irlandais, Rom, ou tout autre origine blanche).", "Provient du Census 2021. Pourcentage de la population du quartier (LSOA) s'identifiant comme Blanc (anglais, gallois, écossais, nord-irlandais, britannique, irlandais, Gitan ou Voyageur irlandais, Rom, ou tout autre origine blanche).",
'% South Asian': '% South Asian':
@ -263,17 +267,21 @@ export const details: Record<string, Record<string, string>> = {
'Aus dem Census 2021 (TS067). Der höchste Abschluss entspricht etwa 5 oder mehr GCSEs mit den Noten 94 (A*C), einer mittleren Ausbildung oder Gleichwertigem.', 'Aus dem Census 2021 (TS067). Der höchste Abschluss entspricht etwa 5 oder mehr GCSEs mit den Noten 94 (A*C), einer mittleren Ausbildung oder Gleichwertigem.',
'% Apprenticeship': 'Aus dem Census 2021 (TS067). Der höchste Abschluss ist eine Ausbildung.', '% Apprenticeship': 'Aus dem Census 2021 (TS067). Der höchste Abschluss ist eine Ausbildung.',
'% A-levels': '% A-levels':
'Aus dem Census 2021 (TS067). Der höchste Abschluss sind A-levels, AS-levels, T-levels, eine weiterführende Ausbildung oder Gleichwertiges meist nach dem 16. Lebensjahr und vor einem Studienabschluss erworben.', 'Aus dem Census 2021 (TS067). Der höchste Abschluss sind A-levels, AS-levels, T-levels, eine weiterführende Ausbildung oder Gleichwertiges, meist nach dem 16. Lebensjahr und vor einem Studienabschluss erworben.',
'% Degree or higher': '% Degree or higher':
'Aus dem Census 2021 (TS067). Der höchste Abschluss liegt auf Hochschulniveau oder darüber Bachelor, Master oder Doktortitel, Foundation Degree, HNC/HND, NVQ 45 oder höhere berufliche Qualifikation. Der Census unterscheidet nicht zwischen Bachelor- und Masterabschlüssen.', 'Aus dem Census 2021 (TS067). Der höchste Abschluss liegt auf Hochschulniveau oder darüber: Bachelor, Master oder Doktortitel, Foundation Degree, HNC/HND, NVQ 45 oder höhere berufliche Qualifikation. Der Census unterscheidet nicht zwischen Bachelor- und Masterabschlüssen.',
'% Other qualifications': '% Other qualifications':
'Aus dem Census 2021 (TS067). Der höchste Abschluss wird als „sonstiger“ eingestuft berufliche oder fachliche Qualifikationen, die keinem britischen Niveau zugeordnet sind, sowie außerhalb des Vereinigten Königreichs erworbene Abschlüsse.', 'Aus dem Census 2021 (TS067). Der höchste Abschluss wird als „sonstiger“ eingestuft: berufliche oder fachliche Qualifikationen, die keinem britischen Niveau zugeordnet sind, sowie außerhalb des Vereinigten Königreichs erworbene Abschlüsse.',
'% Owner occupied': '% Owner occupied':
'Aus dem Census 2021 (TS054). Prozentsatz der Haushalte in der Nachbarschaft (LSOA), die ihre Wohnung schuldenfrei besitzen, sie mit Hypothek oder Darlehen besitzen oder sie über gemeinschaftliches Eigentum (Shared Ownership) halten.', 'Aus dem Census 2021 (TS054). Prozentsatz der Haushalte in der Nachbarschaft (LSOA), die ihre Wohnung schuldenfrei besitzen, sie mit Hypothek oder Darlehen besitzen oder sie über gemeinschaftliches Eigentum (Shared Ownership) halten.',
'% Social rent': '% Social rent':
'Aus dem Census 2021 (TS054). Prozentsatz der Haushalte in der Nachbarschaft (LSOA), die von einer Kommune oder Gemeindeverwaltung oder von einer Wohnungsbaugesellschaft oder einem anderen sozialen Vermieter mieten.', 'Aus dem Census 2021 (TS054). Prozentsatz der Haushalte in der Nachbarschaft (LSOA), die von einer Kommune oder Gemeindeverwaltung oder von einer Wohnungsbaugesellschaft oder einem anderen sozialen Vermieter mieten.',
'% Private rent': '% Private rent':
'Aus dem Census 2021 (TS054). Prozentsatz der Haushalte in der Nachbarschaft (LSOA), die von einem privaten Vermieter oder einer Vermittlungsagentur mieten, zuzüglich des kleinen Anteils, der mietfrei wohnt.', 'Aus dem Census 2021 (TS054). Prozentsatz der Haushalte in der Nachbarschaft (LSOA), die von einem privaten Vermieter oder einer Vermittlungsagentur mieten, zuzüglich des kleinen Anteils, der mietfrei wohnt.',
'% Council housing':
'Geschätzt aus EPC-Mietverhältnisdaten innerhalb der Postleitzahl: der Prozentsatz ihrer Wohnungen, deren Energieausweis-Verlauf zeigt, dass die Wohnung irgendwann eine Kommunal- oder Sozialwohnung war, unabhängig davon, ob sie heute noch eine Sozialwohnung ist oder inzwischen verkauft wurde (zum Beispiel über Right to Buy). Wohnungen ohne EPC werden als nicht kommunal gezählt, und eine Postleitzahl umfasst nur wenige Wohnungen, daher ist dies eine grobe Untergrenze, die die EPC-Abdeckung widerspiegelt und nicht direkt mit dem Sozialmietanteil aus dem Census vergleichbar ist.',
'% Ex-council':
'Geschätzt aus EPC-Mietverhältnisdaten innerhalb der Postleitzahl: der Prozentsatz ihrer Wohnungen, die einst als Kommunal- oder Sozialwohnung erfasst wurden und deren neuester Energieausweis eine andere Wohnform ausweist, typischerweise über Right to Buy verkaufte Wohnungen. Wohnungen ohne EPC werden als nicht kommunal gezählt, und eine Postleitzahl umfasst nur wenige Wohnungen, daher ist dies eine grobe Untergrenze, die die EPC-Abdeckung widerspiegelt und nicht direkt mit dem Sozialmietanteil aus dem Census vergleichbar ist.',
'% White': '% White':
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Nachbarschaft (LSOA), die sich als Weiß identifiziert (Englisch, Walisisch, Schottisch, Nordirisch, Britisch, Irisch, Sinti und Roma, Roma oder sonstiger weißer Hintergrund).', 'Aus dem Census 2021. Prozentsatz der Bevölkerung der Nachbarschaft (LSOA), die sich als Weiß identifiziert (Englisch, Walisisch, Schottisch, Nordirisch, Britisch, Irisch, Sinti und Roma, Roma oder sonstiger weißer Hintergrund).',
'% South Asian': '% South Asian':
@ -420,17 +428,21 @@ export const details: Record<string, Record<string, string>> = {
'数据来自 2021 年 CensusTS067。最高学历约为 5 门或以上成绩为 94A*C的 GCSE、中级学徒制或同等资格。', '数据来自 2021 年 CensusTS067。最高学历约为 5 门或以上成绩为 94A*C的 GCSE、中级学徒制或同等资格。',
'% Apprenticeship': '数据来自 2021 年 CensusTS067。最高学历为学徒制。', '% Apprenticeship': '数据来自 2021 年 CensusTS067。最高学历为学徒制。',
'% A-levels': '% A-levels':
'数据来自 2021 年 CensusTS067。最高学历为 A-levels、AS-levels、T-levels、高级学徒制或同等资格——通常在 16 岁之后、获得学位之前修读。', '数据来自 2021 年 CensusTS067。最高学历为 A-levels、AS-levels、T-levels、高级学徒制或同等资格通常在 16 岁之后、获得学位之前修读。',
'% Degree or higher': '% Degree or higher':
'数据来自 2021 年 CensusTS067。最高学历为学位及以上——学士、硕士或博士、预科学位、HNC/HND、NVQ 45或更高级别的职业资格。该 Census 不区分本科与研究生学位。', '数据来自 2021 年 CensusTS067。最高学历为学位及以上学士、硕士或博士、预科学位、HNC/HND、NVQ 45或更高级别的职业资格。该 Census 不区分本科与研究生学位。',
'% Other qualifications': '% Other qualifications':
'数据来自 2021 年 CensusTS067。最高学历被归为“其他”——未对应到英国级别的职业或专业资格,以及在英国境外取得的学历。', '数据来自 2021 年 CensusTS067。最高学历被归为“其他”未对应到英国级别的职业或专业资格,以及在英国境外取得的学历。',
'% Owner occupied': '% Owner occupied':
'数据来自 2021 年 CensusTS054。本地社区LSOA中全款拥有自住房、以按揭或贷款拥有自住房或通过共享产权持有住房的家庭百分比。', '数据来自 2021 年 CensusTS054。本地社区LSOA中全款拥有自住房、以按揭或贷款拥有自住房或通过共享产权持有住房的家庭百分比。',
'% Social rent': '% Social rent':
'数据来自 2021 年 CensusTS054。本地社区LSOA中向地方议会或地方政府或向住房协会及其他社会房东租房的家庭百分比。', '数据来自 2021 年 CensusTS054。本地社区LSOA中向地方议会或地方政府或向住房协会及其他社会房东租房的家庭百分比。',
'% Private rent': '% Private rent':
'数据来自 2021 年 CensusTS054。本地社区LSOA中向私人房东或租赁中介租房的家庭百分比外加少量免租居住的家庭。', '数据来自 2021 年 CensusTS054。本地社区LSOA中向私人房东或租赁中介租房的家庭百分比外加少量免租居住的家庭。',
'% Council housing':
'根据邮编内的 EPC 产权记录估算该邮编中其能源性能证书EPC历史显示该房屋曾经是公共住房或社会住房的房屋百分比无论它今天是否仍是社会住房还是此后已被售出例如通过 Right to Buy。没有 EPC 的房屋被视为非公共住房,且一个邮编包含的房屋很少,因此这是一个粗略的下限,反映的是 EPC 覆盖范围,无法与 Census 的社会租赁占比直接比较。',
'% Ex-council':
'根据邮编内的 EPC 产权记录估算该邮编中曾被记录为公共住房或社会住房、但其最新能源性能证书EPC显示为不同产权的房屋百分比通常是通过 Right to Buy 出售的房屋。没有 EPC 的房屋被视为非公共住房,且一个邮编包含的房屋很少,因此这是一个粗略的下限,反映的是 EPC 覆盖范围,无法与 Census 的社会租赁占比直接比较。',
'% White': '% White':
'来自2021年Census。本地社区LSOA人口中认同为白人英格兰人、威尔士人、苏格兰人、北爱尔兰人、英国人、爱尔兰人、吉普赛人或爱尔兰旅行者、罗姆人或其他白人背景的百分比。', '来自2021年Census。本地社区LSOA人口中认同为白人英格兰人、威尔士人、苏格兰人、北爱尔兰人、英国人、爱尔兰人、吉普赛人或爱尔兰旅行者、罗姆人或其他白人背景的百分比。',
'% South Asian': '% South Asian':
@ -574,17 +586,21 @@ export const details: Record<string, Record<string, string>> = {
'2021 की Census (TS067) से। सर्वोच्च योग्यता लगभग 5 या अधिक GCSE है जिनके ग्रेड 94 (A*C) हैं, एक इंटरमीडिएट अप्रेंटिसशिप, या समकक्ष।', '2021 की Census (TS067) से। सर्वोच्च योग्यता लगभग 5 या अधिक GCSE है जिनके ग्रेड 94 (A*C) हैं, एक इंटरमीडिएट अप्रेंटिसशिप, या समकक्ष।',
'% Apprenticeship': '2021 की Census (TS067) से। सर्वोच्च योग्यता एक अप्रेंटिसशिप है।', '% Apprenticeship': '2021 की Census (TS067) से। सर्वोच्च योग्यता एक अप्रेंटिसशिप है।',
'% A-levels': '% A-levels':
'2021 की Census (TS067) से। सर्वोच्च योग्यता A-levels, AS-levels, T-levels, एक एडवांस्ड अप्रेंटिसशिप, या समकक्ष है आमतौर पर 16 वर्ष की उम्र के बाद और डिग्री से पहले पढ़ी जाती है।', '2021 की Census (TS067) से। सर्वोच्च योग्यता A-levels, AS-levels, T-levels, एक एडवांस्ड अप्रेंटिसशिप, या समकक्ष है, आमतौर पर 16 वर्ष की उम्र के बाद और डिग्री से पहले पढ़ी जाती है।',
'% Degree or higher': '% Degree or higher':
'2021 की Census (TS067) से। सर्वोच्च योग्यता डिग्री स्तर या उससे ऊपर है बैचलर, मास्टर या PhD, फाउंडेशन डिग्री, HNC/HND, NVQ 45, या उच्च व्यावसायिक योग्यता। Census स्नातक और स्नातकोत्तर डिग्री में अंतर नहीं करती।', '2021 की Census (TS067) से। सर्वोच्च योग्यता डिग्री स्तर या उससे ऊपर है: बैचलर, मास्टर या PhD, फाउंडेशन डिग्री, HNC/HND, NVQ 45, या उच्च व्यावसायिक योग्यता। Census स्नातक और स्नातकोत्तर डिग्री में अंतर नहीं करती।',
'% Other qualifications': '% Other qualifications':
"2021 की Census (TS067) से। सर्वोच्च योग्यता 'अन्य' श्रेणी में आती है ऐसी व्यावसायिक या पेशेवर योग्यताएं जो किसी UK स्तर से मेल नहीं खातीं, और UK के बाहर प्राप्त योग्यताएं।", "2021 की Census (TS067) से। सर्वोच्च योग्यता 'अन्य' श्रेणी में आती है: ऐसी व्यावसायिक या पेशेवर योग्यताएं जो किसी UK स्तर से मेल नहीं खातीं, और UK के बाहर प्राप्त योग्यताएं।",
'% Owner occupied': '% Owner occupied':
'2021 की Census (TS054) से। पड़ोस (LSOA) में उन परिवारों का प्रतिशत जो अपना घर पूर्ण रूप से अपने पास रखते हैं, बंधक या ऋण के साथ रखते हैं, या साझा स्वामित्व के माध्यम से रखते हैं।', '2021 की Census (TS054) से। पड़ोस (LSOA) में उन परिवारों का प्रतिशत जो अपना घर पूर्ण रूप से अपने पास रखते हैं, बंधक या ऋण के साथ रखते हैं, या साझा स्वामित्व के माध्यम से रखते हैं।',
'% Social rent': '% Social rent':
'2021 की Census (TS054) से। पड़ोस (LSOA) में उन परिवारों का प्रतिशत जो स्थानीय काउंसिल या स्थानीय प्राधिकरण से, या किसी हाउसिंग एसोसिएशन या अन्य सामाजिक मकान-मालिक से किराए पर रहते हैं।', '2021 की Census (TS054) से। पड़ोस (LSOA) में उन परिवारों का प्रतिशत जो स्थानीय काउंसिल या स्थानीय प्राधिकरण से, या किसी हाउसिंग एसोसिएशन या अन्य सामाजिक मकान-मालिक से किराए पर रहते हैं।',
'% Private rent': '% Private rent':
'2021 की Census (TS054) से। पड़ोस (LSOA) में उन परिवारों का प्रतिशत जो किसी निजी मकान-मालिक या किराया एजेंसी से किराए पर रहते हैं, साथ ही बिना किराए के रहने वाला छोटा हिस्सा।', '2021 की Census (TS054) से। पड़ोस (LSOA) में उन परिवारों का प्रतिशत जो किसी निजी मकान-मालिक या किराया एजेंसी से किराए पर रहते हैं, साथ ही बिना किराए के रहने वाला छोटा हिस्सा।',
'% Council housing':
'पोस्टकोड के भीतर EPC स्वामित्व रिकॉर्ड से अनुमानित: उस पोस्टकोड के उन घरों का प्रतिशत जिनके ऊर्जा प्रदर्शन प्रमाणपत्र (EPC) के इतिहास से पता चलता है कि वह घर किसी समय काउंसिल या सामाजिक आवास था, चाहे वह आज भी सामाजिक आवास हो या तब से बेचा जा चुका हो (उदाहरण के लिए Right to Buy के तहत)। जिन घरों का कोई EPC नहीं है उन्हें गैर-काउंसिल माना जाता है, और एक पोस्टकोड में कुछ ही घर होते हैं, इसलिए यह एक मोटी निचली सीमा है जो EPC कवरेज को दर्शाती है और Census के सामाजिक-किराया हिस्से से सीधे तुलनीय नहीं है।',
'% Ex-council':
'पोस्टकोड के भीतर EPC स्वामित्व रिकॉर्ड से अनुमानित: उस पोस्टकोड के उन घरों का प्रतिशत जिन्हें कभी काउंसिल या सामाजिक आवास के रूप में दर्ज किया गया था, लेकिन जिनका सबसे हालिया ऊर्जा प्रदर्शन प्रमाणपत्र (EPC) एक अलग स्वामित्व दिखाता है, आमतौर पर Right to Buy के तहत बेचे गए घर। जिन घरों का कोई EPC नहीं है उन्हें गैर-काउंसिल माना जाता है, और एक पोस्टकोड में कुछ ही घर होते हैं, इसलिए यह एक मोटी निचली सीमा है जो EPC कवरेज को दर्शाती है और Census के सामाजिक-किराया हिस्से से सीधे तुलनीय नहीं है।',
'% White': '% White':
'Census 2021 से. स्थानीय पड़ोस (LSOA) की आबादी का प्रतिशत जो खुद को श्वेत (अंग्रेज़, वेल्श, स्कॉटिश, उत्तरी आयरिश, ब्रिटिश, आयरिश, जिप्सी या आयरिश ट्रैवलर, रोमा या किसी अन्य श्वेत पृष्ठभूमि) के रूप में पहचानता है.', 'Census 2021 से. स्थानीय पड़ोस (LSOA) की आबादी का प्रतिशत जो खुद को श्वेत (अंग्रेज़, वेल्श, स्कॉटिश, उत्तरी आयरिश, ब्रिटिश, आयरिश, जिप्सी या आयरिश ट्रैवलर, रोमा या किसी अन्य श्वेत पृष्ठभूमि) के रूप में पहचानता है.',
'% South Asian': '% South Asian':
@ -733,17 +749,21 @@ export const details: Record<string, Record<string, string>> = {
'A 2021-es Census (TS067) alapján. A legmagasabb végzettség körülbelül 5 vagy több GCSE 94 (A*C) osztályzattal, középszintű tanoncképzés vagy ezzel egyenértékű.', 'A 2021-es Census (TS067) alapján. A legmagasabb végzettség körülbelül 5 vagy több GCSE 94 (A*C) osztályzattal, középszintű tanoncképzés vagy ezzel egyenértékű.',
'% Apprenticeship': 'A 2021-es Census (TS067) alapján. A legmagasabb végzettség tanoncképzés.', '% Apprenticeship': 'A 2021-es Census (TS067) alapján. A legmagasabb végzettség tanoncképzés.',
'% A-levels': '% A-levels':
'A 2021-es Census (TS067) alapján. A legmagasabb végzettség A-levels, AS-levels, T-levels, emelt szintű tanoncképzés vagy ezzel egyenértékű jellemzően 16 éves kor után és a diploma előtt szerezhető meg.', 'A 2021-es Census (TS067) alapján. A legmagasabb végzettség A-levels, AS-levels, T-levels, emelt szintű tanoncképzés vagy ezzel egyenértékű, jellemzően 16 éves kor után és a diploma előtt szerezhető meg.',
'% Degree or higher': '% Degree or higher':
'A 2021-es Census (TS067) alapján. A legmagasabb végzettség diploma szintű vagy afölötti alapdiploma, mesterdiploma vagy PhD, foundation degree, HNC/HND, NVQ 45, vagy magasabb szakmai képesítés. A Census nem különbözteti meg az alap- és mesterszintű diplomákat.', 'A 2021-es Census (TS067) alapján. A legmagasabb végzettség diploma szintű vagy afölötti: alapdiploma, mesterdiploma vagy PhD, foundation degree, HNC/HND, NVQ 45, vagy magasabb szakmai képesítés. A Census nem különbözteti meg az alap- és mesterszintű diplomákat.',
'% Other qualifications': '% Other qualifications':
'A 2021-es Census (TS067) alapján. A legmagasabb végzettség „egyéb“ besorolású olyan szakmai vagy hivatásbeli képesítések, amelyek nem feleltethetők meg egy brit szintnek, valamint az Egyesült Királyságon kívül szerzett képesítések.', 'A 2021-es Census (TS067) alapján. A legmagasabb végzettség „egyéb“ besorolású: olyan szakmai vagy hivatásbeli képesítések, amelyek nem feleltethetők meg egy brit szintnek, valamint az Egyesült Királyságon kívül szerzett képesítések.',
'% Owner occupied': '% Owner occupied':
'A 2021-es Census (TS054) alapján. A környéken (LSOA) azon háztartások százaléka, amelyek tehermentesen birtokolják lakásukat, jelzáloggal vagy kölcsönnel birtokolják, vagy résztulajdon (shared ownership) keretében tartják.', 'A 2021-es Census (TS054) alapján. A környéken (LSOA) azon háztartások százaléka, amelyek tehermentesen birtokolják lakásukat, jelzáloggal vagy kölcsönnel birtokolják, vagy résztulajdon (shared ownership) keretében tartják.',
'% Social rent': '% Social rent':
'A 2021-es Census (TS054) alapján. A környéken (LSOA) azon háztartások százaléka, amelyek helyi önkormányzattól vagy hatóságtól, illetve lakásszövetkezettől vagy más szociális bérbeadótól bérelnek.', 'A 2021-es Census (TS054) alapján. A környéken (LSOA) azon háztartások százaléka, amelyek helyi önkormányzattól vagy hatóságtól, illetve lakásszövetkezettől vagy más szociális bérbeadótól bérelnek.',
'% Private rent': '% Private rent':
'A 2021-es Census (TS054) alapján. A környéken (LSOA) azon háztartások százaléka, amelyek magán bérbeadótól vagy ingatlanügynökségtől bérelnek, kiegészülve a lakbér nélkül lakók kis arányával.', 'A 2021-es Census (TS054) alapján. A környéken (LSOA) azon háztartások százaléka, amelyek magán bérbeadótól vagy ingatlanügynökségtől bérelnek, kiegészülve a lakbér nélkül lakók kis arányával.',
'% Council housing':
'Az irányítószámon belüli EPC-bérlettörténeti adatokból becsülve: azon lakásainak százaléka, amelyek energiatanúsítványának (EPC) előzményei szerint a lakás valamikor önkormányzati vagy szociális lakás volt, függetlenül attól, hogy ma is szociális lakás-e vagy azóta eladták (például a Right to Buy keretében). Az EPC nélküli lakások nem önkormányzatinak számítanak, és egy irányítószám kevés lakást foglal magában, így ez egy durva alsó becslés, amely az EPC-lefedettséget tükrözi, és nem hasonlítható közvetlenül a Census szociális bérlakás-arányához.',
'% Ex-council':
'Az irányítószámon belüli EPC-bérlettörténeti adatokból becsülve: azon lakásainak százaléka, amelyeket egykor önkormányzati vagy szociális lakásként rögzítettek, de amelyek legutóbbi energiatanúsítványa (EPC) más lakhatási formát mutat, jellemzően a Right to Buy keretében eladott lakások. Az EPC nélküli lakások nem önkormányzatinak számítanak, és egy irányítószám kevés lakást foglal magában, így ez egy durva alsó becslés, amely az EPC-lefedettséget tükrözi, és nem hasonlítható közvetlenül a Census szociális bérlakás-arányához.',
'% White': '% White':
'A 2021-es Census alapján. A helyi környéken (LSOA) fehérként (angol, walesi, skót, észak-ír, brit, ír, cigány vagy ír vándor, roma, vagy bármely más fehér háttér) azonosított népesség százaléka.', 'A 2021-es Census alapján. A helyi környéken (LSOA) fehérként (angol, walesi, skót, észak-ír, brit, ír, cigány vagy ír vándor, roma, vagy bármely más fehér háttér) azonosított népesség százaléka.',
'% South Asian': '% South Asian':

View file

@ -0,0 +1,37 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, vi } from 'vitest';
describe('language init from ?lang param', () => {
beforeEach(() => {
vi.resetModules();
localStorage.clear();
window.history.replaceState({}, '', '/');
});
it('applies, persists, and strips a ?lang param', async () => {
window.history.replaceState({}, '', '/?lang=de&foo=1');
const mod = await import('./index');
expect(mod.INITIAL_LANGUAGE).toBe('de');
expect(localStorage.getItem('language')).toBe('de');
expect(window.location.search).toBe('?foo=1');
});
it('lets a later stored choice win once ?lang has been stripped', async () => {
window.history.replaceState({}, '', '/?lang=de');
let mod = await import('./index');
expect(mod.INITIAL_LANGUAGE).toBe('de');
// user switches in-app -> dropdown writes localStorage
localStorage.setItem('language', 'en');
// reload (param was stripped, so URL has no lang)
vi.resetModules();
window.history.replaceState({}, '', '/');
mod = await import('./index');
expect(mod.INITIAL_LANGUAGE).toBe('en');
});
it('falls back to stored language when no ?lang is present', async () => {
localStorage.setItem('language', 'fr');
const mod = await import('./index');
expect(mod.INITIAL_LANGUAGE).toBe('fr');
});
});

View file

@ -44,15 +44,49 @@ function getUrlLanguage(): LanguageCode | null {
} }
} }
function setStoredLanguage(code: LanguageCode): void {
try {
localStorage.setItem('language', code);
} catch {
// Storage unavailable (private mode / disabled cookies); ignore.
}
}
// A ?lang= param is a one-time initializer (shared links, screenshot/OG image
// URLs). Strip it after consuming so a stale value can't override a later
// in-app language switch on reload.
function clearUrlLanguageParam(): void {
try {
if (typeof window === 'undefined' || !window.history?.replaceState) return;
const url = new URL(window.location.href);
if (!url.searchParams.has('lang')) return;
url.searchParams.delete('lang');
const search = url.searchParams.toString();
window.history.replaceState(
window.history.state,
'',
`${url.pathname}${search ? `?${search}` : ''}${url.hash}`
);
} catch {
// Malformed URL or unsupported history API; ignore.
}
}
function getBrowserLanguages(): readonly string[] { function getBrowserLanguages(): readonly string[] {
if (typeof navigator === 'undefined') return []; if (typeof navigator === 'undefined') return [];
return navigator.languages?.length ? navigator.languages : [navigator.language]; return navigator.languages?.length ? navigator.languages : [navigator.language];
} }
function detectLanguage(): LanguageCode { function detectLanguage(): LanguageCode {
// 1. Explicit URL language, used by generated screenshot/OG image URLs. // 1. Explicit URL language (shared links, screenshot/OG image URLs).
// Consume once: persist as the user's choice and strip from the URL so a
// stale ?lang can't override a later in-app language switch on reload.
const urlLanguage = getUrlLanguage(); const urlLanguage = getUrlLanguage();
if (urlLanguage) return urlLanguage; if (urlLanguage) {
setStoredLanguage(urlLanguage);
clearUrlLanguageParam();
return urlLanguage;
}
// 2. Explicit user choice (persisted from the language dropdown) // 2. Explicit user choice (persisted from the language dropdown)
const stored = getStoredLanguage(); const stored = getStoredLanguage();

View file

@ -8,6 +8,7 @@ import { createElectionVoteShareFilterKey } from './election-filter';
import { createEthnicityFilterKey } from './ethnicity-filter'; import { createEthnicityFilterKey } from './ethnicity-filter';
import { createQualificationFilterKey } from './qualification-filter'; import { createQualificationFilterKey } from './qualification-filter';
import { createTenureFilterKey } from './tenure-filter'; import { createTenureFilterKey } from './tenure-filter';
import { createCouncilFilterKey } from './council-filter';
import { import {
POI_COUNT_2KM_FILTER_NAME, POI_COUNT_2KM_FILTER_NAME,
TRANSPORT_DISTANCE_FILTER_NAME, TRANSPORT_DISTANCE_FILTER_NAME,
@ -180,6 +181,23 @@ describe('api utilities', () => {
).toBe('% Owner occupied:20:60;;% Private rent:0:25'); ).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', () => { it('serializes amenity distance filters using their selected backend feature', () => {
const features: FeatureMeta[] = [ const features: FeatureMeta[] = [
{ name: 'Distance to nearest amenity (Park) (km)', type: 'numeric', min: 0, max: 2 }, { 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 { getEthnicityFeatureName } from './ethnicity-filter';
import { getQualificationFeatureName } from './qualification-filter'; import { getQualificationFeatureName } from './qualification-filter';
import { getTenureFeatureName } from './tenure-filter'; import { getTenureFeatureName } from './tenure-filter';
import { getCouncilFeatureName } from './council-filter';
import { getPoiDistanceFeatureName } from './poi-distance-filter'; import { getPoiDistanceFeatureName } from './poi-distance-filter';
const SCREENSHOT_LANGUAGES = new Set(['en', 'fr', 'de', 'zh', 'hi', 'hu']); const SCREENSHOT_LANGUAGES = new Set(['en', 'fr', 'de', 'zh', 'hi', 'hu']);
@ -159,6 +160,7 @@ export function buildFilterString(
getEthnicityFeatureName(name) ?? getEthnicityFeatureName(name) ??
getQualificationFeatureName(name) ?? getQualificationFeatureName(name) ??
getTenureFeatureName(name) ?? getTenureFeatureName(name) ??
getCouncilFeatureName(name) ??
getPoiDistanceFeatureName(name) ?? getPoiDistanceFeatureName(name) ??
name; name;
const prev = merged.get(backendName); 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 * At Tube/DLR interchanges (Bank, Canning Town, Stratford, ) NaPTAN merges the
* Underground and DLR nodes into one POI whose category resolves to "Tube * 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 * DLR Station" would otherwise sit under a "Tube station" label. Dropping the
* mode word removes both that contradiction and the everyday redundancy of * mode word removes both that contradiction and the everyday redundancy of
* "Beckton DLR Station" shown above a "DLR station" subtitle. * "Beckton DLR Station" shown above a "DLR station" subtitle.

View file

@ -37,7 +37,7 @@ export const OVERLAYS: OverlayDefinition[] = [
label: 'Trees & woodland', label: 'Trees & woodland',
description: 'Tree canopy and woodland polygons', description: 'Tree canopy and woodland polygons',
detail: 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, defaultEnabled: true,
}, },
{ {
@ -45,7 +45,7 @@ export const OVERLAYS: OverlayDefinition[] = [
label: 'Property borders', label: 'Property borders',
description: 'Individual freehold property/land-parcel boundaries', description: 'Individual freehold property/land-parcel boundaries',
detail: 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, defaultEnabled: true,
}, },
{ {
@ -53,7 +53,7 @@ export const OVERLAYS: OverlayDefinition[] = [
label: 'New developments', label: 'New developments',
description: 'Planned new-home sites (brownfield register + Homes England)', description: 'Planned new-home sites (brownfield register + Homes England)',
detail: 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, 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 * 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 * (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 * this is a data-availability floor, NOT the visibility limit. The limit at
* which overlays (and postcodes) start showing is the shared * which overlays (and postcodes) start showing is the shared
* POSTCODE_ZOOM_THRESHOLD. * POSTCODE_ZOOM_THRESHOLD.

View file

@ -57,6 +57,14 @@ import {
isTenureFeatureName, isTenureFeatureName,
isTenureFilterName, isTenureFilterName,
} from './tenure-filter'; } from './tenure-filter';
import {
COUNCIL_FILTER_NAME,
createCouncilFilterKey,
getCouncilFeatureName,
getCouncilWindow,
isCouncilFeatureName,
isCouncilFilterName,
} from './council-filter';
import { import {
POI_DISTANCE_FILTER_NAME, POI_DISTANCE_FILTER_NAME,
TRANSPORT_DISTANCE_FILTER_NAME, TRANSPORT_DISTANCE_FILTER_NAME,
@ -77,6 +85,7 @@ import {
colorOpacityToPercent, colorOpacityToPercent,
normalizeColorOpacity, normalizeColorOpacity,
} from './color-opacity'; } from './color-opacity';
import { DEFAULT_LISTINGS_MODE, LISTINGS_MODES, type ListingsMode } from './listings';
const POI_NONE_PARAM = '__none'; const POI_NONE_PARAM = '__none';
const CRIME_TYPES_NONE_PARAM = '__none'; const CRIME_TYPES_NONE_PARAM = '__none';
@ -94,6 +103,8 @@ export interface UrlState {
crimeTypes?: Set<string>; crimeTypes?: Set<string>;
basemap: BasemapId; basemap: BasemapId;
colorOpacity: number; colorOpacity: number;
/** Which slice of the live for-sale listings to render (new-build only, etc.). */
listings: ListingsMode;
tab: 'properties' | 'area'; tab: 'properties' | 'area';
travelTime?: TravelTimeInitial; travelTime?: TravelTimeInitial;
postcode?: string; postcode?: string;
@ -109,6 +120,7 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
const ethnicityParams = params.getAll('ethnicity'); const ethnicityParams = params.getAll('ethnicity');
const qualificationParams = params.getAll('qualification'); const qualificationParams = params.getAll('qualification');
const tenureParams = params.getAll('tenure'); const tenureParams = params.getAll('tenure');
const councilParams = params.getAll('council');
const amenityDistanceParams = params.getAll('amenityDistance'); const amenityDistanceParams = params.getAll('amenityDistance');
const transportDistanceParams = params.getAll('transportDistance'); const transportDistanceParams = params.getAll('transportDistance');
const amenityCount2KmParams = params.getAll('amenityCount2km'); const amenityCount2KmParams = params.getAll('amenityCount2km');
@ -122,6 +134,7 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
ethnicityParams.length === 0 && ethnicityParams.length === 0 &&
qualificationParams.length === 0 && qualificationParams.length === 0 &&
tenureParams.length === 0 && tenureParams.length === 0 &&
councilParams.length === 0 &&
amenityDistanceParams.length === 0 && amenityDistanceParams.length === 0 &&
transportDistanceParams.length === 0 && transportDistanceParams.length === 0 &&
amenityCount2KmParams.length === 0 && amenityCount2KmParams.length === 0 &&
@ -243,6 +256,20 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
filters[createTenureFilterKey(featureName, index)] = [min, max]; 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) => { const parsePoiParams = (entries: string[], filterName: PoiFilterName, startIndex: number) => {
entries.forEach((entry, index) => { entries.forEach((entry, index) => {
const parts = entry.split(':'); const parts = entry.split(':');
@ -295,6 +322,7 @@ export function parseUrlState(): UrlState {
overlays: new Set(DEFAULT_OVERLAY_IDS), overlays: new Set(DEFAULT_OVERLAY_IDS),
basemap: 'standard', basemap: 'standard',
colorOpacity: DEFAULT_COLOR_OPACITY, colorOpacity: DEFAULT_COLOR_OPACITY,
listings: DEFAULT_LISTINGS_MODE,
tab: 'area', 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 // Tab: full name
const tab = params.get('tab'); const tab = params.get('tab');
if (tab === 'properties' || tab === 'area') { if (tab === 'properties' || tab === 'area') {
@ -436,7 +471,8 @@ export function stateToParams(
basemap?: BasemapId, basemap?: BasemapId,
selectedCrimeTypes?: Set<string>, selectedCrimeTypes?: Set<string>,
selectedPostcode?: string, selectedPostcode?: string,
colorOpacity?: number colorOpacity?: number,
listingsMode?: ListingsMode
): URLSearchParams { ): URLSearchParams {
const params = new URLSearchParams(); const params = new URLSearchParams();
@ -500,6 +536,15 @@ export function stateToParams(
continue; 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); const amenityDistanceFeatureName = getPoiDistanceFeatureName(name);
if (amenityDistanceFeatureName && isPoiDistanceFilterName(name)) { if (amenityDistanceFeatureName && isPoiDistanceFilterName(name)) {
const [min, max] = value as [number, number]; 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 // Travel time: repeated `tt` params
if (travelTimeEntries) { if (travelTimeEntries) {
for (const entry of dedupeTravelTimeEntries(travelTimeEntries)) { for (const entry of dedupeTravelTimeEntries(travelTimeEntries)) {
@ -612,6 +663,7 @@ export function summarizeParams(queryString: string): string {
const ethnicityParams = params.getAll('ethnicity'); const ethnicityParams = params.getAll('ethnicity');
const qualificationParams = params.getAll('qualification'); const qualificationParams = params.getAll('qualification');
const tenureParams = params.getAll('tenure'); const tenureParams = params.getAll('tenure');
const councilParams = params.getAll('council');
const amenityDistanceParams = params.getAll('amenityDistance'); const amenityDistanceParams = params.getAll('amenityDistance');
const transportDistanceParams = params.getAll('transportDistance'); const transportDistanceParams = params.getAll('transportDistance');
const amenityCount2KmParams = params.getAll('amenityCount2km'); const amenityCount2KmParams = params.getAll('amenityCount2km');
@ -625,6 +677,7 @@ export function summarizeParams(queryString: string): string {
ethnicityParams.length > 0 || ethnicityParams.length > 0 ||
qualificationParams.length > 0 || qualificationParams.length > 0 ||
tenureParams.length > 0 || tenureParams.length > 0 ||
councilParams.length > 0 ||
amenityDistanceParams.length > 0 || amenityDistanceParams.length > 0 ||
transportDistanceParams.length > 0 || transportDistanceParams.length > 0 ||
amenityCount2KmParams.length > 0 || amenityCount2KmParams.length > 0 ||
@ -640,6 +693,7 @@ export function summarizeParams(queryString: string): string {
if (isEthnicityFeatureName(name)) return ETHNICITIES_FILTER_NAME; if (isEthnicityFeatureName(name)) return ETHNICITIES_FILTER_NAME;
if (isQualificationFeatureName(name)) return QUALIFICATIONS_FILTER_NAME; if (isQualificationFeatureName(name)) return QUALIFICATIONS_FILTER_NAME;
if (isTenureFeatureName(name)) return TENURE_FILTER_NAME; if (isTenureFeatureName(name)) return TENURE_FILTER_NAME;
if (isCouncilFeatureName(name)) return COUNCIL_FILTER_NAME;
const poiFilterName = getPoiFilterName(name); const poiFilterName = getPoiFilterName(name);
if (poiFilterName) return poiFilterName; if (poiFilterName) return poiFilterName;
return name; return name;
@ -667,6 +721,9 @@ export function summarizeParams(queryString: string): string {
for (let i = 0; i < tenureParams.length; i++) { for (let i = 0; i < tenureParams.length; i++) {
filterNames.push(TENURE_FILTER_NAME); 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++) { for (let i = 0; i < amenityDistanceParams.length; i++) {
filterNames.push(POI_DISTANCE_FILTER_NAME); filterNames.push(POI_DISTANCE_FILTER_NAME);
} }