LGTM
This commit is contained in:
parent
9248e26af2
commit
f2a2651b8a
95 changed files with 3993 additions and 1471 deletions
|
|
@ -97,7 +97,7 @@ const vec3 pieColors[10] = vec3[10](
|
|||
}
|
||||
}
|
||||
|
||||
color = vec4(sliceColor, 1.0);
|
||||
color = vec4(sliceColor, color.a);
|
||||
}`,
|
||||
},
|
||||
uniformTypes: {},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { FeatureMeta } from '../types';
|
|||
import { apiUrl, assertOk, buildFilterString, isAbortError } from './api';
|
||||
import { createSchoolFilterKey } from './school-filter';
|
||||
import { createSpecificCrimeFilterKey } from './crime-filter';
|
||||
import { createElectionVoteShareFilterKey } from './election-filter';
|
||||
import { createEthnicityFilterKey } from './ethnicity-filter';
|
||||
import {
|
||||
POI_COUNT_2KM_FILTER_NAME,
|
||||
|
|
@ -106,6 +107,23 @@ describe('api utilities', () => {
|
|||
).toBe('Burglary (avg/yr):0:5;;Vehicle crime (avg/yr):1:10');
|
||||
});
|
||||
|
||||
it('serializes election vote-share filters using their selected backend party feature', () => {
|
||||
const features: FeatureMeta[] = [
|
||||
{ name: '% Labour', type: 'numeric', min: 0, max: 100 },
|
||||
{ name: '% Conservative', type: 'numeric', min: 0, max: 100 },
|
||||
];
|
||||
|
||||
expect(
|
||||
buildFilterString(
|
||||
{
|
||||
[createElectionVoteShareFilterKey('% Labour', 1)]: [30, 60],
|
||||
[createElectionVoteShareFilterKey('% Conservative', 2)]: [10, 40],
|
||||
},
|
||||
features
|
||||
)
|
||||
).toBe('% Labour:30:60;;% Conservative:10:40');
|
||||
});
|
||||
|
||||
it('deduplicates repeated ethnicity filters to the strictest backend range', () => {
|
||||
const features: FeatureMeta[] = [{ name: '% White', type: 'numeric', min: 0, max: 100 }];
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { INITIAL_RETRY_MS, MAX_RETRY_MS } from './consts';
|
|||
import pb from './pocketbase';
|
||||
import { getSchoolBackendFeatureName } from './school-filter';
|
||||
import { getSpecificCrimeFeatureName } from './crime-filter';
|
||||
import { getElectionVoteShareFeatureName } from './election-filter';
|
||||
import { getEthnicityFeatureName } from './ethnicity-filter';
|
||||
import { getPoiDistanceFeatureName } from './poi-distance-filter';
|
||||
|
||||
|
|
@ -93,6 +94,7 @@ export function buildFilterString(
|
|||
const backendName =
|
||||
getSchoolBackendFeatureName(name) ??
|
||||
getSpecificCrimeFeatureName(name) ??
|
||||
getElectionVoteShareFeatureName(name) ??
|
||||
getEthnicityFeatureName(name) ??
|
||||
getPoiDistanceFeatureName(name) ??
|
||||
name;
|
||||
|
|
|
|||
|
|
@ -248,14 +248,12 @@ export const STACKED_GROUPS: Record<
|
|||
],
|
||||
},
|
||||
],
|
||||
Demographics: [
|
||||
Neighbours: [
|
||||
{
|
||||
label: 'Ethnic composition',
|
||||
unit: '%',
|
||||
components: ['% White', '% South Asian', '% East Asian', '% Black', '% Mixed', '% Other'],
|
||||
},
|
||||
],
|
||||
Politics: [
|
||||
{
|
||||
label: 'Political vote share',
|
||||
unit: '%',
|
||||
|
|
|
|||
113
frontend/src/lib/election-filter.ts
Normal file
113
frontend/src/lib/election-filter.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import type { FeatureFilters, FeatureMeta } from '../types';
|
||||
|
||||
export const ELECTION_VOTE_SHARE_FILTER_NAME = 'Political vote share';
|
||||
export const ELECTION_VOTE_SHARE_FILTER_KEY_PREFIX = `${ELECTION_VOTE_SHARE_FILTER_NAME}:`;
|
||||
|
||||
export const ELECTION_VOTE_SHARE_FEATURE_NAMES = [
|
||||
'% Labour',
|
||||
'% Conservative',
|
||||
'% Liberal Democrat',
|
||||
'% Reform UK',
|
||||
'% Green',
|
||||
'% Other parties',
|
||||
] as const;
|
||||
|
||||
const ELECTION_VOTE_SHARE_FEATURE_NAME_SET = new Set<string>(ELECTION_VOTE_SHARE_FEATURE_NAMES);
|
||||
|
||||
export function isElectionVoteShareFeatureName(name: string): boolean {
|
||||
return ELECTION_VOTE_SHARE_FEATURE_NAME_SET.has(name);
|
||||
}
|
||||
|
||||
export function isElectionVoteShareFilterName(name: string): boolean {
|
||||
return (
|
||||
isElectionVoteShareFeatureName(name) || name.startsWith(ELECTION_VOTE_SHARE_FILTER_KEY_PREFIX)
|
||||
);
|
||||
}
|
||||
|
||||
export function createElectionVoteShareFilterKey(featureName: string, id: number | string): string {
|
||||
return `${ELECTION_VOTE_SHARE_FILTER_KEY_PREFIX}${encodeURIComponent(featureName)}:${id}`;
|
||||
}
|
||||
|
||||
export function getElectionVoteShareFilterKeyId(name: string): string | null {
|
||||
if (!name.startsWith(ELECTION_VOTE_SHARE_FILTER_KEY_PREFIX)) return null;
|
||||
const rest = name.substring(ELECTION_VOTE_SHARE_FILTER_KEY_PREFIX.length);
|
||||
const lastColon = rest.lastIndexOf(':');
|
||||
return lastColon === -1 ? null : rest.substring(lastColon + 1);
|
||||
}
|
||||
|
||||
export function parseElectionVoteShareFilterKey(name: string): string | null {
|
||||
if (!name.startsWith(ELECTION_VOTE_SHARE_FILTER_KEY_PREFIX)) return null;
|
||||
const rest = name.substring(ELECTION_VOTE_SHARE_FILTER_KEY_PREFIX.length);
|
||||
const lastColon = rest.lastIndexOf(':');
|
||||
if (lastColon === -1) return null;
|
||||
|
||||
const decoded = decodeURIComponent(rest.substring(0, lastColon));
|
||||
return isElectionVoteShareFeatureName(decoded) ? decoded : null;
|
||||
}
|
||||
|
||||
export function getElectionVoteShareFeatureName(name: string): string | null {
|
||||
if (isElectionVoteShareFeatureName(name)) return name;
|
||||
return parseElectionVoteShareFilterKey(name);
|
||||
}
|
||||
|
||||
export function replaceElectionVoteShareFilterKeySelection(
|
||||
key: string,
|
||||
featureName: string
|
||||
): string {
|
||||
const id = getElectionVoteShareFilterKeyId(key) ?? '0';
|
||||
return createElectionVoteShareFilterKey(featureName, id);
|
||||
}
|
||||
|
||||
export function getDefaultElectionVoteShareFeatureName(features: FeatureMeta[]): string | null {
|
||||
return (
|
||||
ELECTION_VOTE_SHARE_FEATURE_NAMES.find((name) =>
|
||||
features.some((feature) => feature.name === name)
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeElectionVoteShareFilters(filters: FeatureFilters): FeatureFilters {
|
||||
let changed = false;
|
||||
const next: FeatureFilters = {};
|
||||
|
||||
for (const [name, value] of Object.entries(filters)) {
|
||||
if (isElectionVoteShareFeatureName(name)) {
|
||||
next[createElectionVoteShareFilterKey(name, Object.keys(next).length)] = value;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next[name] = value;
|
||||
}
|
||||
|
||||
return changed ? next : filters;
|
||||
}
|
||||
|
||||
export function getElectionVoteShareFilterMeta(features: FeatureMeta[]): FeatureMeta {
|
||||
const sourceFeatureName = getDefaultElectionVoteShareFeatureName(features);
|
||||
const sourceFeature = sourceFeatureName
|
||||
? features.find((feature) => feature.name === sourceFeatureName)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
name: ELECTION_VOTE_SHARE_FILTER_NAME,
|
||||
type: 'numeric',
|
||||
group: 'Neighbours',
|
||||
min: sourceFeature?.min ?? 0,
|
||||
max: sourceFeature?.max ?? 100,
|
||||
step: 1,
|
||||
description: 'Vote share by party in the 2024 General Election',
|
||||
detail:
|
||||
'Filter by one party vote-share percentage at a time for the constituency covering each postcode.',
|
||||
source: sourceFeature?.source ?? 'election-results',
|
||||
suffix: '%',
|
||||
};
|
||||
}
|
||||
|
||||
export function clampElectionVoteShareRange(
|
||||
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))];
|
||||
}
|
||||
|
|
@ -85,7 +85,7 @@ export function getEthnicityFilterMeta(features: FeatureMeta[]): FeatureMeta {
|
|||
return {
|
||||
name: ETHNICITIES_FILTER_NAME,
|
||||
type: 'numeric',
|
||||
group: 'Demographics',
|
||||
group: 'Neighbours',
|
||||
min: sourceFeature?.min ?? 0,
|
||||
max: sourceFeature?.max ?? 100,
|
||||
step: 0.1,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildPropertySearchUrls } from './external-search';
|
||||
import { buildPropertySearchUrls, buildRightmoveExactPostcodeRedirectUrl } from './external-search';
|
||||
|
||||
describe('external property search URLs', () => {
|
||||
it('returns null when no postcode is available', () => {
|
||||
|
|
@ -59,7 +59,7 @@ describe('external property search URLs', () => {
|
|||
expect(zoopla.searchParams.getAll('property_sub_type')).toEqual(['detached', 'flat']);
|
||||
});
|
||||
|
||||
it('omits Rightmove when location identifier is missing and uses zero radius for postcodes', () => {
|
||||
it('omits Rightmove when location identifier is missing and uses minimum radius for other portals', () => {
|
||||
const urls = buildPropertySearchUrls({
|
||||
location: {
|
||||
lat: 51.501,
|
||||
|
|
@ -75,4 +75,37 @@ describe('external property search URLs', () => {
|
|||
expect(new URL(urls!.onthemarket).searchParams.get('radius')).toBe('0.25');
|
||||
expect(new URL(urls!.zoopla).searchParams.get('radius')).toBe('0.25');
|
||||
});
|
||||
|
||||
it('uses Rightmove this-area-only radius when an exact postcode identifier is provided', () => {
|
||||
const urls = buildPropertySearchUrls({
|
||||
location: {
|
||||
lat: 51.501,
|
||||
lon: -0.141,
|
||||
resolution: 9,
|
||||
postcode: 'SW1A 1AA',
|
||||
isPostcode: true,
|
||||
},
|
||||
rightmoveLocationId: 'POSTCODE^837246',
|
||||
filters: {},
|
||||
});
|
||||
|
||||
const rightmove = new URL(urls!.rightmove!);
|
||||
|
||||
expect(rightmove.searchParams.get('searchLocation')).toBe('SW1A 1AA');
|
||||
expect(rightmove.searchParams.get('locationIdentifier')).toBe('POSTCODE^837246');
|
||||
expect(rightmove.searchParams.get('radius')).toBe('0.0');
|
||||
});
|
||||
|
||||
it('builds a same-origin Rightmove redirect for exact postcode clicks', () => {
|
||||
const target =
|
||||
'https://www.rightmove.co.uk/property-for-sale/find.html?searchLocation=SW1A+1AA&locationIdentifier=OUTCODE%5E2506';
|
||||
const redirect = new URL(
|
||||
buildRightmoveExactPostcodeRedirectUrl('SW1A 1AA', target),
|
||||
'https://perfect-postcode.co.uk'
|
||||
);
|
||||
|
||||
expect(redirect.pathname).toBe('/api/rightmove-search');
|
||||
expect(redirect.searchParams.get('postcode')).toBe('SW1A 1AA');
|
||||
expect(redirect.searchParams.get('target')).toBe(target);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -89,6 +89,16 @@ interface SearchUrlOptions {
|
|||
rightmoveLocationId?: string;
|
||||
}
|
||||
|
||||
export function buildRightmoveExactPostcodeRedirectUrl(
|
||||
postcode: string,
|
||||
targetUrl: string
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set('postcode', postcode);
|
||||
params.set('target', targetUrl);
|
||||
return `/api/rightmove-search?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function buildPropertySearchUrls({
|
||||
location,
|
||||
filters,
|
||||
|
|
@ -138,7 +148,12 @@ export function buildPropertySearchUrls({
|
|||
rmParams.set('searchLocation', postcode);
|
||||
rmParams.set('useLocationIdentifier', 'true');
|
||||
rmParams.set('locationIdentifier', rightmoveLocationId);
|
||||
rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII)));
|
||||
rmParams.set(
|
||||
'radius',
|
||||
isPostcode && rightmoveLocationId.startsWith('POSTCODE^')
|
||||
? '0.0'
|
||||
: String(nearestRadius(radiusMiles, RIGHTMOVE_RADII))
|
||||
);
|
||||
if (minPrice !== undefined)
|
||||
rmParams.set('minPrice', String(snapToAllowed(minPrice, RIGHTMOVE_PRICES, 'floor')));
|
||||
if (maxPrice !== undefined)
|
||||
|
|
|
|||
|
|
@ -104,17 +104,6 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
|
|||
),
|
||||
|
||||
// ── Transport ────────────────────────────────
|
||||
'Distance to nearest train or tube station (km)': (
|
||||
<>
|
||||
<path d="M12 2v8" />
|
||||
<path d="M4.93 10.93l2.83 2.83" />
|
||||
<path d="M2 18h2" />
|
||||
<path d="M20 18h2" />
|
||||
<path d="M19.07 10.93l-2.83 2.83" />
|
||||
<circle cx="12" cy="18" r="4" />
|
||||
<line x1="12" y1="18" x2="12" y2="15" />
|
||||
</>
|
||||
),
|
||||
'Travel time to nearest train or tube station (min)': (
|
||||
<>
|
||||
<rect x="5" y="3" width="14" height="10" rx="2" />
|
||||
|
|
@ -187,7 +176,7 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
|
|||
</>
|
||||
),
|
||||
|
||||
// ── Deprivation ──────────────────────────────
|
||||
// ── Area characteristics ─────────────────────
|
||||
'Income Score': (
|
||||
<>
|
||||
<rect x="2" y="6" width="20" height="14" rx="2" />
|
||||
|
|
@ -353,7 +342,7 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
|
|||
</>
|
||||
),
|
||||
|
||||
// ── Demographics ─────────────────────────────
|
||||
// ── Neighbours ───────────────────────────────
|
||||
'% White': (
|
||||
<>
|
||||
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
||||
|
|
@ -404,21 +393,6 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
|
|||
),
|
||||
|
||||
// ── Amenities ────────────────────────────────
|
||||
'Number of restaurants within 2km': (
|
||||
<>
|
||||
<path d="M3 2v8c0 1.1.9 2 2 2h2v10h2V12h2a2 2 0 002-2V2" />
|
||||
<path d="M7 2v4" />
|
||||
<path d="M19 2v20" />
|
||||
<path d="M19 8a3 3 0 00-3-3" />
|
||||
</>
|
||||
),
|
||||
'Number of grocery shops and supermarkets within 2km': (
|
||||
<>
|
||||
<circle cx="9" cy="21" r="1" />
|
||||
<circle cx="20" cy="21" r="1" />
|
||||
<path d="M1 1h4l2.68 13.39a2 2 0 002 1.61h9.72a2 2 0 002-1.61L23 6H6" />
|
||||
</>
|
||||
),
|
||||
'Number of parks within 1km': (
|
||||
<>
|
||||
<path d="M12 22v-7" />
|
||||
|
|
|
|||
|
|
@ -89,25 +89,13 @@ export function formatDuration(d: string): string {
|
|||
return d;
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
|
||||
export function formatTransactionDate(fractionalYear: number): string {
|
||||
const year = Math.floor(fractionalYear);
|
||||
const monthIndex = Math.min(Math.round((fractionalYear - year) * 12), 11);
|
||||
return `${MONTH_NAMES[monthIndex]} ${year}`;
|
||||
const language = i18n.language || undefined;
|
||||
return new Intl.DateTimeFormat(language, { month: 'short', year: 'numeric' }).format(
|
||||
new Date(Date.UTC(year, monthIndex, 1))
|
||||
);
|
||||
}
|
||||
|
||||
export function formatAge(value: number, approximate = true): string {
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ const GROUP_ICONS: Record<string, ComponentType<{ className?: string }>> = {
|
|||
Properties: HouseIcon,
|
||||
Transport: RouteIcon,
|
||||
Education: GraduationCapIcon,
|
||||
Deprivation: ChartBarIcon,
|
||||
'Area characteristics': ChartBarIcon,
|
||||
Crime: ShieldIcon,
|
||||
Demographics: UsersIcon,
|
||||
Neighbours: UsersIcon,
|
||||
'Nearby POIs': MapPinIcon,
|
||||
Amenities: ShoppingBagIcon,
|
||||
Environment: TreeIcon,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { cellToChildren, cellToLatLng, latLngToCell } from 'h3-js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { HexagonData } from '../types';
|
||||
import { findOverlappingMatchingHexagon, hasMatchingHexagonAtResolution } from './h3-selection';
|
||||
import { findOverlappingSelectableHexagon, hasMatchingHexagonAtResolution } from './h3-selection';
|
||||
|
||||
function hexagonData(h3: string, count: number): HexagonData {
|
||||
const [lat, lon] = cellToLatLng(h3);
|
||||
|
|
@ -9,10 +9,10 @@ function hexagonData(h3: string, count: number): HexagonData {
|
|||
}
|
||||
|
||||
describe('h3 selection helpers', () => {
|
||||
it('finds a matching higher-resolution hexagon that overlaps the previous hexagon', () => {
|
||||
it('prefers a matching higher-resolution hexagon that overlaps the previous hexagon', () => {
|
||||
const parent = latLngToCell(51.5, -0.1, 8);
|
||||
const children = cellToChildren(parent, 9);
|
||||
const selected = findOverlappingMatchingHexagon(
|
||||
const selected = findOverlappingSelectableHexagon(
|
||||
parent,
|
||||
[hexagonData(children[0], 0), hexagonData(children[1], 4)],
|
||||
9
|
||||
|
|
@ -21,18 +21,18 @@ describe('h3 selection helpers', () => {
|
|||
expect(selected?.h3).toBe(children[1]);
|
||||
});
|
||||
|
||||
it('rejects candidates that do not overlap or have no matches', () => {
|
||||
it('falls back to a selectable no-match candidate when there is no matching overlap', () => {
|
||||
const parent = latLngToCell(51.5, -0.1, 8);
|
||||
const nearbyChild = cellToChildren(parent, 9)[0];
|
||||
const distant = latLngToCell(52.2, -0.1, 9);
|
||||
|
||||
expect(
|
||||
findOverlappingMatchingHexagon(
|
||||
findOverlappingSelectableHexagon(
|
||||
parent,
|
||||
[hexagonData(nearbyChild, 0), hexagonData(distant, 12)],
|
||||
9
|
||||
)
|
||||
).toBeNull();
|
||||
).toEqual(hexagonData(nearbyChild, 0));
|
||||
});
|
||||
|
||||
it('detects when target-resolution matching data is loaded', () => {
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export function hasMatchingHexagonAtResolution(
|
|||
return hexagons.some((hexagon) => hexagon.count > 0 && getResolution(hexagon.h3) === resolution);
|
||||
}
|
||||
|
||||
export function findOverlappingMatchingHexagon(
|
||||
export function findOverlappingSelectableHexagon(
|
||||
previousH3: string,
|
||||
hexagons: HexagonData[],
|
||||
resolution: number
|
||||
|
|
@ -105,16 +105,20 @@ export function findOverlappingMatchingHexagon(
|
|||
let bestDistance = Infinity;
|
||||
|
||||
for (const hexagon of hexagons) {
|
||||
if (hexagon.count <= 0 || getResolution(hexagon.h3) !== resolution) continue;
|
||||
if (getResolution(hexagon.h3) !== resolution) continue;
|
||||
if (!polygonsOverlap(previousBoundary, cellBoundary(hexagon.h3))) continue;
|
||||
|
||||
const distance = (hexagon.lat - previousLat) ** 2 + (hexagon.lon - previousLng) ** 2;
|
||||
const hasMatches = hexagon.count > 0;
|
||||
const bestHasMatches = (best?.count ?? 0) > 0;
|
||||
if (
|
||||
!best ||
|
||||
distance < bestDistance - EPSILON ||
|
||||
(Math.abs(distance - bestDistance) <= EPSILON &&
|
||||
(hexagon.count > best.count ||
|
||||
(hexagon.count === best.count && hexagon.h3.localeCompare(best.h3) < 0)))
|
||||
(hasMatches && !bestHasMatches) ||
|
||||
(hasMatches === bestHasMatches &&
|
||||
(distance < bestDistance - EPSILON ||
|
||||
(Math.abs(distance - bestDistance) <= EPSILON &&
|
||||
(hexagon.count > best.count ||
|
||||
(hexagon.count === best.count && hexagon.h3.localeCompare(best.h3) < 0)))))
|
||||
) {
|
||||
best = hexagon;
|
||||
bestDistance = distance;
|
||||
|
|
|
|||
|
|
@ -753,3 +753,110 @@ export const SEO_CONTENT_PAGES: Record<SeoContentKey, SeoContentPage> = {
|
|||
],
|
||||
},
|
||||
};
|
||||
|
||||
type SeoLanguageCode = 'en' | 'fr' | 'de' | 'zh' | 'hi' | 'hu';
|
||||
type LocalizedSeoLanguageCode = Exclude<SeoLanguageCode, 'en'>;
|
||||
|
||||
function toSeoLanguage(language?: string): SeoLanguageCode {
|
||||
const code = language?.toLowerCase().split('-')[0];
|
||||
if (code === 'fr' || code === 'de' || code === 'zh' || code === 'hi' || code === 'hu') {
|
||||
return code;
|
||||
}
|
||||
return 'en';
|
||||
}
|
||||
|
||||
const COMMON_RELATED_LINKS_BY_LANGUAGE: Record<LocalizedSeoLanguageCode, SeoLink[]> = {
|
||||
fr: [
|
||||
{
|
||||
label: 'Sources et couverture des données',
|
||||
path: '/data-sources',
|
||||
description:
|
||||
'Voyez quels jeux de données alimentent les filtres par code postal et où se situent leurs limites.',
|
||||
},
|
||||
{
|
||||
label: 'Méthodologie',
|
||||
path: '/methodology',
|
||||
description:
|
||||
'Comprenez comment la carte aide à établir une présélection sans remplacer les vérifications nécessaires.',
|
||||
},
|
||||
{
|
||||
label: 'Vérificateur de code postal',
|
||||
path: '/postcode-checker',
|
||||
description: 'Contrôlez un code postal avant de consacrer du temps à une visite.',
|
||||
},
|
||||
],
|
||||
de: [
|
||||
{
|
||||
label: 'Datenquellen und Abdeckung',
|
||||
path: '/data-sources',
|
||||
description:
|
||||
'Sehen Sie, welche Datensätze hinter den Postleitzahlfiltern stehen und wo ihre Grenzen liegen.',
|
||||
},
|
||||
{
|
||||
label: 'Methodik',
|
||||
path: '/methodology',
|
||||
description:
|
||||
'Verstehen Sie, wie die Karte beim Eingrenzen hilft, ohne die eigene Prüfung zu ersetzen.',
|
||||
},
|
||||
{
|
||||
label: 'Postleitzahl-Prüfer',
|
||||
path: '/postcode-checker',
|
||||
description: 'Prüfen Sie eine Postleitzahl, bevor Sie Zeit für eine Besichtigung einplanen.',
|
||||
},
|
||||
],
|
||||
zh: [
|
||||
{
|
||||
label: '数据来源和覆盖范围',
|
||||
path: '/data-sources',
|
||||
description: '查看哪些数据集支持邮编筛选,以及这些数据的限制。',
|
||||
},
|
||||
{
|
||||
label: '方法说明',
|
||||
path: '/methodology',
|
||||
description: '了解地图如何帮助建立候选清单,而不是替代尽职调查。',
|
||||
},
|
||||
{
|
||||
label: '邮编检查器',
|
||||
path: '/postcode-checker',
|
||||
description: '在安排看房前先检查一个邮编。',
|
||||
},
|
||||
],
|
||||
hi: [
|
||||
{
|
||||
label: 'डेटा स्रोत और कवरेज',
|
||||
path: '/data-sources',
|
||||
description:
|
||||
'देखें कि पोस्टकोड फ़िल्टर के पीछे कौन से डेटा सेट हैं और उनकी सीमाएँ कहाँ हैं।',
|
||||
},
|
||||
{
|
||||
label: 'कार्यप्रणाली',
|
||||
path: '/methodology',
|
||||
description:
|
||||
'समझें कि नक्शा शॉर्टलिस्ट बनाने में कैसे मदद करता है, लेकिन आपकी जाँच की जगह नहीं लेता।',
|
||||
},
|
||||
{
|
||||
label: 'पोस्टकोड जाँच',
|
||||
path: '/postcode-checker',
|
||||
description: 'किसी देखने जाने से पहले एक पोस्टकोड की जाँच करें।',
|
||||
},
|
||||
],
|
||||
hu: [
|
||||
{
|
||||
label: 'Adatforrások és lefedettség',
|
||||
path: '/data-sources',
|
||||
description:
|
||||
'Nézze meg, mely adatkészletek állnak az irányítószám-szűrők mögött, és hol vannak a korlátaik.',
|
||||
},
|
||||
{
|
||||
label: 'Módszertan',
|
||||
path: '/methodology',
|
||||
description:
|
||||
'Értse meg, hogyan támogatja a térkép a szűkítést anélkül, hogy kiváltaná a saját ellenőrzést.',
|
||||
},
|
||||
{
|
||||
label: 'Irányítószám-ellenőrző',
|
||||
path: '/postcode-checker',
|
||||
description: 'Ellenőrizzen egy irányítószámot, mielőtt időt szánna egy megtekintésre.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { parseUrlState, stateToParams } from './url-state';
|
|||
import { INITIAL_VIEW_STATE } from './consts';
|
||||
import { createSchoolFilterKey } from './school-filter';
|
||||
import { createSpecificCrimeFilterKey } from './crime-filter';
|
||||
import { createElectionVoteShareFilterKey } from './election-filter';
|
||||
import { createEthnicityFilterKey } from './ethnicity-filter';
|
||||
import {
|
||||
POI_COUNT_2KM_FILTER_NAME,
|
||||
|
|
@ -168,6 +169,33 @@ describe('url-state', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('round-trips repeated election vote-share filters with dedicated URL params', () => {
|
||||
const labour = createElectionVoteShareFilterKey('% Labour', 1);
|
||||
const conservative = createElectionVoteShareFilterKey('% Conservative', 2);
|
||||
|
||||
const params = stateToParams(
|
||||
null,
|
||||
{
|
||||
[labour]: [30, 55],
|
||||
[conservative]: [10, 35],
|
||||
},
|
||||
[],
|
||||
new Set(),
|
||||
'area'
|
||||
);
|
||||
|
||||
expect(params.getAll('voteShare')).toEqual(['% Labour:30:55', '% Conservative:10:35']);
|
||||
expect(params.getAll('filter')).toEqual([]);
|
||||
|
||||
window.history.replaceState({}, '', `/?${params.toString()}`);
|
||||
const state = parseUrlState();
|
||||
|
||||
expect(state.filters).toEqual({
|
||||
[createElectionVoteShareFilterKey('% Labour', 0)]: [30, 55],
|
||||
[createElectionVoteShareFilterKey('% Conservative', 1)]: [10, 35],
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips repeated ethnicity filters with dedicated URL params', () => {
|
||||
const white = createEthnicityFilterKey('% White', 3);
|
||||
const southAsian = createEthnicityFilterKey('% South Asian', 4);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,13 @@ import {
|
|||
isSpecificCrimeFeatureName,
|
||||
isSpecificCrimeFilterName,
|
||||
} from './crime-filter';
|
||||
import {
|
||||
ELECTION_VOTE_SHARE_FILTER_NAME,
|
||||
createElectionVoteShareFilterKey,
|
||||
getElectionVoteShareFeatureName,
|
||||
isElectionVoteShareFeatureName,
|
||||
isElectionVoteShareFilterName,
|
||||
} from './election-filter';
|
||||
import {
|
||||
ETHNICITIES_FILTER_NAME,
|
||||
createEthnicityFilterKey,
|
||||
|
|
@ -58,6 +65,7 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
|
|||
const filterParams = params.getAll('filter');
|
||||
const schoolParams = params.getAll('school');
|
||||
const crimeParams = params.getAll('crime');
|
||||
const voteShareParams = params.getAll('voteShare');
|
||||
const ethnicityParams = params.getAll('ethnicity');
|
||||
const poiDistanceParams = params.getAll('poiDistance');
|
||||
const poiCount2KmParams = params.getAll('poiCount2km');
|
||||
|
|
@ -66,6 +74,7 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
|
|||
filterParams.length === 0 &&
|
||||
schoolParams.length === 0 &&
|
||||
crimeParams.length === 0 &&
|
||||
voteShareParams.length === 0 &&
|
||||
ethnicityParams.length === 0 &&
|
||||
poiDistanceParams.length === 0 &&
|
||||
poiCount2KmParams.length === 0 &&
|
||||
|
|
@ -126,6 +135,18 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
|
|||
filters[createSpecificCrimeFilterKey(featureName, index)] = [min, max];
|
||||
});
|
||||
|
||||
voteShareParams.forEach((entry, index) => {
|
||||
const parts = entry.split(':');
|
||||
if (parts.length < 3) return;
|
||||
const featureName = parts.slice(0, -2).join(':');
|
||||
const min = Number(parts[parts.length - 2]);
|
||||
const max = Number(parts[parts.length - 1]);
|
||||
if (!isElectionVoteShareFeatureName(featureName) || isNaN(min) || isNaN(max)) {
|
||||
return;
|
||||
}
|
||||
filters[createElectionVoteShareFilterKey(featureName, index)] = [min, max];
|
||||
});
|
||||
|
||||
ethnicityParams.forEach((entry, index) => {
|
||||
const parts = entry.split(':');
|
||||
if (parts.length < 3) return;
|
||||
|
|
@ -301,6 +322,13 @@ export function stateToParams(
|
|||
continue;
|
||||
}
|
||||
|
||||
const electionVoteShareFeatureName = getElectionVoteShareFeatureName(name);
|
||||
if (electionVoteShareFeatureName && isElectionVoteShareFilterName(name)) {
|
||||
const [min, max] = value as [number, number];
|
||||
params.append('voteShare', `${electionVoteShareFeatureName}:${min}:${max}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ethnicityFeatureName = getEthnicityFeatureName(name);
|
||||
if (ethnicityFeatureName && isEthnicityFilterName(name)) {
|
||||
const [min, max] = value as [number, number];
|
||||
|
|
@ -372,6 +400,7 @@ export function summarizeParams(queryString: string): string {
|
|||
const filterParams = params.getAll('filter');
|
||||
const schoolParams = params.getAll('school');
|
||||
const crimeParams = params.getAll('crime');
|
||||
const voteShareParams = params.getAll('voteShare');
|
||||
const ethnicityParams = params.getAll('ethnicity');
|
||||
const poiDistanceParams = params.getAll('poiDistance');
|
||||
const poiCount2KmParams = params.getAll('poiCount2km');
|
||||
|
|
@ -380,6 +409,7 @@ export function summarizeParams(queryString: string): string {
|
|||
filterParams.length > 0 ||
|
||||
schoolParams.length > 0 ||
|
||||
crimeParams.length > 0 ||
|
||||
voteShareParams.length > 0 ||
|
||||
ethnicityParams.length > 0 ||
|
||||
poiDistanceParams.length > 0 ||
|
||||
poiCount2KmParams.length > 0 ||
|
||||
|
|
@ -390,6 +420,7 @@ export function summarizeParams(queryString: string): string {
|
|||
const colonIdx = entry.indexOf(':');
|
||||
const name = colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
|
||||
if (isSpecificCrimeFeatureName(name)) return SPECIFIC_CRIMES_FILTER_NAME;
|
||||
if (isElectionVoteShareFeatureName(name)) return ELECTION_VOTE_SHARE_FILTER_NAME;
|
||||
if (isEthnicityFeatureName(name)) return ETHNICITIES_FILTER_NAME;
|
||||
if (isPoiDistanceFeatureName(name)) return POI_DISTANCE_FILTER_NAME;
|
||||
return name;
|
||||
|
|
@ -399,6 +430,9 @@ export function summarizeParams(queryString: string): string {
|
|||
for (let i = 0; i < crimeParams.length; i++) {
|
||||
filterNames.push(SPECIFIC_CRIMES_FILTER_NAME);
|
||||
}
|
||||
for (let i = 0; i < voteShareParams.length; i++) {
|
||||
filterNames.push(ELECTION_VOTE_SHARE_FILTER_NAME);
|
||||
}
|
||||
for (let i = 0; i < ethnicityParams.length; i++) {
|
||||
filterNames.push(ETHNICITIES_FILTER_NAME);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue