This commit is contained in:
Andras Schmelczer 2026-05-12 08:05:29 +01:00
parent a9e5a8ad96
commit 8708bf000d
45 changed files with 434 additions and 436 deletions

1
.gitignore vendored
View file

@ -16,3 +16,4 @@ frontend/public/assets/*
!frontend/public/assets/poi-icons/**
frontend/public/assets/.done
server-rs/logs
video/auth.*

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

View file

@ -76,7 +76,7 @@ const SAME_AS_EN_VALUE_ALLOWLIST = new Set([
]);
const FORBIDDEN_VISIBLE_STRINGS = [
['without this filter', 'filters.withoutThisFilter'],
['without this filter', 'filters.filtersOut'],
['Connecting to server...', 'common.connectingToServer'],
['Property saved!', 'toasts.propertySaved'],
['View saved', 'toasts.viewSaved'],

View file

@ -330,7 +330,7 @@ function FilterPreviewRow({
feature,
value,
rangeLabel,
withoutCount,
filteredOutCount,
index,
isTightened,
onValueChange,
@ -338,7 +338,7 @@ function FilterPreviewRow({
feature: FeatureMeta;
value: [number, number];
rangeLabel: string;
withoutCount: number;
filteredOutCount: number;
index: number;
isTightened: boolean;
onValueChange: (value: [number, number]) => void;
@ -374,8 +374,8 @@ function FilterPreviewRow({
<span
className={`w-fit shrink-0 rounded-md px-2.5 py-1 text-xs font-bold leading-none ${style.chip}`}
>
{t('filters.withoutThisFilter', {
value: withoutCount.toLocaleString(),
{t('filters.filtersOut', {
value: filteredOutCount.toLocaleString(),
})}
</span>
</div>
@ -413,7 +413,11 @@ function formatDemoRange(feature: FeatureMeta, value: [number, number], t: TFunc
return `${value[0]} - ${value[1]}`;
}
function randomWithoutCount(feature: FeatureMeta, value: [number, number], index: number): number {
function randomFilteredOutCount(
feature: FeatureMeta,
value: [number, number],
index: number
): number {
const min = feature.min ?? 0;
const max = feature.max ?? 100;
const range = Math.max(max - min, 1);
@ -456,7 +460,7 @@ function FilterOnlyScreen({ isActive }: { isActive: boolean }) {
[285000, 610000],
[0, 650000],
] as [number, number][],
without: [41820, 50622, 24860, 18645, 29796, 41820],
filteredOut: [41820, 50622, 24860, 18645, 29796, 41820],
},
{
feature: DEMO_FEATURES[3],
@ -465,7 +469,7 @@ function FilterOnlyScreen({ isActive }: { isActive: boolean }) {
[43, 52],
[40, 58],
] as [number, number][],
without: [19412, 8706, 19412],
filteredOut: [19412, 8706, 19412],
},
{
feature: DEMO_FEATURES[4],
@ -474,7 +478,7 @@ function FilterOnlyScreen({ isActive }: { isActive: boolean }) {
[5, 25],
[0, 60],
] as [number, number][],
without: [11209, 4118, 11209],
filteredOut: [11209, 4118, 11209],
},
{
feature: DEMO_FEATURES[2],
@ -483,34 +487,34 @@ function FilterOnlyScreen({ isActive }: { isActive: boolean }) {
[2, 6],
[1, 8],
] as [number, number][],
without: [13608, 6944, 13608],
filteredOut: [13608, 6944, 13608],
},
],
[]
);
const [filterState, setFilterState] = useState(() =>
rows.map((row) => ({ value: row.values[0], without: row.without[0] }))
rows.map((row) => ({ value: row.values[0], filteredOut: row.filteredOut[0] }))
);
useEffect(() => {
if (!isActive || hasUserAdjusted) return;
let frame = 0;
const start = window.performance.now();
setFilterState(rows.map((row) => ({ value: row.values[0], without: row.without[0] })));
setFilterState(rows.map((row) => ({ value: row.values[0], filteredOut: row.filteredOut[0] })));
const animate = (timestamp: number) => {
const progress = ((timestamp - start) % FILTER_ANIMATION_MS) / FILTER_ANIMATION_MS;
setFilterState(
rows.map((row) => {
const value = interpolateRangePath(row.values, progress);
const without = interpolateRangePath(
row.without.map((count) => [count, count]),
const filteredOut = interpolateRangePath(
row.filteredOut.map((count) => [count, count]),
progress
)[0];
return {
value,
without: Math.round(without),
filteredOut: Math.round(filteredOut),
};
})
);
@ -528,7 +532,7 @@ function FilterOnlyScreen({ isActive }: { isActive: boolean }) {
itemIndex === index
? {
value,
without: randomWithoutCount(rows[index].feature, value, index),
filteredOut: randomFilteredOutCount(rows[index].feature, value, index),
}
: item
)
@ -544,7 +548,7 @@ function FilterOnlyScreen({ isActive }: { isActive: boolean }) {
feature={row.feature}
value={filterState[index].value}
rangeLabel={formatDemoRange(row.feature, filterState[index].value, t)}
withoutCount={filterState[index].without}
filteredOutCount={filterState[index].filteredOut}
index={index}
isTightened
onValueChange={(value) => updateFilter(index, value)}

View file

@ -57,6 +57,11 @@ const DATA_SOURCE_DEFS: DataSourceDef[] = [
url: 'https://osdatahub.os.uk/downloads/open/OpenGreenspace',
license: 'Open Government Licence v3.0',
},
{
id: 'forest-research-tow',
url: 'https://www.forestresearch.gov.uk/tools-and-resources/national-trees-outside-woodland-map/',
license: 'Open Government Licence v3.0',
},
{
id: 'naptan',
url: 'https://naptan.dft.gov.uk/naptan/schema/2.4/doc/NaPTANSchemaGuide-2.4-v0.57.pdf',
@ -121,6 +126,11 @@ const DS_KEYS: Record<string, [string, string, string]> = {
'learnPage.dsGreenspaceOrigin',
'learnPage.dsGreenspaceUse',
],
'forest-research-tow': [
'learnPage.dsTowName',
'learnPage.dsTowOrigin',
'learnPage.dsTowUse',
],
naptan: ['learnPage.dsNaptanName', 'learnPage.dsNaptanOrigin', 'learnPage.dsNaptanUse'],
noise: ['learnPage.dsNoiseName', 'learnPage.dsNoiseOrigin', 'learnPage.dsNoiseUse'],
ofsted: ['learnPage.dsOfstedName', 'learnPage.dsOfstedOrigin', 'learnPage.dsOfstedUse'],

View file

@ -6,8 +6,8 @@ import { SearchInput } from '../ui/SearchInput';
import { FilterIcon } from '../ui/icons';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { EmptyState } from '../ui/EmptyState';
import type { FeatureGroup, FeatureMeta } from '../../types';
import { groupFeaturesByCategory } from '../../lib/features';
import type { FeatureMeta } from '../../types';
import { groupFeaturesByCategory, orderFilterGroups } from '../../lib/features';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { FeatureActions } from '../ui/FeatureIcons';
import { FeatureLabel } from '../ui/FeatureLabel';
@ -35,16 +35,6 @@ interface FeatureBrowserProps {
onAddTravelTimeEntry: (mode: TransportMode) => void;
}
function moveTransportFirst(groups: FeatureGroup[]): FeatureGroup[] {
const transportIdx = groups.findIndex((group) => group.name === 'Transport');
if (transportIdx <= 0) return groups;
return [
groups[transportIdx],
...groups.slice(0, transportIdx),
...groups.slice(transportIdx + 1),
];
}
export default function FeatureBrowser({
availableFeatures,
allFeatures,
@ -83,7 +73,7 @@ export default function FeatureBrowser({
);
}, [availableFeatures, search]);
const grouped = useMemo(() => moveTransportFirst(groupFeaturesByCategory(filtered)), [filtered]);
const grouped = useMemo(() => orderFilterGroups(groupFeaturesByCategory(filtered)), [filtered]);
// When searching, expand all groups so results are visible
const isSearching = search.length > 0;

View file

@ -256,7 +256,7 @@ export default memo(function Filters({
const backendFeature = backendName
? features.find((feature) => feature.name === backendName)
: undefined;
return { ...(backendFeature ?? poiFilterMetas[filterName]), name, group: 'Nearby POIs' };
return { ...(backendFeature ?? poiFilterMetas[filterName]), name, group: 'Amenities' };
});
}, [filters, features, poiFilterMetas]);
const availableFeatures = useMemo(() => {

View file

@ -166,7 +166,7 @@ export function TravelTimeCard({
</div>
{filterImpact != null && filterImpact > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
{t('filters.filtersOut', { value: formatNumber(filterImpact) })}
</p>
)}
</div>

View file

@ -148,10 +148,7 @@ export function ActiveFiltersPanel({
</button>
{!collapsed && (
<div
ref={scrollRef}
className="md:min-h-0 md:flex-1 md:overflow-y-auto overflow-x-hidden"
>
<div ref={scrollRef} className="md:min-h-0 md:flex-1 md:overflow-y-auto overflow-x-hidden">
<AiFilterInput
loading={aiFilterLoading}
error={aiFilterError}

View file

@ -219,7 +219,7 @@ export function ElectionVoteShareFilterCard({
/>
{filterImpact != null && filterImpact > 0 && (
<p className="-mt-1 ml-2.5 text-[10px] text-warm-400 dark:text-warm-500">
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
{t('filters.filtersOut', { value: formatNumber(filterImpact) })}
</p>
)}
</div>

View file

@ -65,7 +65,7 @@ export function EnumFeatureFilterCard({
</PillGroup>
{filterImpact != null && filterImpact > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 mt-0.5">
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
{t('filters.filtersOut', { value: formatNumber(filterImpact) })}
</p>
)}
</div>

View file

@ -215,7 +215,7 @@ export function EthnicityFilterCard({
/>
{filterImpact != null && filterImpact > 0 && (
<p className="-mt-1 ml-2.5 text-[10px] text-warm-400 dark:text-warm-500">
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
{t('filters.filtersOut', { value: formatNumber(filterImpact) })}
</p>
)}
</div>

View file

@ -130,7 +130,7 @@ export function NumericFeatureFilterCard({
/>
{filterImpact != null && filterImpact > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
{t('filters.filtersOut', { value: formatNumber(filterImpact) })}
</p>
)}
</div>

View file

@ -198,7 +198,7 @@ export function PoiDistanceFilterCard({
/>
{filterImpact != null && filterImpact > 0 && (
<p className="-mt-1 ml-2.5 text-[10px] text-warm-400 dark:text-warm-500">
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
{t('filters.filtersOut', { value: formatNumber(filterImpact) })}
</p>
)}
</div>

View file

@ -10,6 +10,9 @@ import { getGroupIcon } from '../../../lib/group-icons';
import { getPoiFeatureCategory } from '../../../lib/poi-distance-filter';
import type { FeatureMeta } from '../../../types';
const DROPDOWN_MAX_HEIGHT = 256;
const FALLBACK_DROPDOWN_MAX_HEIGHT = 'min(16rem, 45dvh)';
interface PoiTypeDropdownProps {
options: FeatureMeta[];
value: string;
@ -108,11 +111,15 @@ export function PoiTypeDropdown({ options, value, onChange }: PoiTypeDropdownPro
requestAnimationFrame(() => inputRef.current?.focus({ preventScroll: true }));
};
const dropdownStyle = pos
? { ...dropdownPositionStyle(pos), maxHeight: Math.min(pos.maxHeight, DROPDOWN_MAX_HEIGHT) }
: { maxHeight: FALLBACK_DROPDOWN_MAX_HEIGHT };
const dropdown = open && (
<div
ref={dropdownRef}
className="flex flex-col overflow-hidden rounded-md border border-warm-200 bg-white shadow-lg dark:border-warm-700 dark:bg-warm-800"
style={pos ? dropdownPositionStyle(pos) : undefined}
style={dropdownStyle}
>
<div className="shrink-0 border-b border-warm-100 p-1.5 dark:border-warm-700">
<div className="relative">
@ -183,11 +190,8 @@ export function PoiTypeDropdown({ options, value, onChange }: PoiTypeDropdownPro
onClick={() => (open ? setOpen(false) : handleOpen())}
className="flex w-full items-center gap-1.5 rounded-md border border-warm-200 bg-warm-50 px-2 py-1.5 pr-8 text-left text-sm font-medium text-navy-950 shadow-inner outline-none transition-colors hover:bg-white focus:border-teal-400 focus:ring-2 focus:ring-teal-200 dark:border-warm-700 dark:bg-navy-900 dark:text-warm-100 dark:hover:bg-navy-800 dark:focus:ring-teal-900/50"
>
{selected &&
optionIcon(selected, 'h-4 w-4 shrink-0 text-teal-600 dark:text-teal-400')}
<span className="min-w-0 flex-1 truncate">
{selected ? optionLabel(selected) : ''}
</span>
{selected && optionIcon(selected, 'h-4 w-4 shrink-0 text-teal-600 dark:text-teal-400')}
<span className="min-w-0 flex-1 truncate">{selected ? optionLabel(selected) : ''}</span>
<ChevronIcon
direction={open ? 'up' : 'down'}
className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-warm-400 dark:text-warm-500"

View file

@ -232,7 +232,7 @@ export function SchoolFilterCard({
/>
{filterImpact != null && filterImpact > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
{t('filters.filtersOut', { value: formatNumber(filterImpact) })}
</p>
)}
</div>

View file

@ -215,7 +215,7 @@ export function SpecificCrimeFilterCard({
/>
{filterImpact != null && filterImpact > 0 && (
<p className="-mt-1 ml-2.5 text-[10px] text-warm-400 dark:text-warm-500">
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
{t('filters.filtersOut', { value: formatNumber(filterImpact) })}
</p>
)}
</div>

View file

@ -7,6 +7,9 @@ import { MapPinIcon } from './icons/MapPinIcon';
import { ChevronIcon } from './icons/ChevronIcon';
import { CloseIcon } from './icons/CloseIcon';
const DROPDOWN_MAX_HEIGHT = 256;
const FALLBACK_DROPDOWN_MAX_HEIGHT = 'min(16rem, 45dvh)';
interface DestinationDropdownProps {
destinations: Destination[];
loading: boolean;
@ -105,11 +108,15 @@ export function DestinationDropdown({
requestAnimationFrame(() => inputRef.current?.focus({ preventScroll: true }));
}, []);
const dropdownStyle = pos
? { ...dropdownPositionStyle(pos), maxHeight: Math.min(pos.maxHeight, DROPDOWN_MAX_HEIGHT) }
: { maxHeight: FALLBACK_DROPDOWN_MAX_HEIGHT };
const dropdown = open && (
<div
ref={dropdownRef}
className="flex flex-col bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 overflow-hidden"
style={pos ? dropdownPositionStyle(pos) : undefined}
style={dropdownStyle}
>
<div className="shrink-0 p-1.5 border-b border-warm-100 dark:border-warm-700">
<input

View file

@ -5,7 +5,16 @@ function Digit({ char, delay, active }: { char: string; delay: number; active: b
const idx = DIGITS.indexOf(char);
if (idx === -1)
return (
<span className="inline-block" style={{ height: `${H}em`, lineHeight: `${H}em` }}>
<span
aria-hidden="true"
className="inline-block"
style={{
height: `${H}em`,
lineHeight: `${H}em`,
overflow: 'hidden',
verticalAlign: 'top',
}}
>
{char}
</span>
);
@ -13,7 +22,16 @@ function Digit({ char, delay, active }: { char: string; delay: number; active: b
const offset = active ? -idx * H : 0;
return (
<span className="inline-block overflow-hidden" style={{ height: `${H}em` }}>
<span
aria-hidden="true"
className="inline-block overflow-hidden"
style={{
height: `${H}em`,
lineHeight: `${H}em`,
overflow: 'hidden',
verticalAlign: 'top',
}}
>
<span
className="block"
style={{
@ -39,7 +57,12 @@ export function TickerValue({ text, active = true }: { text: string; active?: bo
const chars = text.split('');
const len = chars.length;
return (
<span className="inline-flex" style={{ fontVariantNumeric: 'tabular-nums' }}>
<span
aria-label={text}
className="inline-flex whitespace-nowrap leading-none"
role="text"
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{chars.map((ch, i) => (
<Digit key={i} char={ch} delay={(len - 1 - i) * 30} active={active} />
))}

View file

@ -11,8 +11,8 @@ interface FilterCountsResponse {
}
/**
* Fetches per-filter marginal impact counts: for each active filter,
* how many more properties would be visible if that filter were removed.
* Fetches per-filter rejection counts: for each active filter,
* how many in-bounds properties that filter removes.
*/
export function useFilterCounts(
filters: FeatureFilters,

View file

@ -94,15 +94,14 @@ const descriptions: Record<string, Record<string, string>> = {
'% Green': 'Part des voix vertes aux élections générales de 2024',
'% Other parties': 'Part des voix combinée de tous les autres partis et indépendants',
'Distance to nearest park (km)': 'Distance au parc ou espace vert le plus proche',
'Number of parks within 1km': 'Nombre de parcs et espaces verts à moins de 1 km',
'Noise (dB)': 'Niveau de bruit routier au code postal en décibels (Lden)',
'Max available download speed (Mbps)': 'Débit descendant maximal disponible au code postal',
Schools: 'Écoles primaires et secondaires notées à proximité',
'Specific crimes': 'Filtrer une catégorie de criminalité de rue à la fois',
Ethnicities: 'Pourcentage de population par groupe ethnique',
'POI distance': 'Distance aux points dintérêt proches',
'POIs within 2km': 'Nombre de points dintérêt proches dans un rayon de 2 km',
'POIs within 5km': 'Nombre de points dintérêt proches dans un rayon de 5 km',
'Amenity distance': 'Distance aux commodités proches',
'Amenities within 2km': 'Nombre de commodités proches dans un rayon de 2 km',
'Amenities within 5km': 'Nombre de commodités proches dans un rayon de 5 km',
'Political vote share': 'Part des voix par parti aux élections générales de 2024',
},
de: {
@ -189,16 +188,15 @@ const descriptions: Record<string, Record<string, string>> = {
'% Green': 'Stimmenanteil der Grünen bei der Parlamentswahl 2024',
'% Other parties': 'Kombinierter Stimmenanteil aller anderen Parteien und Unabhängigen',
'Distance to nearest park (km)': 'Entfernung zum nächsten Park oder Grünfläche',
'Number of parks within 1km': 'Anzahl Parks und Grünflächen im Umkreis von 1 km',
'Noise (dB)': 'Straßenlärmpegel an der Postleitzahl in Dezibel (Lden)',
'Max available download speed (Mbps)':
'Maximal verfügbare Breitband-Downloadgeschwindigkeit an der Postleitzahl',
Schools: 'Bewertete Grundschulen und weiterführende Schulen in der Nähe',
'Specific crimes': 'Jeweils eine Straßenkriminalitätskategorie filtern',
Ethnicities: 'Bevölkerungsanteil nach ethnischer Gruppe',
'POI distance': 'Entfernung zu nahe gelegenen Points of Interest',
'POIs within 2km': 'Anzahl nahe gelegener Points of Interest im Umkreis von 2 km',
'POIs within 5km': 'Anzahl nahe gelegener Points of Interest im Umkreis von 5 km',
'Amenity distance': 'Entfernung zu nahe gelegener Infrastruktur',
'Amenities within 2km': 'Anzahl nahe gelegener Infrastrukturangebote im Umkreis von 2 km',
'Amenities within 5km': 'Anzahl nahe gelegener Infrastrukturangebote im Umkreis von 5 km',
'Political vote share': 'Stimmenanteil nach Partei bei der Parlamentswahl 2024',
},
zh: {
@ -264,15 +262,14 @@ const descriptions: Record<string, Record<string, string>> = {
'% Green': '2024年大选中绿党得票率',
'% Other parties': '所有其他政党和独立候选人的综合得票率',
'Distance to nearest park (km)': '到最近公园或绿地的距离',
'Number of parks within 1km': '1公里内公园和绿地数量',
'Noise (dB)': '该邮编的道路噪音水平分贝Lden',
'Max available download speed (Mbps)': '该邮编可用的最大宽带下载速度',
Schools: '附近有评级的小学和中学',
'Specific crimes': '一次筛选一种街面犯罪类别',
Ethnicities: '按族裔群体划分的人口比例',
'POI distance': '到附近兴趣点的距离',
'POIs within 2km': '2公里内附近兴趣点数量',
'POIs within 5km': '5公里内附近兴趣点数量',
'Amenity distance': '到附近配套设施的距离',
'Amenities within 2km': '2公里内附近配套设施数量',
'Amenities within 5km': '5公里内附近配套设施数量',
'Political vote share': '2024年大选中各政党的得票份额',
},
hi: {
@ -348,15 +345,14 @@ const descriptions: Record<string, Record<string, string>> = {
'% Green': '2024 आम चुनाव में ग्रीन पार्टी का मत-प्रतिशत',
'% Other parties': 'बाकी सभी पार्टियों और निर्दलीयों का संयुक्त मत-प्रतिशत',
'Distance to nearest park (km)': 'निकटतम पार्क या हरित क्षेत्र तक दूरी',
'Number of parks within 1km': '1 किमी के भीतर पार्कों और हरित क्षेत्रों की संख्या',
'Noise (dB)': 'पोस्टकोड पर सड़क शोर स्तर, डेसीबल (Lden) में',
'Max available download speed (Mbps)': 'पोस्टकोड पर उपलब्ध अधिकतम डाउनलोड गति',
Schools: 'पास के रेटेड प्राइमरी और सेकेंडरी स्कूल',
'Specific crimes': 'एक समय में एक सड़क-स्तर अपराध श्रेणी से फिल्टर करें',
Ethnicities: 'जातीय समूह के अनुसार आबादी का प्रतिशत',
'POI distance': 'पास के रुचि-स्थलों तक दूरी',
'POIs within 2km': '2 किमी के अंदर पास के रुचि-स्थलों की संख्या',
'POIs within 5km': '5 किमी के अंदर पास के रुचि-स्थलों की संख्या',
'Amenity distance': 'पास की सुविधाओं तक दूरी',
'Amenities within 2km': '2 किमी के अंदर पास की सुविधाओं की संख्या',
'Amenities within 5km': '5 किमी के अंदर पास की सुविधाओं की संख्या',
'Political vote share': '2024 आम चुनाव में पार्टी के अनुसार मत-प्रतिशत',
},
hu: {
@ -437,16 +433,15 @@ const descriptions: Record<string, Record<string, string>> = {
'% Green': 'A Zöld Párt szavazataránya a 2024-es parlamenti választáson',
'% Other parties': 'Az összes többi párt és független jelölt összesített szavazataránya',
'Distance to nearest park (km)': 'Távolság a legközelebbi parkig vagy zöldterületig',
'Number of parks within 1km': 'Parkok és zöldterületek száma 1 km-en belül',
'Noise (dB)': 'Közúti zajszint az irányítószámnál decibelben (Lden)',
'Max available download speed (Mbps)':
'Az irányítószámnál elérhető maximális szélessávú letöltési sebesség',
Schools: 'Közeli minősített általános és középiskolák',
'Specific crimes': 'Egy-egy utcai bűncselekmény-kategória szűrése',
Ethnicities: 'Népességi arány etnikai csoport szerint',
'POI distance': 'Távolság a közeli érdekes pontokig',
'POIs within 2km': 'Közeli érdekes pontok száma 2 km-en belül',
'POIs within 5km': 'Közeli érdekes pontok száma 5 km-en belül',
'Amenity distance': 'Távolság a közeli szolgáltatásokig',
'Amenities within 2km': 'Közeli szolgáltatások száma 2 km-en belül',
'Amenities within 5km': 'Közeli szolgáltatások száma 5 km-en belül',
'Political vote share': 'Pártonkénti szavazatarány a 2024-es parlamenti választáson',
},
};

View file

@ -129,8 +129,6 @@ export const details: Record<string, Record<string, string>> = {
'Pourcentage des votes valides exprimés pour des partis autres que Travailliste, Conservateur, Libéral-démocrate, Reform UK et Vert dans la circonscription couvrant ce code postal. Comprend les indépendants, le Président de la Chambre et les partis mineurs.',
'Distance to nearest park (km)':
"Distance à vol d'oiseau en kilomètres depuis le code postal jusqu'à l'entrée du parc la plus proche. Couvre les parcs publics, jardins, terrains de jeux et espaces de loisirs. Utilise les emplacements des points d'accès issus du jeu de données OS Open Greenspace, de sorte que les propriétés bordant un grand parc affichent correctement une courte distance.",
'Number of parks within 1km':
'Nombre de parcs publics, jardins, terrains de jeux et espaces de loisirs dont au moins une entrée se trouve dans un rayon de 1 km du centroïde du code postal de la propriété. Dérivé du jeu de données OS Open Greenspace (Ordnance Survey), utilisant les emplacements des entrées de parcs pour une correspondance de proximité précise.',
'Noise (dB)':
"Niveau de bruit routier en décibels (Lden, moyenne pondérée sur 24 heures) provenant de la cartographie stratégique du bruit de Defra, 4e cycle (2022). Modélisé à 4m au-dessus du sol sur une grille de 10m. Au-dessus d'environ 55 dB, le bruit est généralement perceptible ; au-dessus d'environ 70 dB, il est considéré comme nocif par l'OMS.",
'Max available download speed (Mbps)':
@ -141,12 +139,12 @@ export const details: Record<string, Record<string, string>> = {
"Filtre une catégorie de criminalité de rue à la fois, en utilisant la moyenne annuelle des infractions dans le LSOA. Les valeurs proviennent des données police.uk 2023-2025 et permettent d'isoler des catégories comme cambriolage, véhicules ou comportement antisocial.",
Ethnicities:
"Filtre le pourcentage de population appartenant à un groupe ethnique sélectionné, d'après le Census 2021. Une seule catégorie est appliquée à la fois afin de comparer la composition locale entre les secteurs.",
'POI distance':
"Filtre la distance au point d'intérêt le plus proche du type choisi, calculée depuis le centroïde du code postal. Utilise les points d'intérêt OpenStreetMap pour comparer l'accès local aux services et équipements.",
'POIs within 2km':
"Filtre le nombre de points d'intérêt du type choisi dans un rayon de 2 km autour du code postal. Utile pour comparer les équipements accessibles à pied ou à courte distance.",
'POIs within 5km':
"Filtre le nombre de points d'intérêt du type choisi dans un rayon de 5 km autour du code postal. Utile pour comparer l'offre plus large de services, commerces et équipements autour d'un secteur.",
'Amenity distance':
"Filtre la distance à la commodité la plus proche du type choisi, calculée depuis le centroïde du code postal. Utilise les données OpenStreetMap pour comparer l'accès local aux services et équipements.",
'Amenities within 2km':
'Filtre le nombre de commodités du type choisi dans un rayon de 2 km autour du code postal. Utile pour comparer les équipements accessibles à pied ou à courte distance.',
'Amenities within 5km':
"Filtre le nombre de commodités du type choisi dans un rayon de 5 km autour du code postal. Utile pour comparer l'offre plus large de services, commerces et équipements autour d'un secteur.",
'Political vote share':
'Filtre le pourcentage de votes obtenu par un parti sélectionné dans la circonscription couvrant chaque code postal, lors des élections générales britanniques de juillet 2024.',
},
@ -275,8 +273,6 @@ export const details: Record<string, Record<string, string>> = {
'Prozentsatz der gültigen Stimmen für Parteien außer Labour, Conservative, Liberal Democrat, Reform UK und Green im Wahlkreis. Umfasst Unabhängige, den Speaker und kleinere Parteien.',
'Distance to nearest park (km)':
'Luftlinienentfernung in Kilometern vom Postleitzahlenzentrum zum nächsten Parkeingang. Umfasst öffentliche Parks, Gärten, Sportplätze und Spielbereiche. Verwendet Zugangspunktstandorte aus dem OS Open Greenspace-Datensatz, sodass Immobilien an der Grenze eines großen Parks korrekt eine kurze Entfernung anzeigen.',
'Number of parks within 1km':
'Anzahl öffentlicher Parks, Gärten, Sportplätze und Spielbereiche mit mindestens einem Eingang innerhalb eines 1-km-Radius um den Postleitzahlenmittelpunkt der Immobilie. Abgeleitet aus dem OS Open Greenspace-Datensatz (Ordnance Survey) unter Verwendung von Parkeingangsstandorten für genaues Abstandsmatching.',
'Noise (dB)':
'Straßenlärmpegel in Dezibel (Lden, ein 24-Stunden-gewichteter Durchschnitt) aus Defras Strategic Noise Mapping Round 4 (2022). Modelliert in 4 m Höhe über dem Boden auf einem 10-m-Raster. Über ~55 dB ist in der Regel wahrnehmbar; über ~70 dB gilt laut WHO als gesundheitsschädlich.',
'Max available download speed (Mbps)':
@ -287,12 +283,12 @@ export const details: Record<string, Record<string, string>> = {
'Filtert jeweils eine Kategorie der Straßenkriminalität anhand der durchschnittlichen jährlichen Vorfälle im LSOA. Die Werte stammen aus police.uk-Daten 2023-2025 und helfen, Kategorien wie Einbruch, Fahrzeugkriminalität oder asoziales Verhalten getrennt zu betrachten.',
Ethnicities:
'Filtert den Bevölkerungsanteil einer ausgewählten ethnischen Gruppe auf Basis des Census 2021. Es wird jeweils eine Kategorie angewendet, damit die lokale Zusammensetzung zwischen Gebieten vergleichbar bleibt.',
'POI distance':
'Filtert die Entfernung zum nächsten Point of Interest des gewählten Typs, berechnet vom Postleitzahlenmittelpunkt. Verwendet OpenStreetMap-POIs, um den lokalen Zugang zu Diensten und Einrichtungen zu vergleichen.',
'POIs within 2km':
'Filtert die Anzahl der Points of Interest des gewählten Typs innerhalb von 2 km um die Postleitzahl. Nützlich zum Vergleich von Einrichtungen, die zu Fuß oder in kurzer Entfernung erreichbar sind.',
'POIs within 5km':
'Filtert die Anzahl der Points of Interest des gewählten Typs innerhalb von 5 km um die Postleitzahl. Nützlich zum Vergleich des breiteren Angebots an Diensten, Geschäften und Einrichtungen in einem Gebiet.',
'Amenity distance':
'Filtert die Entfernung zur nächsten Infrastruktur des gewählten Typs, berechnet vom Postleitzahlenmittelpunkt. Verwendet OpenStreetMap-Daten, um den lokalen Zugang zu Diensten und Einrichtungen zu vergleichen.',
'Amenities within 2km':
'Filtert die Anzahl der Infrastrukturangebote des gewählten Typs innerhalb von 2 km um die Postleitzahl. Nützlich zum Vergleich von Einrichtungen, die zu Fuß oder in kurzer Entfernung erreichbar sind.',
'Amenities within 5km':
'Filtert die Anzahl der Infrastrukturangebote des gewählten Typs innerhalb von 5 km um die Postleitzahl. Nützlich zum Vergleich des breiteren Angebots an Diensten, Geschäften und Einrichtungen in einem Gebiet.',
'Political vote share':
'Filtert den Stimmenanteil einer ausgewählten Partei in dem Wahlkreis, der die jeweilige Postleitzahl abdeckt, bei der britischen Parlamentswahl im Juli 2024.',
},
@ -415,8 +411,6 @@ export const details: Record<string, Record<string, string>> = {
'该选区中投给工党、保守党、自由民主党、英国改革党和绿党以外政党的有效选票百分比。包括独立候选人、议长和小型政党。',
'Distance to nearest park (km)':
'从邮政编码到最近公园入口的直线距离km。涵盖公共公园、花园、运动场和游乐场地。使用 OS Open Greenspace 数据集中的出入口位置,因此紧邻大型公园的房产可正确显示较短距离。',
'Number of parks within 1km':
'以房产邮政编码中心点为圆心1km半径内至少有一个入口的公共公园、花园、运动场和游乐场地数量。来源于OS Open Greenspace数据集英国地形测量局使用公园入口位置进行精确近距离匹配。',
'Noise (dB)':
'来自Defra战略噪声图第4轮2022年的道路噪声水平单位为分贝Lden24小时加权平均值。在地面以上4m、10m网格间距处建模。一般而言超过约55 dB可明显感知超过约70 dB被世卫组织认定为有害。',
'Max available download speed (Mbps)':
@ -427,12 +421,12 @@ export const details: Record<string, Record<string, string>> = {
'一次筛选一种街面犯罪类别使用LSOA内的年均案件数。数值来自2023-2025年的police.uk数据可单独查看入室盗窃、车辆犯罪或反社会行为等类别。',
Ethnicities:
'根据2021年人口普查筛选所选族裔群体占人口的百分比。每次应用一个类别便于比较不同地区的本地人口构成。',
'POI distance':
'筛选到所选类型最近兴趣点的距离从邮政编码中心点计算。使用OpenStreetMap兴趣点,用于比较本地服务和设施的可达性。',
'POIs within 2km':
'筛选邮政编码周围2公里内所选类型兴趣点的数量。适合比较步行或短距离可达的设施。',
'POIs within 5km':
'筛选邮政编码周围5公里内所选类型兴趣点的数量。适合比较某一区域周边更广泛的服务、商店和设施供给。',
'Amenity distance':
'筛选到所选类型最近配套设施的距离从邮政编码中心点计算。使用OpenStreetMap数据,用于比较本地服务和设施的可达性。',
'Amenities within 2km':
'筛选邮政编码周围2公里内所选类型配套设施的数量。适合比较步行或短距离可达的设施。',
'Amenities within 5km':
'筛选邮政编码周围5公里内所选类型配套设施的数量。适合比较某一区域周边更广泛的服务、商店和设施供给。',
'Political vote share':
'筛选在覆盖每个邮政编码的选区中所选政党在2024年7月英国大选获得的得票百分比。',
},
@ -561,8 +555,6 @@ export const details: Record<string, Record<string, string>> = {
'इस पोस्टकोड के निर्वाचन क्षेत्र में Labour, Conservative, Liberal Democrat, Reform UK और Green के अलावा अन्य पार्टियों को पड़े वैध मतों का प्रतिशत. इसमें निर्दलीय, स्पीकर और छोटे दल शामिल हैं.',
'Distance to nearest park (km)':
'पोस्टकोड से निकटतम पार्क प्रवेश तक सीधी रेखा में दूरी, किलोमीटर में. इसमें सार्वजनिक पार्क, बगीचे, खेल मैदान और खेल स्थान शामिल हैं. OS Open Greenspace डेटा सेट के प्रवेश-बिंदु स्थानों का उपयोग करता है, इसलिए बड़े पार्क के किनारे स्थित संपत्तियां सही कम दूरी दिखाती हैं.',
'Number of parks within 1km':
'संपत्ति के पोस्टकोड केंद्र से 1 km दायरे में कम से कम एक प्रवेश रखने वाले सार्वजनिक पार्कों, बगीचों, खेल मैदानों और खेल स्थानों की संख्या. OS Open Greenspace डेटा सेट (Ordnance Survey) से निकाला गया, और सटीक निकटता मिलान के लिए पार्क प्रवेश स्थानों का उपयोग करता है.',
'Noise (dB)':
'Defra Strategic Noise Mapping Round 4 (2022) से सड़क-शोर स्तर, डेसीबल में (Lden, 24-घंटे भारित औसत). जमीन से 4 m ऊपर 10 m ग्रिड पर मॉडल किया गया. लगभग 55 dB से ऊपर शोर आमतौर पर महसूस होता है; लगभग 70 dB से ऊपर WHO इसे हानिकारक मानता है.',
'Max available download speed (Mbps)':
@ -573,12 +565,12 @@ export const details: Record<string, Record<string, string>> = {
'LSOA में सालाना औसत घटनाओं के आधार पर एक समय में एक सड़क-स्तर अपराध श्रेणी फिल्टर करता है. मान 2023-2025 के police.uk डेटा से आते हैं और चोरी, वाहन अपराध या असामाजिक व्यवहार जैसी श्रेणियों को अलग से देखने में मदद करते हैं.',
Ethnicities:
'Census 2021 के आधार पर चुने गए जातीय समूह की आबादी का प्रतिशत फिल्टर करता है. अलग-अलग क्षेत्रों की स्थानीय संरचना की तुलना के लिए एक समय में एक श्रेणी लागू होती है.',
'POI distance':
'चुने गए प्रकार के सबसे नजदीकी रुचि-स्थल तक दूरी फिल्टर करता है, जो पोस्टकोड केंद्र से निकाली जाती है. स्थानीय सेवाओं और सुविधाओं तक पहुंच की तुलना के लिए OpenStreetMap रुचि-स्थलों का उपयोग करता है.',
'POIs within 2km':
'पोस्टकोड के 2 किमी दायरे में चुने गए प्रकार के रुचि-स्थलों की संख्या फिल्टर करता है. पैदल या कम दूरी में पहुंच योग्य सुविधाओं की तुलना के लिए उपयोगी.',
'POIs within 5km':
'पोस्टकोड के 5 किमी दायरे में चुने गए प्रकार के रुचि-स्थलों की संख्या फिल्टर करता है. किसी क्षेत्र के आसपास सेवाओं, दुकानों और सुविधाओं की व्यापक उपलब्धता की तुलना के लिए उपयोगी.',
'Amenity distance':
'चुने गए प्रकार की सबसे नजदीकी सुविधा तक दूरी फिल्टर करता है, जो पोस्टकोड केंद्र से निकाली जाती है. स्थानीय सेवाओं और सुविधाओं तक पहुंच की तुलना के लिए OpenStreetMap डेटा का उपयोग करता है.',
'Amenities within 2km':
'पोस्टकोड के 2 किमी दायरे में चुने गए प्रकार की सुविधाओं की संख्या फिल्टर करता है. पैदल या कम दूरी में पहुंच योग्य सुविधाओं की तुलना के लिए उपयोगी.',
'Amenities within 5km':
'पोस्टकोड के 5 किमी दायरे में चुने गए प्रकार की सुविधाओं की संख्या फिल्टर करता है. किसी क्षेत्र के आसपास सेवाओं, दुकानों और सुविधाओं की व्यापक उपलब्धता की तुलना के लिए उपयोगी.',
'Political vote share':
'हर पोस्टकोड को कवर करने वाले निर्वाचन क्षेत्र में, जुलाई 2024 के ब्रिटेन के आम चुनाव में चुनी गई पार्टी को मिले वोटों का प्रतिशत फिल्टर करता है.',
},
@ -707,8 +699,6 @@ export const details: Record<string, Record<string, string>> = {
'Az érvényes szavazatok százaléka, amelyeket a Munkáspárton, Konzervatívokon, Liberális Demokratákon, Reform UK-n és Zöldeken kívüli pártokra adtak le. Tartalmazza a függetleneket, a Házelnököt és a kisebb pártokat.',
'Distance to nearest park (km)':
'Légvonalbeli távolság kilométerben az irányítószámtól a legközelebbi park bejáratáig. Magában foglalja a közparkokat, kerteket, játszótereket és szabadidős területeket. Az OS Open Greenspace adatkészlet hozzáférési pont helyszíneit használja, így a nagy park szomszédságában lévő ingatlanok helyesen rövid távolságot mutatnak.',
'Number of parks within 1km':
'A közparkok, kertek, játszóterek és szabadidős területek száma, amelyeknek legalább egy bejárata van az ingatlan irányítószámának középpontjától számított 1 km-es körzetben. Az OS Open Greenspace adatkészletből (Ordnance Survey) származik, park bejárati helyszíneket használva a pontos közelségi egyeztetéshez.',
'Noise (dB)':
'Közúti zajszint decibel (Lden, 24 órás súlyozott átlag) értékben, a Defra Stratégiai Zajtérképezés 4. fordulójából (2022). 4 m magasságban, 10 m-es rácson modellezve. ~55 dB felett általában érzékelhető; ~70 dB felett a WHO károsnak minősíti.',
'Max available download speed (Mbps)':
@ -719,12 +709,12 @@ export const details: Record<string, Record<string, string>> = {
'Egyszerre egy utcai bűncselekmény-kategóriát szűr az LSOA éves átlagos esetszámai alapján. Az értékek a 2023-2025-ös police.uk adatokból származnak, és segítenek külön vizsgálni például a betörést, járműbűnözést vagy antiszociális viselkedést.',
Ethnicities:
'A kiválasztott etnikai csoport népességi arányát szűri a 2021-es népszámlálás alapján. Egyszerre egy kategória alkalmazható, hogy a helyi összetétel összehasonlítható legyen a területek között.',
'POI distance':
'A kiválasztott típusú legközelebbi érdekes pont távolságát szűri, az irányítószám középpontjától számítva. OpenStreetMap POI-adatokat használ a helyi szolgáltatások és létesítmények elérhetőségének összehasonlításához.',
'POIs within 2km':
'A kiválasztott típusú érdekes pontok számát szűri az irányítószám 2 km-es körzetében. Hasznos a gyalogosan vagy rövid úton elérhető létesítmények összehasonlításához.',
'POIs within 5km':
'A kiválasztott típusú érdekes pontok számát szűri az irányítószám 5 km-es körzetében. Hasznos a környék szélesebb szolgáltatás-, üzlet- és létesítménykínálatának összehasonlításához.',
'Amenity distance':
'A kiválasztott típusú legközelebbi szolgáltatás távolságát szűri, az irányítószám középpontjától számítva. OpenStreetMap-adatokat használ a helyi szolgáltatások és létesítmények elérhetőségének összehasonlításához.',
'Amenities within 2km':
'A kiválasztott típusú szolgáltatások számát szűri az irányítószám 2 km-es körzetében. Hasznos a gyalogosan vagy rövid úton elérhető létesítmények összehasonlításához.',
'Amenities within 5km':
'A kiválasztott típusú szolgáltatások számát szűri az irányítószám 5 km-es körzetében. Hasznos a környék szélesebb szolgáltatás-, üzlet- és létesítménykínálatának összehasonlításához.',
'Political vote share':
'A kiválasztott párt szavazatarányát szűri az egyes irányítószámokat lefedő választókerületben, a 2024. júliusi brit parlamenti választás alapján.',
},

View file

@ -896,6 +896,7 @@ const de: Translations = {
server: {
// ─ Feature group names ─
Properties: 'Immobilien',
'Property prices': 'Immobilienpreise',
Transport: 'Verkehr',
Education: 'Bildung',
'Area development': 'Gebietsentwicklung',
@ -987,7 +988,6 @@ const de: Translations = {
// ─ Feature names (Amenities) ─
'Distance to nearest park (km)': 'Entfernung zum nächsten Park (km)',
'Number of parks within 1km': 'Anzahl Parks im Umkreis von 1 km',
'Noise (dB)': 'Lärm (dB)',
'Max available download speed (Mbps)': 'Max. verfügbare Downloadgeschwindigkeit (Mbps)',

View file

@ -609,6 +609,10 @@ const en = {
dsGreenspaceOrigin: 'Ordnance Survey',
dsGreenspaceUse:
'Authoritative green space boundaries for Great Britain, including public parks, gardens, playing fields, and play spaces. Polygon centroids are used for park proximity counts and distance-to-nearest-park calculations.',
dsTowName: 'National Trees Outside Woodland Map',
dsTowOrigin: 'Forest Research / Defra NCEA',
dsTowUse:
'Tree canopy polygons for lone trees, groups of trees, and small woodlands in England. Used here to estimate street-level tree density around property addresses.',
dsNaptanName: 'NaPTAN (Public Transport Stops)',
dsNaptanOrigin: 'Department for Transport',
dsNaptanUse:
@ -880,6 +884,7 @@ const en = {
server: {
// ─ Feature group names ─
Properties: 'Properties',
'Property prices': 'Property prices',
Transport: 'Transport',
Education: 'Education',
'Area development': 'Area development',
@ -967,7 +972,6 @@ const en = {
// ─ Feature names (Amenities) ─
'Distance to nearest park (km)': 'Distance to nearest park (km)',
'Number of parks within 1km': 'Number of parks within 1km',
'Noise (dB)': 'Noise (dB)',
'Max available download speed (Mbps)': 'Max available download speed (Mbps)',

View file

@ -899,6 +899,7 @@ const fr: Translations = {
server: {
// ─ Feature group names ─
Properties: 'Propriétés',
'Property prices': 'Prix immobiliers',
Transport: 'Transports',
Education: 'Éducation',
'Area development': 'Développement du quartier',
@ -986,7 +987,6 @@ const fr: Translations = {
// ─ Feature names (Amenities) ─
'Distance to nearest park (km)': 'Distance au parc le plus proche (km)',
'Number of parks within 1km': 'Nombre de parcs à moins de 1 km',
'Noise (dB)': 'Bruit (dB)',
'Max available download speed (Mbps)': 'Débit descendant max. disponible (Mbps)',

View file

@ -831,6 +831,7 @@ const hi: Translations = {
server: {
Properties: 'संपत्तियां',
'Property prices': 'संपत्ति कीमतें',
Transport: 'परिवहन',
Education: 'शिक्षा',
'Area development': 'क्षेत्र विकास',
@ -905,7 +906,6 @@ const hi: Translations = {
'% Green': '% ग्रीन',
'% Other parties': '% अन्य पार्टियां',
'Distance to nearest park (km)': 'निकटतम पार्क तक दूरी (किमी)',
'Number of parks within 1km': '1 किमी के अंदर पार्कों की संख्या',
'Noise (dB)': 'शोर (dB)',
'Max available download speed (Mbps)': 'अधिकतम उपलब्ध डाउनलोड स्पीड (Mbps)',
Schools: 'स्कूल',

View file

@ -891,6 +891,7 @@ const hu: Translations = {
server: {
// ─ Feature group names ─
Properties: 'Ingatlanok',
'Property prices': 'Ingatlanárak',
Transport: 'Közlekedés',
Education: 'Oktatás',
'Area development': 'Területi fejlődés',
@ -978,7 +979,6 @@ const hu: Translations = {
// ─ Feature names (Amenities) ─
'Distance to nearest park (km)': 'Távolság a legközelebbi parktól (km)',
'Number of parks within 1km': 'Parkok száma 1 km-en belül',
'Noise (dB)': 'Zaj (dB)',
'Max available download speed (Mbps)': 'Max elérhető letöltési sebesség (Mbps)',

View file

@ -861,6 +861,7 @@ const zh: Translations = {
server: {
// ─ Feature group names ─
Properties: '房产',
'Property prices': '房价',
Transport: '交通',
Education: '教育',
'Area development': '区域发展',
@ -947,7 +948,6 @@ const zh: Translations = {
// ─ Feature names (Amenities) ─
'Distance to nearest park (km)': '到最近公园的距离(公里)',
'Number of parks within 1km': '1公里内公园数量',
'Noise (dB)': '噪音(分贝)',
'Max available download speed (Mbps)': '最大可用下载速度Mbps',

View file

@ -138,7 +138,7 @@ describe('api utilities', () => {
).toBe('% White:20:80');
});
it('serializes POI distance filters using their selected backend feature', () => {
it('serializes amenity distance filters using their selected backend feature', () => {
const features: FeatureMeta[] = [
{ name: 'Distance to nearest park (km)', type: 'numeric', min: 0, max: 2 },
{ name: 'Distance to nearest Tesco (km)', type: 'numeric', min: 0, max: 5 },
@ -155,20 +155,22 @@ describe('api utilities', () => {
).toBe('Distance to nearest park (km):0:0.5;;Distance to nearest Tesco (km):0:1');
});
it('serializes POI count filters using their selected backend feature', () => {
it('serializes amenity count filters using their selected backend feature', () => {
const features: FeatureMeta[] = [
{ name: 'Number of Cafe POIs within 2km', type: 'numeric', min: 0, max: 20 },
{ name: 'Number of amenities (Cafe) within 2km', type: 'numeric', min: 0, max: 20 },
];
expect(
buildFilterString(
{
[createPoiFilterKey(POI_COUNT_2KM_FILTER_NAME, 'Number of Cafe POIs within 2km', 1)]: [
2, 10,
],
[createPoiFilterKey(
POI_COUNT_2KM_FILTER_NAME,
'Number of amenities (Cafe) within 2km',
1
)]: [2, 10],
},
features
)
).toBe('Number of Cafe POIs within 2km:2:10');
).toBe('Number of amenities (Cafe) within 2km:2:10');
});
});

View file

@ -102,6 +102,15 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</>
),
'Street tree density (%)': (
<>
<path d="M12 22V12" />
<path d="M6 22h12" />
<path d="M12 12c-3 0-5-2-5-5 0-2.8 2.2-5 5-5s5 2.2 5 5c0 3-2 5-5 5z" />
<path d="M9 11c-2.5 0-4.5 1.7-4.5 4 0 2.1 1.7 3.5 4 3.5" />
<path d="M15 11c2.5 0 4.5 1.7 4.5 4 0 2.1-1.7 3.5-4 3.5" />
</>
),
// ── Transport ────────────────────────────────
'Travel time to nearest train or tube station (min)': (
@ -176,7 +185,7 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
</>
),
// ── Area characteristics ─────────────────────
// ── Area development ─────────────────────────
'Income Score': (
<>
<rect x="2" y="6" width="20" height="14" rx="2" />
@ -392,14 +401,6 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
</>
),
// ── Amenities ────────────────────────────────
'Number of parks within 1km': (
<>
<path d="M12 22v-7" />
<path d="M17 15H7l2-4H5l7-9 7 9h-4l2 4z" />
</>
),
// ── Environment ──────────────────────────────
'Noise (dB)': (
<>

View file

@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import type { FeatureGroup, FeatureMeta } from '../types';
import { groupFeaturesByCategory, orderFilterGroups } from './features';
function group(name: string): FeatureGroup {
return { name, features: [] };
}
function feature(name: string, groupName: string): FeatureMeta {
return { name, group: groupName, type: 'numeric' };
}
describe('feature grouping utilities', () => {
it('orders filter groups around transport, property, amenities, and area development', () => {
const groups = [
group('Properties'),
group('Education'),
group('Area development'),
group('Property prices'),
group('Crime'),
group('Amenities'),
group('Transport'),
];
expect(orderFilterGroups(groups).map((item) => item.name)).toEqual([
'Transport',
'Property prices',
'Properties',
'Amenities',
'Education',
'Crime',
'Area development',
]);
});
it('keeps feature order inside grouped categories', () => {
const groups = groupFeaturesByCategory([
feature('A', 'Crime'),
feature('B', 'Crime'),
feature('C', 'Properties'),
]);
expect(groups).toEqual([
{ name: 'Crime', features: [feature('A', 'Crime'), feature('B', 'Crime')] },
{ name: 'Properties', features: [feature('C', 'Properties')] },
]);
});
});

View file

@ -7,19 +7,18 @@ import {
ShieldIcon,
UsersIcon,
ShoppingBagIcon,
MapPinIcon,
TreeIcon,
TagIcon,
} from '../components/ui/icons';
const GROUP_ICONS: Record<string, ComponentType<{ className?: string }>> = {
Properties: HouseIcon,
'Property prices': TagIcon,
Transport: RouteIcon,
Education: GraduationCapIcon,
'Area characteristics': ChartBarIcon,
'Area development': ChartBarIcon,
Crime: ShieldIcon,
Neighbours: UsersIcon,
'Nearby POIs': MapPinIcon,
Amenities: ShoppingBagIcon,
Environment: TreeIcon,
Property: TagIcon,

View file

@ -1,8 +1,8 @@
import type { FeatureFilters, FeatureMeta } from '../types';
export const POI_DISTANCE_FILTER_NAME = 'POI distance';
export const POI_COUNT_2KM_FILTER_NAME = 'POIs within 2km';
export const POI_COUNT_5KM_FILTER_NAME = 'POIs within 5km';
export const POI_DISTANCE_FILTER_NAME = 'Amenity distance';
export const POI_COUNT_2KM_FILTER_NAME = 'Amenities within 2km';
export const POI_COUNT_5KM_FILTER_NAME = 'Amenities within 5km';
export const POI_FILTER_NAMES = [
POI_DISTANCE_FILTER_NAME,
@ -27,14 +27,14 @@ export const POI_DISTANCE_FEATURE_NAMES = [
'Distance to nearest restaurant (km)',
] as const;
const LEGACY_POI_DISTANCE_FEATURE_NAME_SET = new Set<string>(POI_DISTANCE_FEATURE_NAMES);
const LEGACY_POI_DISTANCE_AGGREGATE_OPTIONS = [
const STATIC_AMENITY_DISTANCE_FEATURE_NAME_SET = new Set<string>(POI_DISTANCE_FEATURE_NAMES);
const STATIC_AMENITY_DISTANCE_AGGREGATE_OPTIONS = [
'Distance to nearest park (km)',
'Distance to nearest grocery store (km)',
] as const;
const DYNAMIC_DISTANCE_RE = /^Distance to nearest (.+) POI \(km\)$/;
const DYNAMIC_COUNT_RE = /^Number of (.+) POIs within (2|5)km$/;
const DYNAMIC_DISTANCE_RE = /^Distance to nearest amenity \((.+)\) \(km\)$/;
const DYNAMIC_COUNT_RE = /^Number of amenities \((.+)\) within (2|5)km$/;
const POI_FILTER_CONFIGS: Record<
PoiFilterName,
@ -51,8 +51,8 @@ const POI_FILTER_CONFIGS: Record<
[POI_DISTANCE_FILTER_NAME]: {
metric: 'distance',
keyPrefix: POI_DISTANCE_FILTER_KEY_PREFIX,
description: 'Distance to nearby points of interest',
detail: 'Filter by distance to one nearby point-of-interest type at a time.',
description: 'Distance to nearby amenities',
detail: 'Filter by distance to one nearby amenity type at a time.',
defaultMax: 5,
step: 0.1,
suffix: ' km',
@ -60,8 +60,8 @@ const POI_FILTER_CONFIGS: Record<
[POI_COUNT_2KM_FILTER_NAME]: {
metric: 'count_2km',
keyPrefix: `${POI_COUNT_2KM_FILTER_NAME}:`,
description: 'Number of nearby points of interest within 2km',
detail: 'Filter by the count of one point-of-interest type within 2km.',
description: 'Number of nearby amenities within 2km',
detail: 'Filter by the count of one amenity type within 2km.',
defaultMax: 20,
step: 1,
suffix: '',
@ -69,8 +69,8 @@ const POI_FILTER_CONFIGS: Record<
[POI_COUNT_5KM_FILTER_NAME]: {
metric: 'count_5km',
keyPrefix: `${POI_COUNT_5KM_FILTER_NAME}:`,
description: 'Number of nearby points of interest within 5km',
detail: 'Filter by the count of one point-of-interest type within 5km.',
description: 'Number of nearby amenities within 5km',
detail: 'Filter by the count of one amenity type within 5km.',
defaultMax: 50,
step: 1,
suffix: '',
@ -86,7 +86,10 @@ function isDynamicPoiDistanceFeatureName(name: string): boolean {
}
function getPoiMetric(name: string): PoiMetric | null {
if (isDynamicPoiDistanceFeatureName(name) || LEGACY_POI_DISTANCE_FEATURE_NAME_SET.has(name)) {
if (
isDynamicPoiDistanceFeatureName(name) ||
STATIC_AMENITY_DISTANCE_FEATURE_NAME_SET.has(name)
) {
return 'distance';
}
@ -112,7 +115,9 @@ export function getPoiFeatureCategory(name: string): string | null {
}
export function isPoiDistanceFeatureName(name: string): boolean {
return isDynamicPoiDistanceFeatureName(name) || LEGACY_POI_DISTANCE_FEATURE_NAME_SET.has(name);
return (
isDynamicPoiDistanceFeatureName(name) || STATIC_AMENITY_DISTANCE_FEATURE_NAME_SET.has(name)
);
}
export function isPoiFilterFeatureName(name: string): boolean {
@ -202,7 +207,7 @@ export function getPoiFilterFeatureOptions(
});
if (dynamicOptions.length > 0 && metric === 'distance') {
const aggregateOptions = LEGACY_POI_DISTANCE_AGGREGATE_OPTIONS.map((name) =>
const aggregateOptions = STATIC_AMENITY_DISTANCE_AGGREGATE_OPTIONS.map((name) =>
features.find((feature) => feature.name === name)
).filter((feature): feature is FeatureMeta => Boolean(feature));
return [...dynamicOptions, ...aggregateOptions];
@ -238,7 +243,7 @@ export function getPoiFilterMeta(features: FeatureMeta[], filterName: PoiFilterN
return {
name: filterName,
type: 'numeric',
group: 'Nearby POIs',
group: 'Amenities',
min: sourceFeature?.min ?? 0,
max: sourceFeature?.max ?? config.defaultMax,
step: config.step,

View file

@ -753,110 +753,3 @@ 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

@ -223,7 +223,7 @@ describe('url-state', () => {
});
});
it('round-trips repeated POI distance filters with dedicated URL params', () => {
it('round-trips repeated amenity distance filters with dedicated URL params', () => {
const park = createPoiDistanceFilterKey('Distance to nearest park (km)', 3);
const tesco = createPoiDistanceFilterKey('Distance to nearest Tesco (km)', 4);
@ -238,7 +238,7 @@ describe('url-state', () => {
'area'
);
expect(params.getAll('poiDistance')).toEqual([
expect(params.getAll('amenityDistance')).toEqual([
'Distance%20to%20nearest%20park%20(km):0:0.4',
'Distance%20to%20nearest%20Tesco%20(km):0:1.5',
]);
@ -253,10 +253,10 @@ describe('url-state', () => {
});
});
it('round-trips POI count filters with dedicated URL params', () => {
it('round-trips amenity count filters with dedicated URL params', () => {
const cafes = createPoiFilterKey(
POI_COUNT_2KM_FILTER_NAME,
'Number of Cafe POIs within 2km',
'Number of amenities (Cafe) within 2km',
3
);
@ -270,14 +270,17 @@ describe('url-state', () => {
'area'
);
expect(params.getAll('poiCount2km')).toEqual(['Number%20of%20Cafe%20POIs%20within%202km:2:8']);
expect(params.getAll('amenityCount2km')).toEqual([
'Number%20of%20amenities%20(Cafe)%20within%202km:2:8',
]);
expect(params.getAll('filter')).toEqual([]);
window.history.replaceState({}, '', `/?${params.toString()}`);
const state = parseUrlState();
expect(state.filters).toEqual({
[createPoiFilterKey(POI_COUNT_2KM_FILTER_NAME, 'Number of Cafe POIs within 2km', 0)]: [2, 8],
[createPoiFilterKey(POI_COUNT_2KM_FILTER_NAME, 'Number of amenities (Cafe) within 2km', 0)]:
[2, 8],
});
});

View file

@ -67,18 +67,18 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
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');
const poiCount5KmParams = params.getAll('poiCount5km');
const amenityDistanceParams = params.getAll('amenityDistance');
const amenityCount2KmParams = params.getAll('amenityCount2km');
const amenityCount5KmParams = params.getAll('amenityCount5km');
if (
filterParams.length === 0 &&
schoolParams.length === 0 &&
crimeParams.length === 0 &&
voteShareParams.length === 0 &&
ethnicityParams.length === 0 &&
poiDistanceParams.length === 0 &&
poiCount2KmParams.length === 0 &&
poiCount5KmParams.length === 0
amenityDistanceParams.length === 0 &&
amenityCount2KmParams.length === 0 &&
amenityCount5KmParams.length === 0
) {
return {};
}
@ -159,7 +159,7 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
filters[createEthnicityFilterKey(featureName, index)] = [min, max];
});
poiDistanceParams.forEach((entry, index) => {
amenityDistanceParams.forEach((entry, index) => {
const parts = entry.split(':');
if (parts.length < 3) return;
const featureName = decodeURIComponent(parts.slice(0, -2).join(':'));
@ -188,11 +188,15 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
filters[createPoiFilterKey(filterName, featureName, startIndex + index)] = [min, max];
});
};
parsePoiCountParams(poiCount2KmParams, POI_COUNT_2KM_FILTER_NAME, poiDistanceParams.length);
parsePoiCountParams(
poiCount5KmParams,
amenityCount2KmParams,
POI_COUNT_2KM_FILTER_NAME,
amenityDistanceParams.length
);
parsePoiCountParams(
amenityCount5KmParams,
POI_COUNT_5KM_FILTER_NAME,
poiDistanceParams.length + poiCount2KmParams.length
amenityDistanceParams.length + amenityCount2KmParams.length
);
return filters;
@ -336,17 +340,20 @@ export function stateToParams(
continue;
}
const poiDistanceFeatureName = getPoiDistanceFeatureName(name);
if (poiDistanceFeatureName && isPoiDistanceFilterName(name)) {
const amenityDistanceFeatureName = getPoiDistanceFeatureName(name);
if (amenityDistanceFeatureName && isPoiDistanceFilterName(name)) {
const [min, max] = value as [number, number];
const filterName = getPoiFilterName(name);
const paramName =
filterName === POI_COUNT_2KM_FILTER_NAME
? 'poiCount2km'
? 'amenityCount2km'
: filterName === POI_COUNT_5KM_FILTER_NAME
? 'poiCount5km'
: 'poiDistance';
params.append(paramName, `${encodeURIComponent(poiDistanceFeatureName)}:${min}:${max}`);
? 'amenityCount5km'
: 'amenityDistance';
params.append(
paramName,
`${encodeURIComponent(amenityDistanceFeatureName)}:${min}:${max}`
);
continue;
}
@ -402,18 +409,18 @@ export function summarizeParams(queryString: string): string {
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');
const poiCount5KmParams = params.getAll('poiCount5km');
const amenityDistanceParams = params.getAll('amenityDistance');
const amenityCount2KmParams = params.getAll('amenityCount2km');
const amenityCount5KmParams = params.getAll('amenityCount5km');
if (
filterParams.length > 0 ||
schoolParams.length > 0 ||
crimeParams.length > 0 ||
voteShareParams.length > 0 ||
ethnicityParams.length > 0 ||
poiDistanceParams.length > 0 ||
poiCount2KmParams.length > 0 ||
poiCount5KmParams.length > 0
amenityDistanceParams.length > 0 ||
amenityCount2KmParams.length > 0 ||
amenityCount5KmParams.length > 0
) {
const filterNames = filterParams
.map((entry) => {
@ -436,13 +443,13 @@ export function summarizeParams(queryString: string): string {
for (let i = 0; i < ethnicityParams.length; i++) {
filterNames.push(ETHNICITIES_FILTER_NAME);
}
for (let i = 0; i < poiDistanceParams.length; i++) {
for (let i = 0; i < amenityDistanceParams.length; i++) {
filterNames.push(POI_DISTANCE_FILTER_NAME);
}
for (let i = 0; i < poiCount2KmParams.length; i++) {
for (let i = 0; i < amenityCount2KmParams.length; i++) {
filterNames.push(POI_COUNT_2KM_FILTER_NAME);
}
for (let i = 0; i < poiCount5KmParams.length; i++) {
for (let i = 0; i < amenityCount5KmParams.length; i++) {
filterNames.push(POI_COUNT_5KM_FILTER_NAME);
}
if (filterNames.length > 0) {

View file

@ -156,7 +156,7 @@ def min_distance_per_postcode(
For each postcode, compute the distance (km) to the closest POI per group.
Returns NaN where no POI of that group exists.
"""
print("Computing minimum POI distances per postcode...")
print("Computing minimum distances per postcode...")
n_postcodes = len(postcodes_df)
n_pois = len(pois)

View file

@ -27,6 +27,7 @@ dependencies = [
"pyshp>=2.3.0",
"pillow>=12.0.0",
"folium>=0.20.0",
"pyogrio>=0.12.1",
"httpx",
"polars",
]

View file

@ -212,7 +212,7 @@ impl TravelTimeStore {
let mut map = FxHashMap::default();
map.reserve(df.height());
for (i, (pc, min)) in postcodes.into_iter().zip(minutes.into_iter()).enumerate() {
for (i, (pc, min)) in postcodes.into_iter().zip(minutes).enumerate() {
if let (Some(pc), Some(min)) = (pc, min) {
let best_min = best.as_ref().and_then(|b| b.get(i));
let journey = journeys.as_ref().and_then(|j| j.get(i)).map(Arc::from);

View file

@ -79,78 +79,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
detail: "From HM Land Registry Price Paid data. Freehold means you own the building and the land it stands on. Leasehold means you own the building but not the land: you have a lease from the freeholder for a set number of years.",
source: "price-paid",
}),
Feature::Numeric(FeatureConfig {
name: "Estimated current price",
bounds: Bounds::Fixed {
min: 0.0,
max: 2_500_000.0,
},
step: 10000.0,
description: "Modelled estimate of the current property value",
detail: "Based on the last sale price, local repeat-sales price movement, and nearby recently sold properties. The repeat-sales index is tracked by postcode sector and property type, with smoothing and neighbour blending where data is sparse. Recent sales stay close to the recorded price; older sales depend more on the model.",
source: "price-paid",
prefix: "£",
suffix: "",
raw: false,
absolute: true,
}),
Feature::Numeric(FeatureConfig {
name: "Last known price",
bounds: Bounds::Fixed {
min: 0.0,
max: 2_500_000.0,
},
step: 10000.0,
description: "Most recent sale price from the Land Registry",
detail: "The last recorded sale price for this property from HM Land Registry Price Paid data. Covers residential sales in England. May be years old if the property hasn't sold recently.",
source: "price-paid",
prefix: "£",
suffix: "",
raw: false,
absolute: true,
}),
Feature::Numeric(FeatureConfig {
name: "Est. price per sqm",
bounds: Bounds::Percentile {
low: 0.0,
high: 98.0,
},
step: 100.0,
description: "Estimated current price divided by total floor area",
detail: "Calculated by dividing the modelled estimated current price by the total floor area from the EPC certificate. Provides a more up-to-date price-per-area comparison than the historical sale price per sqm.",
source: "price-paid",
prefix: "£",
suffix: "",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Price per sqm",
bounds: Bounds::Percentile {
low: 0.0,
high: 98.0,
},
step: 100.0,
description: "Sale price divided by total floor area",
detail: "Calculated by dividing the last known sale price by the total floor area from the EPC certificate. Useful for comparing value across different-sized properties. Only available where both price and floor area data exist.",
source: "price-paid",
prefix: "£",
suffix: "",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Estimated monthly rent",
bounds: Bounds::Percentile { low: 2.0, high: 98.0 },
step: 25.0,
description: "Mean monthly private rent for the local area",
detail: "Mean monthly rental price from ONS Price Index of Private Rents (PIPR), matched by local authority and bedroom count.",
source: "ons-rental",
prefix: "£",
suffix: "/mo",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Total floor area (sqm)",
bounds: Bounds::Percentile {
@ -196,21 +124,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
raw: true,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Date of last transaction",
bounds: Bounds::Fixed {
min: 1995.0,
max: 2026.0,
},
step: 1.0,
description: "Date of the most recent sale from the Land Registry",
detail: "The date of the most recent recorded sale for this property from HM Land Registry Price Paid data. Stored as a datetime in the data; converted to fractional year for filtering and charting.",
source: "price-paid",
prefix: "",
suffix: "",
raw: true,
absolute: false,
}),
Feature::Enum(EnumFeatureConfig {
name: "Former council house",
order: Some(&["Yes", "No"]),
@ -247,6 +160,113 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Street tree density (%)",
bounds: Bounds::Fixed {
min: 0.0,
max: 100.0,
},
step: 1.0,
description: "Estimated tree canopy density on the property's street",
detail: "Approximate street-level tree density derived from Forest Research's 2025 Trees Outside Woodland map. Tree canopy polygons for lone trees and groups of trees are counted within 50m of postcode centroids, then averaged across Price Paid addresses on the same street. This is a street proxy, not an exact address-to-road-segment measurement.",
source: "forest-research-tow",
prefix: "",
suffix: "%",
raw: false,
absolute: true,
}),
],
},
FeatureGroup {
name: "Property prices",
features: &[
Feature::Numeric(FeatureConfig {
name: "Estimated current price",
bounds: Bounds::Fixed {
min: 0.0,
max: 2_500_000.0,
},
step: 10000.0,
description: "Modelled estimate of the current property value",
detail: "Based on the last sale price, local repeat-sales price movement, and nearby recently sold properties. The repeat-sales index is tracked by postcode sector and property type, with smoothing and neighbour blending where data is sparse. Recent sales stay close to the recorded price; older sales depend more on the model.",
source: "price-paid",
prefix: "£",
suffix: "",
raw: false,
absolute: true,
}),
Feature::Numeric(FeatureConfig {
name: "Est. price per sqm",
bounds: Bounds::Percentile {
low: 0.0,
high: 98.0,
},
step: 100.0,
description: "Estimated current price divided by total floor area",
detail: "Calculated by dividing the modelled estimated current price by the total floor area from the EPC certificate. Provides a more up-to-date price-per-area comparison than the historical sale price per sqm.",
source: "price-paid",
prefix: "£",
suffix: "",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Estimated monthly rent",
bounds: Bounds::Percentile { low: 2.0, high: 98.0 },
step: 25.0,
description: "Mean monthly private rent for the local area",
detail: "Mean monthly rental price from ONS Price Index of Private Rents (PIPR), matched by local authority and bedroom count.",
source: "ons-rental",
prefix: "£",
suffix: "/mo",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Last known price",
bounds: Bounds::Fixed {
min: 0.0,
max: 2_500_000.0,
},
step: 10000.0,
description: "Most recent sale price from the Land Registry",
detail: "The last recorded sale price for this property from HM Land Registry Price Paid data. Covers residential sales in England. May be years old if the property hasn't sold recently.",
source: "price-paid",
prefix: "£",
suffix: "",
raw: false,
absolute: true,
}),
Feature::Numeric(FeatureConfig {
name: "Price per sqm",
bounds: Bounds::Percentile {
low: 0.0,
high: 98.0,
},
step: 100.0,
description: "Sale price divided by total floor area",
detail: "Calculated by dividing the last known sale price by the total floor area from the EPC certificate. Useful for comparing value across different-sized properties. Only available where both price and floor area data exist.",
source: "price-paid",
prefix: "£",
suffix: "",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Date of last transaction",
bounds: Bounds::Fixed {
min: 1995.0,
max: 2026.0,
},
step: 1.0,
description: "Date of the most recent sale from the Land Registry",
detail: "The date of the most recent recorded sale for this property from HM Land Registry Price Paid data. Stored as a datetime in the data; converted to fractional year for filtering and charting.",
source: "price-paid",
prefix: "",
suffix: "",
raw: true,
absolute: false,
}),
],
},
FeatureGroup {
@ -390,7 +410,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
],
},
FeatureGroup {
name: "Area characteristics",
name: "Area development",
features: &[
Feature::Numeric(FeatureConfig {
name: "Income Score",
@ -1098,21 +1118,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Number of parks within 1km",
bounds: Bounds::Percentile {
low: 5.0,
high: 95.0,
},
step: 1.0,
description: "Number of parks and green spaces within 1km",
detail: "Count of public parks, gardens, playing fields, and play spaces with at least one entrance within a 1km radius of the property's postcode centroid. Derived from the OS Open Greenspace dataset (Ordnance Survey), using park entrance locations for accurate proximity matching.",
source: "os-open-greenspace",
prefix: "",
suffix: "",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Noise (dB)",
bounds: Bounds::Fixed {
@ -1205,14 +1210,14 @@ pub fn bounds_for(name: &str) -> Option<Bounds> {
}
pub fn dynamic_poi_distance_category(name: &str) -> Option<&str> {
name.strip_prefix("Distance to nearest ")
.and_then(|rest| rest.strip_suffix(" POI (km)"))
name.strip_prefix("Distance to nearest amenity (")
.and_then(|rest| rest.strip_suffix(") (km)"))
.filter(|category| !category.is_empty())
}
pub fn dynamic_poi_count_radius(name: &str) -> Option<u8> {
let rest = name.strip_prefix("Number of ")?;
let (_category, suffix) = rest.rsplit_once(" POIs within ")?;
let rest = name.strip_prefix("Number of amenities (")?;
let (_category, suffix) = rest.rsplit_once(") within ")?;
match suffix {
"2km" => Some(2),
"5km" => Some(5),
@ -1221,8 +1226,8 @@ pub fn dynamic_poi_count_radius(name: &str) -> Option<u8> {
}
pub fn dynamic_poi_count_category(name: &str) -> Option<&str> {
let rest = name.strip_prefix("Number of ")?;
let (category, suffix) = rest.rsplit_once(" POIs within ")?;
let rest = name.strip_prefix("Number of amenities (")?;
let (category, suffix) = rest.rsplit_once(") within ")?;
matches!(suffix, "2km" | "5km")
.then_some(category)
.filter(|category| !category.is_empty())

View file

@ -132,12 +132,13 @@ mod tests {
let normal: FxHashMap<String, usize> = [("Price".to_string(), 0), ("Area".to_string(), 1)]
.into_iter()
.collect();
let poi: FxHashMap<String, usize> = [("Distance to nearest cafe POI (km)".to_string(), 2)]
.into_iter()
.collect();
let poi: FxHashMap<String, usize> =
[("Distance to nearest amenity (Cafe) (km)".to_string(), 2)]
.into_iter()
.collect();
let parsed = parse_field_indices_with_poi(
Some("Price;;Distance to nearest cafe POI (km)"),
Some("Price;;Distance to nearest amenity (Cafe) (km)"),
&normal,
&poi,
)

30
uv.lock generated
View file

@ -1380,6 +1380,7 @@ dependencies = [
{ name = "plotly", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "polars", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "pyarrow", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "pyogrio", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "pyproj", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "pyshp", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "rasterio", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
@ -1413,6 +1414,7 @@ requires-dist = [
{ name = "polars" },
{ name = "polars", specifier = ">=1.37.1" },
{ name = "pyarrow", specifier = ">=15.0.0" },
{ name = "pyogrio", specifier = ">=0.12.1" },
{ name = "pyproj", specifier = ">=3.7.2" },
{ name = "pyshp", specifier = ">=2.3.0" },
{ name = "rasterio", specifier = ">=1.5.0" },
@ -1509,6 +1511,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pyogrio"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "numpy", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "packaging", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/d4/12f86b1ed09721363da4c09622464b604c851a9223fc0c6b393fb2012208/pyogrio-0.12.1.tar.gz", hash = "sha256:e548ab705bb3e5383693717de1e6c76da97f3762ab92522cb310f93128a75ff1", size = 303289, upload-time = "2025-11-28T19:04:53.341Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/ca/5368571a8b00b941ccfbe6ea29a5566aaffd45d4eb1553b956f7755af43e/pyogrio-0.12.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:81096a5139532de5a8003ef02b41d5d2444cb382a9aecd1165b447eb549180d3", size = 31417048, upload-time = "2025-11-28T19:03:32.572Z" },
{ url = "https://files.pythonhosted.org/packages/ef/85/6eeb875f27bf498d657eb5dab9f58e4c48b36c9037122787abee9a1ba4ba/pyogrio-0.12.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:41b78863f782f7a113ed0d36a5dc74d59735bd3a82af53510899bb02a18b06bb", size = 30952115, upload-time = "2025-11-28T19:03:35.332Z" },
{ url = "https://files.pythonhosted.org/packages/36/f7/cf8bec9024625947e1a71441906f60a5fa6f9e4c441c4428037e73b1fcc8/pyogrio-0.12.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:8b65be8c4258b27cc8f919b21929cecdadda4c353e3637fa30850339ef4d15c5", size = 32537246, upload-time = "2025-11-28T19:03:37.969Z" },
{ url = "https://files.pythonhosted.org/packages/59/58/925f1c129ddd7cbba8dea4e7609797cea7a76dbc863ac9afd318a679c4b9/pyogrio-0.12.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:73a88436f9962750d782853727897ac2722cac5900d920e39fab3e56d7a6a7f1", size = 31377986, upload-time = "2025-11-28T19:03:48.495Z" },
{ url = "https://files.pythonhosted.org/packages/18/5f/c87034e92847b1844d0e8492a6a8e3301147d32c5e57909397ce64dbedf5/pyogrio-0.12.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:b5d248a0d59fe9bbf9a35690b70004c67830ee0ebe7d4f7bb8ffd8659f684b3a", size = 30915791, upload-time = "2025-11-28T19:03:51.267Z" },
{ url = "https://files.pythonhosted.org/packages/46/35/b874f79d03e9f900012cf609f7fff97b77164f2e14ee5aac282f8a999c1b/pyogrio-0.12.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0622bc1a186421547660271083079b38d42e6f868802936d8538c0b379f1ab6b", size = 32499754, upload-time = "2025-11-28T19:03:58.776Z" },
{ url = "https://files.pythonhosted.org/packages/87/a1/39fefd9cddd95986700524f43d3093b4350f6e4fc200623c3838424a5080/pyogrio-0.12.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d3f1a19f63bfd1d3042e45f37ad1d6598123a5a604b6c4ba3f38b419273486cd", size = 31368995, upload-time = "2025-11-28T19:04:09.88Z" },
{ url = "https://files.pythonhosted.org/packages/18/d7/da88c566e67d741a03851eb8d01358949d52e0b0fc2cd953582dc6d89ff8/pyogrio-0.12.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:f3dcc59b3316b8a0f59346bcc638a4d69997864a4d21da839192f50c4c92369a", size = 31035589, upload-time = "2025-11-28T19:04:12.993Z" },
{ url = "https://files.pythonhosted.org/packages/11/ac/8f0199f0d31b8ddbc4b4ea1918df8070fdf3e0a63100b898633ec9396224/pyogrio-0.12.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:a0643e041dee3e8e038fce69f52a915ecb486e6d7b674c0f9919f3c9e9629689", size = 32487973, upload-time = "2025-11-28T19:04:16.103Z" },
{ url = "https://files.pythonhosted.org/packages/89/6e/e9929d2261a07c36301983de2767bcde90d441ab5bf1d767ce56dd07f8b4/pyogrio-0.12.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:648c6f7f5f214d30e6cf493b4af1d59782907ac068af9119ca35f18153d6865a", size = 31336936, upload-time = "2025-11-28T19:04:26.594Z" },
{ url = "https://files.pythonhosted.org/packages/1d/9e/c59941d734ed936d4e5c89b4b99cb5541307cc42b3fd466ee78a1850c177/pyogrio-0.12.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:58042584f3fd4cabb0f55d26c1405053f656be8a5c266c38140316a1e981aca0", size = 30902210, upload-time = "2025-11-28T19:04:29.143Z" },
{ url = "https://files.pythonhosted.org/packages/d1/68/cc07320a63f9c2586e60bf11d148b00e12d0e707673bffe609bbdcb7e754/pyogrio-0.12.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b438e38e4ccbaedaa5cb5824ff5de5539315d9b2fde6547c1e816576924ee8ca", size = 32461674, upload-time = "2025-11-28T19:04:31.792Z" },
{ url = "https://files.pythonhosted.org/packages/27/95/4d4c3644695d99c6fa0b0b42f0d6266ae9dfaf64478a3371eaac950bdd02/pyogrio-0.12.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0db95765ac0ca935c7fe579e29451294e3ab19c317b0c59c31fbe92a69155e0", size = 31371995, upload-time = "2025-11-28T19:04:42.736Z" },
{ url = "https://files.pythonhosted.org/packages/4c/6f/71f6bcca8754c8bf55a4b7153c61c91f8ac5ba992568e9fa3e54a0ee76fd/pyogrio-0.12.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fc882779075982b93064b3bf3d8642514a6df00d9dd752493b104817072cfb01", size = 31035498, upload-time = "2025-11-28T19:04:45.79Z" },
{ url = "https://files.pythonhosted.org/packages/fd/47/75c1aa165a988347317afab9b938a01ad25dbca559b582ea34473703dc38/pyogrio-0.12.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:806f620e0c54b54dbdd65e9b6368d24f344cda84c9343364b40a57eb3e1c4dca", size = 32496390, upload-time = "2025-11-28T19:04:48.786Z" },
]
[[package]]
name = "pyparsing"
version = "3.3.2"

View file

@ -1,22 +0,0 @@
{
"cookies": [],
"origins": [
{
"origin": "https://perfect-postcode.co.uk",
"localStorage": [
{
"name": "theme",
"value": "light"
},
{
"name": "pocketbase_auth",
"value": "{\"token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJleHAiOjE3Nzg5NDgxMzIsImlkIjoiZDZ5aG9odWh3MHh5dHhwIiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.bOR_vxK2MvqeKc_C9ao13416n_F-Ipmbn53NJy6L_JU\",\"record\":{\"ai_tokens_used\":0,\"ai_tokens_week\":0,\"avatar\":\"\",\"collectionId\":\"_pb_users_auth_\",\"collectionName\":\"users\",\"created\":\"2026-03-14 21:50:46.000Z\",\"email\":\"schmelczerandras+10@gmail.com\",\"emailVisibility\":false,\"id\":\"d6yhohuhw0xytxp\",\"is_admin\":false,\"name\":\"\",\"newsletter\":false,\"subscription\":\"licensed\",\"updated\":\"2026-03-14 21:50:48.430Z\",\"verified\":false}}"
},
{
"name": "tutorial_completed",
"value": "1"
}
]
}
]
}

View file

@ -27,7 +27,7 @@ const PROMPT_TEXT = 'Flats <£300k, 35 min to commute Manchester close to an out
const BRAND = {
name: 'Perfect Postcode',
tagline: 'Find where you actually want to live.',
tagline: 'Your best chance to find your next perfect home.',
url: 'https://perfect-postcode.co.uk',
};
@ -40,9 +40,8 @@ const TT_DRAG_FROM_MIN = 35;
const TT_DRAG_TO_MIN = 20;
const BRITISH_MALE_NARRATOR =
'Calm but cheerful, professional middle-aged British male narrator from the North with a ' +
'strong Manchester accent. Even, measured pace; warm but and smiling voice; product-demo register. Do not laugh, sigh, gasp, or add ' +
'filler sounds; no audible breaths between sentences.';
'Calm and cheerful young British male narrator from the North of England with a ' +
'strong Manchester accent.';
const DEFAULT_CUES: Storyboard['cues'] = [
{