This commit is contained in:
Andras Schmelczer 2026-05-11 21:38:26 +01:00
parent 9248e26af2
commit f2a2651b8a
95 changed files with 3993 additions and 1471 deletions

View file

@ -97,7 +97,7 @@ const vec3 pieColors[10] = vec3[10](
}
}
color = vec4(sliceColor, 1.0);
color = vec4(sliceColor, color.a);
}`,
},
uniformTypes: {},

View file

@ -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 }];

View file

@ -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;

View file

@ -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: '%',

View 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))];
}

View file

@ -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,

View file

@ -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);
});
});

View file

@ -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)

View file

@ -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" />

View file

@ -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 {

View file

@ -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,

View file

@ -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', () => {

View file

@ -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;

View file

@ -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.',
},
],
};

View file

@ -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);

View file

@ -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);
}