This commit is contained in:
Andras Schmelczer 2026-05-06 22:40:46 +01:00
parent 28323f145e
commit 94f9c0d594
76 changed files with 3238 additions and 1230 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Before After
Before After

Binary file not shown.

View file

@ -408,7 +408,7 @@ async function prerender() {
const updated = updateHead(baseIndexHtml, route).replace(
'<div id="root"></div>',
`<div id="root">${html}</div>`
`<div id="root" data-prerender-path="${escapeAttr(route.path)}">${html}</div>`
);
if (updated === baseIndexHtml) {

View file

@ -49,6 +49,7 @@ const SHOWCASE_STEP_COUNT = 4;
const SHOWCASE_INTERVAL_MS = 5200;
const FILTER_ANIMATION_MS = 5000;
const INSPECT_SCROLL_ANIMATION_MS = 4600;
const SCOUT_TABLE_REVEAL_MS = 2400;
const BRAND_NAME = 'Perfect Postcode';
const BRAND_TEXT_CLASS = 'text-teal-600 dark:text-teal-400';
const HOME_SECTION_CONTAINER_CLASS = 'max-w-7xl mx-auto px-6 md:px-10';
@ -262,19 +263,6 @@ const VOTE_SHARE_SEGMENTS = [
{ name: '% Other parties', value: 4 },
];
const HOUSE_PRICE_BREAKDOWN = [
{ label: 'Flats', value: '£612k', helper: '146 sold' },
{ label: 'Terraces', value: '£1.04m', helper: '38 sold' },
{ label: 'Semis', value: '£1.22m', helper: '14 sold' },
{ label: '£/sq ft', value: '£872', helper: 'local median' },
];
const PRICE_SIGNALS = [
['5-year change', '+22%'],
['Last 12 months', '+2.1%'],
['Days to sell', '41'],
];
const INSPECT_TRAVEL_ENTRIES: TravelTimeEntry[] = [
{
mode: 'transit',
@ -821,36 +809,6 @@ function RightPaneOnlyScreen({
</span>
</div>
<PriceHistoryChart points={PRICE_POINTS} />
<div className="mt-3 grid grid-cols-2 gap-2">
{HOUSE_PRICE_BREAKDOWN.map((item) => (
<div
key={item.label}
className="rounded-md bg-white px-2.5 py-2 shadow-sm shadow-navy-950/5 dark:bg-navy-900/60 sm:px-3"
>
<div className="text-xs font-bold uppercase text-warm-400">
{item.label}
</div>
<div className="mt-0.5 flex items-baseline justify-between gap-2">
<span className="text-sm font-black text-navy-950 dark:text-warm-100">
{item.value}
</span>
<span className="text-xs font-semibold text-warm-500">
{item.helper}
</span>
</div>
</div>
))}
</div>
<div className="mt-3 grid grid-cols-3 divide-x divide-warm-200 rounded-md bg-white text-center shadow-sm shadow-navy-950/5 dark:divide-navy-700 dark:bg-navy-900/60">
{PRICE_SIGNALS.map(([label, value]) => (
<div key={label} className="px-2 py-2">
<div className="text-xs font-bold uppercase text-warm-400">{label}</div>
<div className="mt-0.5 text-xs font-black text-navy-950 dark:text-warm-100">
{value}
</div>
</div>
))}
</div>
</div>
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950/50 sm:p-4">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-warm-700 dark:text-warm-300">
@ -914,16 +872,46 @@ function RightPaneOnlyScreen({
);
}
function ScoutScreen() {
function ScoutScreen({ isActive }: { isActive: boolean }) {
const { t } = useTranslation();
const [isTableRevealed, setIsTableRevealed] = useState(false);
const scoutRows = [
{ postcode: 'SW5 9AA', score: '94%', commute: '23 min', price: '£492k' },
{ postcode: 'SE22 8EF', score: '91%', commute: '28 min', price: '£518k' },
{ postcode: 'N4 2AB', score: '88%', commute: '31 min', price: '£476k' },
];
useEffect(() => {
if (!isActive) {
setIsTableRevealed(false);
return;
}
const prefersReducedMotion =
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) {
setIsTableRevealed(true);
return;
}
setIsTableRevealed(false);
const timer = window.setTimeout(() => setIsTableRevealed(true), SCOUT_TABLE_REVEAL_MS);
return () => window.clearTimeout(timer);
}, [isActive]);
return (
<div className="relative flex h-full min-h-0 flex-col overflow-hidden bg-[#f7f3ed] p-3 dark:bg-navy-950/45 sm:p-5">
<div className="relative z-10 mt-5 rounded-lg bg-white p-3 shadow-2xl shadow-navy-950/10 dark:bg-navy-900/65 dark:backdrop-blur-sm sm:mt-9 sm:p-5">
<div
className={`relative flex h-full min-h-0 flex-col overflow-hidden bg-[#f7f3ed] p-3 dark:bg-navy-950/45 sm:p-5 ${
isActive ? 'scout-screen-active' : ''
}`}
>
<div className="my-auto w-full shrink-0">
<div className="relative z-10 shrink-0 rounded-lg bg-white p-2 shadow-2xl shadow-navy-950/10 dark:bg-navy-900/65 dark:backdrop-blur-sm sm:p-5">
<div className="grid grid-cols-2 gap-2 sm:gap-4">
<div className="cursor-default select-none rounded-lg border border-warm-200 bg-warm-50 p-3 shadow-sm dark:border-navy-700 dark:bg-navy-950/50 sm:p-4">
<div className="cursor-default select-none rounded-lg border border-warm-200 bg-warm-50 p-2 shadow-sm dark:border-navy-700 dark:bg-navy-950/50 sm:p-4">
<div className="flex items-center gap-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-teal-50 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300 sm:h-10 sm:w-10">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-teal-50 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300 sm:h-10 sm:w-10">
<ClipboardIcon className="h-4 w-4 sm:h-5 sm:w-5" />
</span>
<div className="min-w-0">
@ -936,10 +924,10 @@ function ScoutScreen() {
</div>
</div>
</div>
<div className="scout-export-action relative cursor-default select-none overflow-hidden rounded-lg border border-teal-300 bg-teal-600 p-3 text-white shadow-lg shadow-teal-900/20 dark:border-teal-500 dark:bg-teal-500 dark:text-navy-950 sm:p-4">
<div className="scout-export-action relative cursor-default select-none overflow-hidden rounded-lg border border-teal-300 bg-teal-600 p-2 text-white shadow-lg shadow-teal-900/20 dark:border-teal-500 dark:bg-teal-500 dark:text-navy-950 sm:p-4">
<span className="scout-export-ripple" aria-hidden="true" />
<div className="relative flex items-center gap-2 sm:gap-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-white/15 text-white dark:bg-navy-950/10 dark:text-navy-950 sm:h-10 sm:w-10">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-white/15 text-white dark:bg-navy-950/10 dark:text-navy-950 sm:h-10 sm:w-10">
<DownloadIcon className="h-4 w-4 sm:h-5 sm:w-5" />
</span>
<div className="min-w-0">
@ -956,54 +944,91 @@ function ScoutScreen() {
</div>
</div>
<svg
className="pointer-events-none absolute left-14 top-[8.75rem] z-20 h-36 w-20 text-teal-500/80 dark:text-teal-300/80 sm:left-12 sm:top-[11rem] sm:h-56 sm:w-72"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
<div
className={`overflow-hidden transition-all duration-500 ease-out ${
isTableRevealed
? 'mt-3 max-h-64 opacity-100 sm:mt-4'
: 'mt-0 max-h-0 opacity-0'
}`}
aria-hidden={!isTableRevealed}
>
<path
d="M50 4 L50 92"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeDasharray="4 4"
/>
<polygon
points="50,92 44.5,84 55.5,84"
className="fill-teal-500 dark:fill-teal-300"
/>
</svg>
<svg
className="pointer-events-none absolute right-14 top-[8.75rem] z-20 h-36 w-20 text-teal-500/80 dark:text-teal-300/80 sm:right-12 sm:top-[11rem] sm:h-56 sm:w-72"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
<div className="relative z-10 shrink-0 overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl shadow-navy-950/10 ring-1 ring-white/80 dark:border-navy-700 dark:bg-navy-900/70 dark:ring-white/5 dark:backdrop-blur-sm">
<div className="flex items-center justify-between gap-3 border-b border-warm-200 bg-gradient-to-r from-white via-emerald-50/80 to-white px-3 py-1.5 text-navy-950 dark:border-navy-700 dark:from-navy-900/90 dark:via-emerald-900/20 dark:to-navy-900/90 dark:text-warm-100 sm:px-4 sm:py-2">
<div className="flex min-w-0 items-center gap-2">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-emerald-600 text-white shadow-sm shadow-emerald-900/20 dark:bg-emerald-400 dark:text-navy-950 sm:h-7 sm:w-7">
<DownloadIcon className="h-3.5 w-3.5" />
</span>
<span className="min-w-0 truncate text-xs font-black sm:text-sm">
{t('home.showcaseStep4FileName')}
</span>
</div>
<span className="shrink-0 rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[10px] font-black text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-200 sm:text-xs">
Top 3
</span>
</div>
<div className="grid grid-cols-[1.25fr_0.85fr_0.9fr_1fr] border-b border-warm-200 bg-warm-50 text-[10px] font-black uppercase text-warm-500 dark:border-navy-700 dark:bg-navy-950/45 dark:text-warm-400 sm:text-xs">
{[
t('home.showcaseStep4ColPostcode'),
t('home.showcaseStep4ColScore'),
t('home.showcaseStep4ColCommute'),
t('home.showcaseStep4ColPrice'),
].map((heading) => (
<div
key={heading}
className="truncate border-r border-warm-200 px-2 py-1.5 last:border-r-0 dark:border-navy-700 sm:px-3 sm:py-2"
>
<path
d="M50 4 L50 92"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeDasharray="4 4"
{heading}
</div>
))}
</div>
{scoutRows.map((row, index) => (
<div
key={row.postcode}
className={`grid grid-cols-[1.25fr_0.85fr_0.9fr_1fr] text-[11px] font-semibold text-navy-950 dark:text-warm-100 sm:text-sm ${
index % 2 === 0
? 'bg-white dark:bg-navy-900/55'
: 'bg-warm-50/70 dark:bg-navy-950/35'
}`}
>
<div className="flex min-w-0 items-center gap-1.5 border-r border-warm-200 px-2 py-1.5 dark:border-navy-700 sm:px-3 sm:py-2.5">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-emerald-50 text-[10px] font-black text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300">
{index + 1}
</span>
<span className="truncate font-black">{row.postcode}</span>
</div>
<div className="min-w-0 border-r border-warm-200 px-2 py-1.5 dark:border-navy-700 sm:px-3 sm:py-2.5">
<div className="flex items-center gap-2">
<span className="shrink-0 font-black text-emerald-700 dark:text-emerald-300">
{row.score}
</span>
<span className="hidden h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-warm-200 dark:bg-navy-700 sm:block">
<span
className="block h-full rounded-full bg-emerald-500 dark:bg-emerald-300"
style={{ width: row.score }}
/>
<polygon
points="50,92 44.5,84 55.5,84"
className="fill-teal-500 dark:fill-teal-300"
/>
</svg>
</span>
</div>
</div>
<div className="truncate border-r border-warm-200 px-2 py-1.5 dark:border-navy-700 sm:px-3 sm:py-2.5">
{row.commute}
</div>
<div className="truncate px-2 py-1.5 font-black sm:px-3 sm:py-2.5">{row.price}</div>
</div>
))}
</div>
</div>
<div className="relative z-10 mt-auto rounded-lg bg-navy-950/55 p-4 text-white shadow-2xl shadow-navy-950/20 backdrop-blur-sm sm:p-5">
<div className="text-lg font-black leading-tight">
<div className="relative z-10 mt-3 shrink-0 rounded-lg bg-navy-950/55 p-3 text-center text-white shadow-2xl shadow-navy-950/20 backdrop-blur-sm sm:mt-4 sm:p-4">
<div className="text-base font-black leading-tight">
{t('home.showcaseStep4Conclusion')}
</div>
<div className="mt-4 grid gap-2 text-xs font-medium leading-relaxed text-warm-300 sm:mt-5 sm:gap-3 sm:text-sm">
<div className="mt-3 grid gap-1.5 text-xs font-medium leading-relaxed text-warm-300 sm:gap-2">
{[
'Walk the streets before the listing search narrows your options.',
'Test the commute from a real front door, not a borough name.',
'Compare viewings with evidence already in hand.',
].map((item) => (
<div key={item} className="flex gap-2">
<div key={item} className="flex justify-center gap-2">
<CheckIcon className="mt-0.5 h-4 w-4 shrink-0 text-teal-300" />
<span>{item}</span>
</div>
@ -1011,6 +1036,7 @@ function ScoutScreen() {
</div>
</div>
</div>
</div>
);
}
@ -1031,7 +1057,7 @@ function DashboardShowcase({
isActive={activeStep === 2}
userScrolledRef={inspectUserScrolledRef}
/>,
<ScoutScreen key="scout" />,
<ScoutScreen key="scout" isActive={activeStep === 3} />,
];
const ActiveIcon = active.Icon;
const showStageHeader = activeStep !== 3;
@ -1112,14 +1138,14 @@ function HeroProductShowcase() {
return (
<div
className="dark relative w-full min-w-0 max-w-[58rem] justify-self-center lg:max-w-none lg:justify-self-stretch"
className="home-hero-showcase dark relative w-full min-w-0 max-w-[58rem] justify-self-center"
onMouseEnter={() => setIsStagePaused(true)}
onMouseLeave={() => setIsStagePaused(false)}
onFocus={() => setIsStagePaused(true)}
onBlur={() => setIsStagePaused(false)}
aria-label={t('home.showcaseContext')}
>
<div className="flex h-[34rem] flex-col overflow-hidden rounded-lg bg-navy-950/50 shadow-2xl shadow-black/40 backdrop-blur-sm ring-1 ring-white/10 sm:h-[46rem] md:h-[50rem] lg:h-[47rem] xl:h-[46rem]">
<div className="home-hero-showcase-frame flex h-[36rem] flex-col overflow-hidden rounded-lg bg-navy-950/50 shadow-2xl shadow-black/40 backdrop-blur-sm ring-1 ring-white/10 sm:h-[38rem] md:h-[40rem]">
<div className="shrink-0 bg-white/[0.035] p-1.5 sm:p-2 md:p-3">
<div className="grid grid-cols-4 gap-1 sm:gap-2">
{steps.map((step, index) => {
@ -1133,13 +1159,13 @@ function HeroProductShowcase() {
className={`rounded-md px-1.5 py-2 text-left transition-colors sm:px-3 ${
activeStep === index
? 'bg-teal-400/10 text-white shadow-sm'
: 'bg-white/[0.03] text-warm-400 hover:bg-white/[0.06] hover:text-warm-200'
: 'bg-white/[0.03] text-warm-200 hover:bg-white/[0.06] hover:text-white'
}`}
>
<div className="flex items-center justify-center gap-1 sm:justify-start sm:gap-2">
<Icon
className={`hidden h-4 w-4 sm:block ${
activeStep === index ? 'text-teal-300' : 'text-warm-500'
activeStep === index ? 'text-teal-300' : 'text-warm-300'
}`}
/>
<span className="text-[11px] font-bold sm:hidden">{index + 1}</span>
@ -1259,9 +1285,9 @@ export default function HomePage({
{/* Hero */}
<div className="relative overflow-hidden bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 dark:from-navy-950 dark:via-navy-900 dark:to-navy-950 min-h-[calc(100dvh-3rem)] flex flex-col">
<HexCanvas isDark={theme === 'dark'} animated={false} />
<div className="relative z-10 mx-auto flex w-full max-w-[104rem] flex-1 flex-col px-6 pb-8 pt-6 backdrop-blur-[2px] md:px-10 md:py-10 lg:py-12">
<div className="hero-roomy-lift grid flex-1 items-center gap-x-8 gap-y-6 lg:grid-cols-[minmax(0,0.82fr)_minmax(38rem,1.18fr)] lg:gap-12 xl:grid-cols-[minmax(0,0.78fr)_minmax(44rem,1.22fr)] xl:gap-16">
<div className="min-w-0 max-w-4xl lg:max-w-[42rem] xl:max-w-[45rem]">
<div className="home-hero-container relative z-10 mx-auto flex w-full max-w-[104rem] flex-1 flex-col px-6 pb-8 pt-6 backdrop-blur-[2px] md:px-10 md:py-10">
<div className="home-hero-layout hero-roomy-lift grid flex-1 items-center gap-x-8 gap-y-6">
<div className="home-hero-copy min-w-0 max-w-4xl">
<p className="text-sm font-semibold text-teal-300 mb-3">{t('home.heroEyebrow')}</p>
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-4 leading-[1.1]">
{t('home.heroTitle1')}{' '}
@ -1269,10 +1295,10 @@ export default function HomePage({
<br />
{t('home.heroTitle3')}
</h1>
<p className="text-base md:text-lg text-warm-300 mb-6 leading-relaxed max-w-xl">
<p className="text-base md:text-lg text-warm-100 mb-6 leading-relaxed max-w-xl">
{t('home.heroSubtitle')}
</p>
<p className="text-base md:text-lg text-warm-400 mb-8 max-w-xl">
<p className="text-base md:text-lg text-warm-200 mb-8 max-w-xl">
{highlightBrandText(t('home.heroDescription'))}
</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-10">
@ -1300,19 +1326,19 @@ export default function HomePage({
<div className="text-2xl md:text-3xl font-bold text-white">
<TickerValue text="13M" active={statsActive} />
</div>
<div className="text-sm text-warm-400">{t('home.statProperties')}</div>
<div className="text-sm text-warm-200">{t('home.statProperties')}</div>
</div>
<div>
<div className="text-2xl md:text-3xl font-bold text-white">
<TickerValue text="56" active={statsActive} />
</div>
<div className="text-sm text-warm-400">{t('home.statFilters')}</div>
<div className="text-sm text-warm-200">{t('home.statFilters')}</div>
</div>
<div>
<div className="text-2xl md:text-3xl font-bold text-white">
{t('home.statEvery')}
</div>
<div className="text-sm text-warm-400">{t('home.statPostcodeInEngland')}</div>
<div className="text-sm text-warm-200">{t('home.statPostcodeInEngland')}</div>
</div>
</div>
</div>

View file

@ -192,6 +192,7 @@ export default function LearnPage() {
items: [
{ question: t('learnPage.faqCommute1Q'), answer: t('learnPage.faqCommute1A') },
{ question: t('learnPage.faqCommute2Q'), answer: t('learnPage.faqCommute2A') },
{ question: t('learnPage.faqCommute3Q'), answer: t('learnPage.faqCommute3A') },
],
},
{
@ -201,6 +202,14 @@ export default function LearnPage() {
{ question: t('learnPage.faqBudget2Q'), answer: t('learnPage.faqBudget2A') },
],
},
{
title: t('learnPage.faqTipsTitle'),
items: [
{ question: t('learnPage.faqTips1Q'), answer: t('learnPage.faqTips1A') },
{ question: t('learnPage.faqTips2Q'), answer: t('learnPage.faqTips2A') },
{ question: t('learnPage.faqTips3Q'), answer: t('learnPage.faqTips3A') },
],
},
{
title: t('learnPage.faqDueDiligenceTitle'),
items: [
@ -287,7 +296,7 @@ export default function LearnPage() {
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
{tDynamic(nameKey)}
</h2>
<span className="text-xs bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded text-right">
<span className="max-w-44 text-left text-xs leading-snug bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded">
{source.license}
</span>
</div>

View file

@ -66,7 +66,11 @@ export default function FeatureBrowser({
const filtered = useMemo(() => {
if (!search) return availableFeatures;
const lower = search.toLowerCase();
return availableFeatures.filter((f) => f.name.toLowerCase().includes(lower));
return availableFeatures.filter((f) =>
[f.name, f.description, f.detail, f.group]
.filter((value): value is string => Boolean(value))
.some((value) => value.toLowerCase().includes(lower))
);
}, [availableFeatures, search]);
const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]);

View file

@ -29,6 +29,17 @@ import {
type TravelTimeEntry,
travelFieldKey,
} from '../../hooks/useTravelTime';
import {
SPECIFIC_CRIMES_FILTER_NAME,
SPECIFIC_CRIME_FEATURE_NAMES,
clampSpecificCrimeRange,
getDefaultSpecificCrimeFeatureName,
getSpecificCrimeFeatureName,
getSpecificCrimeFilterMeta,
isSpecificCrimeFeatureName,
isSpecificCrimeFilterName,
replaceSpecificCrimeFilterKeySelection,
} from '../../lib/crime-filter';
import {
SCHOOL_FILTER_NAME,
clampSchoolRange,
@ -399,6 +410,210 @@ function SchoolFilterCard({
);
}
function SpecificCrimeFilterCard({
features,
crimeFeature,
filters,
activeFeature,
dragValue,
pinnedFeature,
filterImpact,
percentileScale,
onFilterChange,
onDragStart,
onDragChange,
onDragEnd,
onTogglePin,
onShowInfo,
onRemove,
}: {
features: FeatureMeta[];
crimeFeature: FeatureMeta;
filters: FeatureFilters;
activeFeature: string | null;
dragValue: [number, number] | null;
pinnedFeature: string | null;
filterImpact?: number;
percentileScale?: PercentileScale;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
onDragStart: (name: string) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
onShowInfo: (feature: FeatureMeta) => void;
onRemove: () => void;
}) {
const specificCrimeMeta = getSpecificCrimeFilterMeta(features);
const crimeOptions = SPECIFIC_CRIME_FEATURE_NAMES.map((name) =>
features.find((feature) => feature.name === name)
).filter((feature): feature is FeatureMeta => Boolean(feature));
const selectedFeatureName =
getSpecificCrimeFeatureName(crimeFeature.name) ?? getDefaultSpecificCrimeFeatureName(features);
const selectedFeature = selectedFeatureName
? features.find((feature) => feature.name === selectedFeatureName)
: undefined;
if (!selectedFeature || crimeOptions.length === 0 || !selectedFeatureName) return null;
const isActive = activeFeature === crimeFeature.name;
const isPinned = pinnedFeature === crimeFeature.name;
const hist = selectedFeature.histogram;
const dataMin = hist?.min ?? selectedFeature.min ?? 0;
const dataMax = hist?.max ?? selectedFeature.max ?? 100;
const displayValue =
isActive && dragValue
? dragValue
: (filters[crimeFeature.name] as [number, number]) || [dataMin, dataMax];
const scale = percentileScale;
const clampMin = displayValue[0] <= dataMin;
const clampMax = displayValue[1] >= dataMax;
const isAtMin = displayValue[0] === dataMin;
const isAtMax = displayValue[1] === dataMax;
const sliderValue: [number, number] = scale
? [
clampMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
clampMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
]
: [
clampMin ? (selectedFeature.min ?? dataMin) : displayValue[0],
clampMax ? (selectedFeature.max ?? dataMax) : displayValue[1],
];
const replaceCrimeFeature = (nextFeatureName: string) => {
const nextName = replaceSpecificCrimeFilterKeySelection(crimeFeature.name, nextFeatureName);
if (nextName === crimeFeature.name) return;
const nextFeature = features.find((feature) => feature.name === nextFeatureName);
const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0;
const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax);
const nextRange = clampSpecificCrimeRange(
[
displayValue[0] <= dataMin ? nextDataMin : displayValue[0],
displayValue[1] >= dataMax ? nextDataMax : displayValue[1],
],
nextFeature
);
onFilterChange(nextName, nextRange);
if (isPinned) onTogglePin(nextName);
};
const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0';
const mobileIcon =
getFeatureIcon(selectedFeature.name, mobileIconClass) ||
(() => {
const G = selectedFeature.group ? getGroupIcon(selectedFeature.group) : null;
return G ? <G className={mobileIconClass} /> : null;
})();
return (
<div
data-filter-name={SPECIFIC_CRIMES_FILTER_NAME}
className={`space-y-2 rounded-lg border border-warm-200 bg-white px-2 py-2 shadow-sm dark:border-warm-700 dark:bg-warm-800 ${
isActive
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
: isPinned
? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20'
: ''
}`}
>
<div className="relative z-10 flex items-center justify-between gap-1">
<FeatureLabel
feature={specificCrimeMeta}
size="sm"
className="min-w-0 shrink"
hideIconOnMobile
/>
<FeatureActions
feature={selectedFeature}
actionName={crimeFeature.name}
isPinned={isPinned}
isPreviewing={isActive}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={onRemove}
/>
</div>
<div>
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
Crime type
</label>
<div className="relative">
<select
value={selectedFeatureName}
onChange={(e) => replaceCrimeFeature(e.target.value)}
className="w-full appearance-none rounded-md border border-warm-200 bg-warm-50 px-2 py-1.5 pr-8 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"
>
{crimeOptions.map((option) => (
<option key={option.name} value={option.name}>
{ts(option.name)}
</option>
))}
</select>
<ChevronIcon
direction="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"
/>
</div>
</div>
<div className="flex items-start gap-1.5 md:block">
{mobileIcon && <div className="shrink-0 pt-0.5 md:hidden">{mobileIcon}</div>}
<div className="min-w-0 flex-1">
<Slider
min={scale ? 0 : (selectedFeature.min ?? dataMin)}
max={scale ? 100 : (selectedFeature.max ?? dataMax)}
step={
scale
? 1
: (selectedFeature.step ??
((selectedFeature.max ?? dataMax) - (selectedFeature.min ?? dataMin)) / 100)
}
value={sliderValue}
onValueChange={
scale
? ([pMin, pMax]) => {
const step = selectedFeature.step ?? 1;
const snap = (v: number) => Math.round(v / step) * step;
onDragChange([
pMin <= 0 ? dataMin : snap(scale.toValue(pMin)),
pMax >= 100 ? dataMax : snap(scale.toValue(pMax)),
]);
}
: ([min, max]) =>
onDragChange([
min <= (selectedFeature.min ?? dataMin) ? dataMin : min,
max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
])
}
onPointerDown={() => onDragStart(crimeFeature.name)}
onPointerUp={() => onDragEnd()}
/>
<SliderLabels
min={scale ? 0 : (selectedFeature.min ?? dataMin)}
max={scale ? 100 : (selectedFeature.max ?? dataMax)}
value={sliderValue}
displayValues={displayValue}
isAtMin={isAtMin}
isAtMax={isAtMax}
raw={selectedFeature.raw}
feature={selectedFeature}
onValueChange={(v) =>
onFilterChange(crimeFeature.name, clampSpecificCrimeRange(v, selectedFeature))
}
/>
{filterImpact != null && filterImpact > 0 && (
<p className="-mt-1 ml-2.5 text-[10px] text-warm-400 dark:text-warm-500">
+{formatNumber(filterImpact)} without this filter
</p>
)}
</div>
</div>
</div>
);
}
interface FiltersProps {
features: FeatureMeta[];
filters: FeatureFilters;
@ -492,6 +707,11 @@ export default memo(function Filters({
const defaultSchoolFeatureName = useMemo(() => getDefaultSchoolFeatureName(features), [features]);
const schoolMeta = useMemo(() => getSchoolFilterMeta(features), [features]);
const defaultSpecificCrimeFeatureName = useMemo(
() => getDefaultSpecificCrimeFeatureName(features),
[features]
);
const specificCrimeMeta = useMemo(() => getSpecificCrimeFilterMeta(features), [features]);
const schoolFilterItems = useMemo(() => {
return Object.keys(filters)
.filter(isSchoolFilterName)
@ -503,9 +723,21 @@ export default memo(function Filters({
return { ...(backendFeature ?? schoolMeta), name, group: 'Education' };
});
}, [filters, features, schoolMeta]);
const specificCrimeFilterItems = useMemo(() => {
return Object.keys(filters)
.filter(isSpecificCrimeFilterName)
.map((name) => {
const backendName = getSpecificCrimeFeatureName(name);
const backendFeature = backendName
? features.find((feature) => feature.name === backendName)
: undefined;
return { ...(backendFeature ?? specificCrimeMeta), name, group: 'Crime' };
});
}, [filters, features, specificCrimeMeta]);
const availableFeatures = useMemo(() => {
const result: FeatureMeta[] = [];
let insertedSchoolFilter = false;
let insertedSpecificCrimeFilter = false;
for (const feature of features) {
if (isSchoolFilterName(feature.name)) {
@ -515,14 +747,29 @@ export default memo(function Filters({
}
continue;
}
if (isSpecificCrimeFeatureName(feature.name)) {
if (defaultSpecificCrimeFeatureName && !insertedSpecificCrimeFilter) {
result.push(specificCrimeMeta);
insertedSpecificCrimeFilter = true;
}
continue;
}
if (!enabledFeatures.has(feature.name)) result.push(feature);
}
return result;
}, [features, enabledFeatures, defaultSchoolFeatureName, schoolMeta]);
}, [
features,
enabledFeatures,
defaultSchoolFeatureName,
schoolMeta,
defaultSpecificCrimeFeatureName,
specificCrimeMeta,
]);
const enabledFeatureList = useMemo(() => {
const result: FeatureMeta[] = [];
let insertedSchoolFilter = false;
let insertedSpecificCrimeFilters = false;
for (const feature of features) {
if (isSchoolFilterName(feature.name)) {
@ -532,11 +779,18 @@ export default memo(function Filters({
}
continue;
}
if (isSpecificCrimeFeatureName(feature.name)) {
if (!insertedSpecificCrimeFilters) {
result.push(...specificCrimeFilterItems);
insertedSpecificCrimeFilters = true;
}
continue;
}
if (enabledFeatures.has(feature.name)) result.push(feature);
}
return result;
}, [features, enabledFeatures, schoolFilterItems]);
}, [features, enabledFeatures, schoolFilterItems, specificCrimeFilterItems]);
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
@ -556,11 +810,17 @@ export default memo(function Filters({
onAddFilter(SCHOOL_FILTER_NAME);
return;
}
if (name === SPECIFIC_CRIMES_FILTER_NAME) {
if (!defaultSpecificCrimeFeatureName) return;
pendingScrollRef.current = SPECIFIC_CRIMES_FILTER_NAME;
onAddFilter(SPECIFIC_CRIMES_FILTER_NAME);
return;
}
pendingScrollRef.current = name;
onAddFilter(name);
},
[defaultSchoolFeatureName, onAddFilter]
[defaultSchoolFeatureName, defaultSpecificCrimeFeatureName, onAddFilter]
);
const handleRemoveSchoolFilter = useCallback(
@ -793,6 +1053,66 @@ export default memo(function Filters({
);
}
if (isSpecificCrimeFilterName(feature.name)) {
const specificCrimeBackendName = getSpecificCrimeFeatureName(feature.name);
return (
<Fragment key={feature.name}>
{featureIdx === travelInsertIdx &&
travelTimeEntries.map((entry, index) => (
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label, lat, lon) =>
onTravelTimeSetDestination(index, slug, label, lat, lon)
}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
destinationDropdownPortal={destinationDropdownPortal}
/>
</div>
))}
<SpecificCrimeFilterCard
features={features}
crimeFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={
specificCrimeBackendName
? filterImpacts?.[specificCrimeBackendName]
: undefined
}
percentileScale={
specificCrimeBackendName
? percentileScales.get(specificCrimeBackendName)
: undefined
}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={() => onRemoveFilter(feature.name)}
/>
</Fragment>
);
}
if (feature.type === 'enum') {
const selectedValues = (filters[feature.name] as string[]) || [];
const allValues = feature.values || [];
@ -1063,10 +1383,12 @@ export default memo(function Filters({
<div className="md:flex-1 md:min-h-0 md:overflow-y-auto">
<FeatureBrowser
availableFeatures={availableFeatures}
allFeatures={[...features, schoolMeta]}
allFeatures={[...features, schoolMeta, specificCrimeMeta]}
pinnedFeature={
pinnedFeature && isSchoolFilterName(pinnedFeature)
? SCHOOL_FILTER_NAME
: pinnedFeature && isSpecificCrimeFilterName(pinnedFeature)
? SPECIFIC_CRIMES_FILTER_NAME
: pinnedFeature
}
onAddFilter={handleAddAndScroll}
@ -1075,6 +1397,10 @@ export default memo(function Filters({
if (defaultSchoolFeatureName) onTogglePin(defaultSchoolFeatureName);
return;
}
if (name === SPECIFIC_CRIMES_FILTER_NAME) {
if (defaultSpecificCrimeFeatureName) onTogglePin(defaultSpecificCrimeFeatureName);
return;
}
onTogglePin(name);
}}
onNavigateToSource={onNavigateToSource}
@ -1203,7 +1529,7 @@ export default memo(function Filters({
{clearSaveError && (
<p className="text-sm text-red-600 dark:text-red-300">{clearSaveError}</p>
)}
<div className="flex gap-3 justify-end">
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-center">
<button
type="button"
onClick={handleClearWithoutSaving}
@ -1214,7 +1540,7 @@ export default memo(function Filters({
<button
type="submit"
disabled={!clearSaveName.trim() || savingSearch}
className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
className="flex items-center justify-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
>
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{savingSearch ? t('saveSearch.saving') : t('filters.saveAndClear')}

View file

@ -4,6 +4,7 @@ import type { FeatureFilters, FeatureMeta } from '../../types';
import { formatValue } from '../../lib/format';
import { ts } from '../../i18n/server';
import { SCHOOL_FILTER_NAME, getSchoolBackendFeatureName } from '../../lib/school-filter';
import { getSpecificCrimeFeatureName } from '../../lib/crime-filter';
interface HoverCardData {
count: number;
@ -42,7 +43,9 @@ export default memo(function HoverCard({
// Show stats for active filters (up to 4)
for (const name of activeFilterNames.slice(0, 4)) {
const backendName = getSchoolBackendFeatureName(name) ?? name;
const schoolBackendName = getSchoolBackendFeatureName(name);
const specificCrimeFeatureName = getSpecificCrimeFeatureName(name);
const backendName = schoolBackendName ?? specificCrimeFeatureName ?? name;
const val = data[`avg_${backendName}`] ?? data[`min_${backendName}`];
if (val == null || typeof val !== 'number') continue;
const meta = featureMap.get(backendName);
@ -51,7 +54,7 @@ export default memo(function HoverCard({
if (label) results.push({ name: backendName, value: ts(label) });
} else {
results.push({
name: backendName === name ? name : SCHOOL_FILTER_NAME,
name: schoolBackendName ? SCHOOL_FILTER_NAME : backendName,
value: formatValue(val, meta),
});
}

View file

@ -1,6 +1,6 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { PostcodeGeometry } from '../../types';
import type { MapFlyToOptions, PostcodeGeometry } from '../../types';
import { authHeaders } from '../../lib/api';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
@ -8,6 +8,8 @@ import { PlaceSearchInput } from '../ui/PlaceSearchInput';
import { LocateIcon } from '../ui/icons/LocateIcon';
import { SearchIcon } from '../ui/icons/SearchIcon';
declare const __DEV__: boolean;
export interface SearchedLocation {
postcode: string;
geometry: PostcodeGeometry;
@ -35,13 +37,18 @@ const ZOOM_FOR_TYPE: Record<string, number> = {
isolated_dwelling: 16,
};
const DEV_CURRENT_LOCATION = {
latitude: 51.5033635,
longitude: -0.1276248,
};
export default function LocationSearch({
onFlyTo,
onLocationSearched,
onCurrentLocationFound,
onMouseEnter,
}: {
onFlyTo: (lat: number, lng: number, zoom: number) => void;
onFlyTo: (lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void;
onLocationSearched?: (postcode: SearchedLocation | null) => void;
onCurrentLocationFound?: (lat: number, lng: number) => void;
onMouseEnter?: () => void;
@ -162,7 +169,7 @@ export default function LocationSearch({
const [locating, setLocating] = useState(false);
const locateUser = useCallback(async () => {
if (!navigator.geolocation) {
if (!__DEV__ && !navigator.geolocation) {
setError(t('locationSearch.geolocationUnsupported'));
return;
}
@ -170,15 +177,27 @@ export default function LocationSearch({
setLocating(true);
search.close();
try {
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
const { latitude, longitude } = __DEV__
? DEV_CURRENT_LOCATION
: await new Promise<GeolocationCoordinates>((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('Geolocation unsupported'));
return;
}
navigator.geolocation.getCurrentPosition(
(position) => resolve(position.coords),
reject,
{
enableHighAccuracy: true,
timeout: 10000,
}
);
});
});
const { latitude, longitude } = position.coords;
if (onCurrentLocationFound) {
onCurrentLocationFound(latitude, longitude);
} else {
onFlyTo(latitude, longitude, 17);
onCurrentLocationFound?.(latitude, longitude);
}
search.clear();
if (isMobile) setExpanded(false);
} catch {

View file

@ -12,6 +12,7 @@ import type {
POI,
FeatureMeta,
Bounds,
MapFlyToOptions,
} from '../../types';
import {
@ -19,6 +20,7 @@ import {
getBoundsFromViewState,
getMapStyle,
getPoiIconUrl,
getMapCenterForTargetScreenPoint,
} from '../../lib/map-utils';
import {
INITIAL_VIEW_STATE,
@ -56,7 +58,9 @@ interface MapProps {
onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
initialViewState?: ViewState;
flyToRef?: React.MutableRefObject<((lat: number, lng: number, zoom: number) => void) | null>;
flyToRef?: React.MutableRefObject<
((lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void) | null
>;
theme?: 'light' | 'dark';
screenshotMode?: boolean;
ogMode?: boolean;
@ -80,6 +84,61 @@ interface Dimensions {
height: number;
}
function resolveInset(pixelValue: number | undefined, ratioValue: number | undefined, size: number) {
return Math.max(0, (pixelValue ?? 0) + (ratioValue ?? 0) * size);
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function getMapRelativeVisibleAreaCenter(dimensions: Dimensions, options?: MapFlyToOptions) {
const area = options?.visibleArea;
const leftInset = resolveInset(area?.left, area?.leftRatio, dimensions.width);
const rightInset = resolveInset(area?.right, area?.rightRatio, dimensions.width);
const topInset = resolveInset(area?.top, area?.topRatio, dimensions.height);
const bottomInset = resolveInset(area?.bottom, area?.bottomRatio, dimensions.height);
const left = Math.min(dimensions.width, leftInset);
const right = Math.max(left, dimensions.width - Math.min(dimensions.width, rightInset));
const top = Math.min(dimensions.height, topInset);
const bottom = Math.max(top, dimensions.height - Math.min(dimensions.height, bottomInset));
return {
x: (left + right) / 2,
y: (top + bottom) / 2,
};
}
function getViewportRelativeVisibleAreaCenter(
dimensions: Dimensions,
container: HTMLDivElement | null,
options?: MapFlyToOptions
) {
const area = options?.visibleViewportArea;
if (!area || !container) return null;
const rect = container.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const viewportLeft = resolveInset(area.left, area.leftRatio, viewportWidth);
const viewportRight =
viewportWidth - resolveInset(area.right, area.rightRatio, viewportWidth);
const viewportTop = resolveInset(area.top, area.topRatio, viewportHeight);
const viewportBottom =
viewportHeight - resolveInset(area.bottom, area.bottomRatio, viewportHeight);
const left = clamp(viewportLeft - rect.left, 0, dimensions.width);
const right = clamp(viewportRight - rect.left, left, dimensions.width);
const top = clamp(viewportTop - rect.top, 0, dimensions.height);
const bottom = clamp(viewportBottom - rect.top, top, dimensions.height);
return {
x: (left + right) / 2,
y: (top + bottom) / 2,
};
}
interface DeckWithPrivateDraw {
_drawLayers?: (
redrawReason: string,
@ -255,9 +314,27 @@ export default memo(function Map({
if (screenshotMode) window.__map_idle = true;
}, [screenshotMode]);
const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => {
setInternalViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
}, []);
const handleFlyTo = useCallback(
(lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => {
setInternalViewState((prev) => {
const targetPoint =
getViewportRelativeVisibleAreaCenter(dimensions, containerRef.current, options) ??
getMapRelativeVisibleAreaCenter(dimensions, options);
const center = getMapCenterForTargetScreenPoint(
lat,
lng,
zoom,
dimensions.width,
dimensions.height,
targetPoint.x,
targetPoint.y
);
return { ...prev, ...center, zoom };
});
},
[dimensions]
);
if (flyToRef) flyToRef.current = handleFlyTo;
@ -361,7 +438,7 @@ export default memo(function Map({
) : null
) : (
<>
<div className="absolute top-3 left-3 right-3 z-[60] flex flex-wrap items-start justify-between gap-2 pointer-events-none">
<div className="absolute top-3 left-3 right-3 z-20 flex flex-wrap items-start justify-between gap-2 pointer-events-none">
{!hideLocationSearch && (
<LocationSearch
onFlyTo={handleFlyTo}

View file

@ -8,6 +8,7 @@ import type {
ViewState,
PostcodeGeometry,
Property,
MapFlyToOptions,
} from '../../types';
import type { SearchedLocation } from './LocationSearch';
import type { Page } from '../ui/Header';
@ -36,6 +37,7 @@ import { trackEvent } from '../../lib/analytics';
import { canWheelScrollInsideTarget } from '../../lib/dom-scroll';
import { INITIAL_VIEW_STATE } from '../../lib/consts';
import { getSchoolBackendFeatureName } from '../../lib/school-filter';
import { getSpecificCrimeFeatureName } from '../../lib/crime-filter';
import { useLicense } from '../../hooks/useLicense';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
@ -208,7 +210,11 @@ export default function MapPage({
handleToggleBest,
} = useTravelTime(initialTravelTime);
const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null);
const mapFlyToRef = useRef<
((lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void) | null
>(null);
const pendingCurrentLocationFlyToRef = useRef<{ lat: number; lng: number } | null>(null);
const mobileDrawerPanelRectRef = useRef<DOMRectReadOnly | null>(null);
const mapData = useMapData({
filters,
@ -349,8 +355,11 @@ export default function MapPage({
} = useHexagonSelection({
filters,
features,
hexagonData: mapData.committedHexagonData,
resolution: mapData.resolution,
usePostcodeView: mapData.usePostcodeView,
travelTimeEntries: entries,
shareCode,
journeyDest,
});
@ -379,15 +388,44 @@ export default function MapPage({
[handleLocationSearch, handleCloseSelection, isMobile]
);
const consumePendingCurrentLocationFlyTo = useCallback((rect?: DOMRectReadOnly | null) => {
const pending = pendingCurrentLocationFlyToRef.current;
const panelRect = rect ?? mobileDrawerPanelRectRef.current;
if (!pending || !panelRect) return;
const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
mapFlyToRef.current?.(pending.lat, pending.lng, 17, {
visibleViewportArea: { bottom: bottomInset },
});
pendingCurrentLocationFlyToRef.current = null;
}, []);
const handleCurrentLocationFound = useCallback(
(lat: number, lng: number) => {
if (isMobile) {
pendingCurrentLocationFlyToRef.current = { lat, lng };
consumePendingCurrentLocationFlyTo();
} else {
mapFlyToRef.current?.(lat, lng, 17);
}
setCurrentLocation({ lat, lng });
handleCurrentLocationSearch(lat, lng);
if (isMobile) setMobileDrawerOpen(true);
},
[handleCurrentLocationSearch, isMobile]
[consumePendingCurrentLocationFlyTo, handleCurrentLocationSearch, isMobile]
);
const handleMobileDrawerPanelRectChange = useCallback((rect: DOMRectReadOnly) => {
mobileDrawerPanelRectRef.current = rect;
consumePendingCurrentLocationFlyTo(rect);
}, [consumePendingCurrentLocationFlyTo]);
const handleMobileDrawerClose = useCallback(() => {
pendingCurrentLocationFlyToRef.current = null;
mobileDrawerPanelRectRef.current = null;
setMobileDrawerOpen(false);
}, []);
// For share-link recipients, "Continue with Demo" snaps back to the shared
// coords (the area their link was meant to show), not the central-London
// free-zone demo. Captured once on mount so a later URL rewrite by
@ -557,12 +595,19 @@ export default function MapPage({
const mobileLegendMeta = useMemo(() => {
const featureName = viewFeature
? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature)
? (getSchoolBackendFeatureName(viewFeature) ??
getSpecificCrimeFeatureName(viewFeature) ??
viewFeature)
: null;
return featureName ? features.find((f) => f.name === featureName) || null : null;
}, [viewFeature, features]);
const mapViewFeature = useMemo(
() => (viewFeature ? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature) : null),
() =>
viewFeature
? (getSchoolBackendFeatureName(viewFeature) ??
getSpecificCrimeFeatureName(viewFeature) ??
viewFeature)
: null,
[viewFeature]
);
const mobileDensityRange = useMemo((): [number, number] => {
@ -760,7 +805,7 @@ export default function MapPage({
onLoginRequired={onRegisterClick ?? (() => {})}
isLicensed={user?.subscription === 'licensed'}
onUpgradeClick={() => onNavigateTo('pricing')}
onResetTutorial={tutorial.resetTutorial}
onResetTutorial={!isMobile ? tutorial.resetTutorial : undefined}
filterImpacts={filterCounts.impacts}
onClearAll={handleClearAll}
onSaveSearch={onSaveSearch}
@ -914,10 +959,11 @@ export default function MapPage({
{mobileDrawerOpen && selectedHexagon && (
<Suspense fallback={<PaneFallback />}>
<MobileDrawer
onClose={() => setMobileDrawerOpen(false)}
onClose={handleMobileDrawerClose}
renderArea={renderAreaPane}
renderProperties={renderPropertiesPane}
tab={rightPaneTab}
onPanelRectChange={handleMobileDrawerPanelRectChange}
onTabChange={(t) => {
if (t === 'properties') {
handlePropertiesTabClick();

View file

@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useLayoutEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { TabButton } from '../ui/TabButton';
@ -9,6 +9,7 @@ interface MobileDrawerProps {
renderProperties: () => React.ReactNode;
tab: 'area' | 'properties';
onTabChange: (tab: 'area' | 'properties') => void;
onPanelRectChange?: (rect: DOMRectReadOnly) => void;
}
export default function MobileDrawer({
@ -17,8 +18,30 @@ export default function MobileDrawer({
renderProperties,
tab,
onTabChange,
onPanelRectChange,
}: MobileDrawerProps) {
const { t } = useTranslation();
const panelRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const panel = panelRef.current;
if (!panel || !onPanelRectChange) return;
const reportRect = () => onPanelRectChange(panel.getBoundingClientRect());
reportRect();
const observer = new ResizeObserver(reportRect);
observer.observe(panel);
window.addEventListener('resize', reportRect);
window.visualViewport?.addEventListener('resize', reportRect);
return () => {
observer.disconnect();
window.removeEventListener('resize', reportRect);
window.visualViewport?.removeEventListener('resize', reportRect);
};
}, [onPanelRectChange]);
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@ -34,7 +57,10 @@ export default function MobileDrawer({
<div className="h-[10%] bg-black/50" onClick={onClose} />
{/* Panel — bottom 90% */}
<div className="h-[90%] bg-white dark:bg-navy-950 rounded-t-2xl flex flex-col shadow-xl overflow-hidden">
<div
ref={panelRef}
className="h-[90%] bg-white dark:bg-navy-950 rounded-t-2xl flex flex-col shadow-xl overflow-hidden"
>
{/* Tab bar + close */}
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm shrink-0">
<TabButton

View file

@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { getPriceScale } from './PriceHistoryChart';
describe('PriceHistoryChart scale', () => {
it('uses a high percentile ceiling instead of the absolute max', () => {
const scale = getPriceScale([
...Array.from({ length: 19 }, (_, i) => ({
year: 2000 + i,
price: 3_000_000 + i * 10_000,
})),
{ year: 2025, price: 10_000_000 },
]);
expect(scale.max).toBeGreaterThan(3_000_000);
expect(scale.max).toBeLessThan(10_000_000);
expect(Math.max(...scale.ticks)).toBe(scale.max);
});
it('keeps single-value data visible with a padded domain', () => {
const scale = getPriceScale([
{ year: 2020, price: 2_500_000 },
{ year: 2021, price: 2_500_000 },
]);
expect(scale.min).toBeLessThan(2_500_000);
expect(scale.max).toBeGreaterThan(2_500_000);
});
});

View file

@ -8,8 +8,15 @@ interface PriceHistoryChartProps {
const PADDING = { top: 8, right: 24, bottom: 20, left: 42 };
const HEIGHT = 120;
const PRICE_SCALE_TOP_PERCENTILE = 95;
const priceFmt = { prefix: '£' };
interface PriceScale {
min: number;
max: number;
ticks: number[];
}
export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
@ -25,7 +32,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
return () => observer.disconnect();
}, []);
const { yearMin, yearMax, priceMin, priceMax, medians, priceTicks } = useMemo(() => {
const { yearMin, yearMax, priceScale, medians } = useMemo(() => {
let yMin = Infinity,
yMax = -Infinity;
for (const p of points) {
@ -33,14 +40,6 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
if (p.year > yMax) yMax = p.year;
}
// Use p5/p95 to clip outliers
const sorted = points.map((p) => p.price).sort((a, b) => a - b);
const p5 = sorted[Math.floor(sorted.length * 0.05)];
const p95 = sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95))];
const pRange = p95 - p5 || 1;
const pMin = Math.max(0, p5 - pRange * 0.1);
const pMax = p95 + pRange * 0.1;
// Yearly medians (robust to outliers)
const byYear = new Map<number, number[]>();
for (const p of points) {
@ -73,15 +72,11 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
return { year: pt.year + 0.5, price: sum / count };
});
const ticks = niceTicksForRange(pMin, pMax, 4);
return {
yearMin: yMin,
yearMax: yMax,
priceMin: pMin,
priceMax: pMax,
priceScale: getPriceScale(points),
medians: meds,
priceTicks: ticks,
};
}, [points]);
@ -91,8 +86,8 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
const scaleX = (year: number) => PADDING.left + ((year - yearMin) / yearRange) * plotW;
const scaleY = (price: number) => {
const t = (price - priceMin) / (priceMax - priceMin || 1);
return PADDING.top + (1 - Math.max(0, Math.min(1, t))) * plotH;
const t = (price - priceScale.min) / (priceScale.max - priceScale.min || 1);
return PADDING.top + (1 - t) * plotH;
};
// Year labels: every 5 years
@ -107,7 +102,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
{width > 0 && (
<svg width={width} height={HEIGHT}>
{/* Grid lines */}
{priceTicks.map((tick) => (
{priceScale.ticks.map((tick) => (
<line
key={tick}
x1={PADDING.left}
@ -119,7 +114,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
/>
))}
{/* Dots (clamp outliers to visible range) */}
{/* Dots */}
{points.map((p, i) => (
<circle
key={i}
@ -143,7 +138,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
)}
{/* Y-axis labels */}
{priceTicks.map((tick) => (
{priceScale.ticks.map((tick) => (
<text
key={`label-${tick}`}
x={PADDING.left - 4}
@ -176,6 +171,40 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
);
}
export function getPriceScale(points: PricePoint[]): PriceScale {
const prices = points
.map((p) => p.price)
.filter(Number.isFinite)
.sort((a, b) => a - b);
if (prices.length === 0) {
return { min: 0, max: 1, ticks: [0, 1] };
}
const min = prices[0];
const scaleTop = percentile(prices, PRICE_SCALE_TOP_PERCENTILE);
const range = scaleTop - min;
const padding = range > 0 ? range * 0.1 : Math.max(Math.abs(scaleTop) * 0.1, 1);
const paddedMin = Math.max(0, min - padding);
const paddedMax = scaleTop + padding;
const ticks = niceTicksForRange(paddedMin, paddedMax, 4);
return {
min: ticks[0] ?? paddedMin,
max: ticks[ticks.length - 1] ?? paddedMax,
ticks,
};
}
function percentile(sorted: number[], p: number): number {
const clamped = Math.max(0, Math.min(100, p));
const rank = ((sorted.length - 1) * clamped) / 100;
const lower = Math.floor(rank);
const upper = Math.ceil(rank);
if (lower === upper) return sorted[lower];
const weight = rank - lower;
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
}
/** Generate ~count nice round tick values spanning [min, max]. */
function niceTicksForRange(min: number, max: number, count: number): number[] {
const range = max - min;
@ -189,10 +218,17 @@ function niceTicksForRange(min: number, max: number, count: number): number[] {
else if (normalized <= 7.5) step = 5 * magnitude;
else step = 10 * magnitude;
const start =
min >= 0 ? Math.max(0, Math.floor(min / step) * step) : Math.floor(min / step) * step;
const end = Math.ceil(max / step) * step;
const ticks: number[] = [];
const start = Math.ceil(min / step) * step;
for (let t = start; t <= max; t += step) {
ticks.push(t);
for (let t = start; t <= end + step / 2; t += step) {
ticks.push(cleanTick(t));
}
return ticks;
}
function cleanTick(value: number): number {
return Number(value.toPrecision(12));
}

View file

@ -4,6 +4,7 @@ import { IconButton } from './IconButton';
interface FeatureActionsProps {
feature: FeatureMeta;
actionName?: string;
isPinned: boolean;
isPreviewing?: boolean;
onTogglePin: (name: string) => void;
@ -14,6 +15,7 @@ interface FeatureActionsProps {
export function FeatureActions({
feature,
actionName,
isPinned,
isPreviewing = false,
onTogglePin,
@ -22,6 +24,7 @@ export function FeatureActions({
onAdd,
}: FeatureActionsProps) {
const isEyeActive = isPinned || isPreviewing;
const callbackName = actionName ?? feature.name;
return (
<div className="flex items-center gap-0.5 shrink-0">
@ -31,7 +34,7 @@ export function FeatureActions({
</IconButton>
)}
<IconButton
onClick={() => onTogglePin(feature.name)}
onClick={() => onTogglePin(callbackName)}
title={isPinned ? 'Unpin colour view' : 'Colour map by this feature'}
active={isEyeActive}
size="md"
@ -40,7 +43,7 @@ export function FeatureActions({
</IconButton>
{onAdd && (
<button
onClick={() => onAdd(feature.name)}
onClick={() => onAdd(callbackName)}
title="Add filter"
className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40"
>
@ -48,7 +51,7 @@ export function FeatureActions({
</button>
)}
{onRemove && (
<IconButton onClick={() => onRemove(feature.name)} title="Remove filter">
<IconButton onClick={() => onRemove(callbackName)} title="Remove filter">
<CloseIcon className="w-3.5 h-3.5" />
</IconButton>
)}

View file

@ -140,19 +140,20 @@ export default function Header({
};
const tabClass = (page: Page) =>
`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
`inline-flex cursor-pointer items-center px-3 py-1.5 rounded text-sm font-medium transition-colors ${
activePage === page
? 'bg-navy-700 text-white'
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
}`;
return (
<>
<header className="relative z-50 h-12 bg-navy-900 text-white flex items-center px-4 shrink-0">
{/* Left: Logo + nav */}
<div className="flex items-center gap-4">
<a
href="/"
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
className="flex cursor-pointer items-center gap-2 hover:opacity-80 transition-opacity"
onClick={(e) => navLink('home', e)}
>
<LogoIcon className="w-5 h-5 text-teal-400" />
@ -206,7 +207,7 @@ export default function Header({
<button
onClick={handleShare}
disabled={sharing}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-wait"
className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:cursor-wait disabled:opacity-50"
>
{sharing ? (
<>
@ -228,7 +229,7 @@ export default function Header({
<button
onClick={onExport ?? undefined}
disabled={!onExport || exporting}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-wait"
className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:cursor-wait disabled:opacity-50"
title={t('header.exportToExcel')}
>
<DownloadIcon className="w-4 h-4" />
@ -238,7 +239,7 @@ export default function Header({
<button
onClick={onSaveSearch}
disabled={savingSearch}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-wait"
className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:cursor-wait disabled:opacity-50"
>
{savingSearch ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
@ -275,13 +276,13 @@ export default function Header({
<>
<button
onClick={onLoginClick}
className="px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
className="cursor-pointer px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
>
{t('header.logIn')}
</button>
<button
onClick={onRegisterClick}
className="px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium"
className="cursor-pointer px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium"
>
{t('header.createAccount')}
</button>
@ -294,7 +295,7 @@ export default function Header({
{isMobile && !user && (
<button
onClick={onRegisterClick}
className="px-4 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-semibold"
className="cursor-pointer px-4 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-semibold"
>
{t('header.createAccount')}
</button>
@ -307,10 +308,14 @@ export default function Header({
{!isMobile && !user && (
<button
onClick={onToggleTheme}
className="flex items-center justify-center w-8 h-8 rounded bg-navy-800 hover:bg-navy-700 transition-colors"
className="flex cursor-pointer items-center justify-center w-8 h-8 rounded bg-navy-800 hover:bg-navy-700 transition-colors"
title={theme === 'light' ? t('userMenu.themeLight') : t('userMenu.themeDark')}
>
{theme === 'light' ? <SunIcon className="w-4 h-4" /> : <MoonIcon className="w-4 h-4" />}
{theme === 'light' ? (
<SunIcon className="w-4 h-4" />
) : (
<MoonIcon className="w-4 h-4" />
)}
</button>
)}
@ -318,13 +323,14 @@ export default function Header({
{isMobile && (
<button
onClick={() => setMenuOpen(true)}
className="flex items-center justify-center w-10 h-10 -mr-2 rounded hover:bg-navy-800 transition-colors"
className="flex cursor-pointer items-center justify-center w-10 h-10 -mr-2 rounded hover:bg-navy-800 transition-colors"
aria-label={t('header.openMenu')}
>
<MenuIcon className="w-6 h-6" />
</button>
)}
</div>
</header>
{/* Mobile slide-in menu */}
{isMobile && menuOpen && (
@ -344,6 +350,7 @@ export default function Header({
onClose={() => setMenuOpen(false)}
onShare={handleShare}
copied={copied}
sharing={sharing}
/>
)}
{/* Mobile "Copied" toast */}
@ -353,6 +360,6 @@ export default function Header({
{t('common.copiedToClipboard')}
</div>
)}
</header>
</>
);
}

View file

@ -33,7 +33,7 @@ export default function LanguageDropdown() {
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1 px-2 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
className="flex cursor-pointer items-center gap-1 px-2 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
aria-label="Language"
>
<span className="text-base leading-none">{current.flag}</span>
@ -54,7 +54,7 @@ export default function LanguageDropdown() {
<button
key={lang.code}
onClick={() => changeLanguage(lang.code)}
className={`w-full flex items-center gap-2.5 px-3 py-1.5 text-sm ${
className={`w-full flex cursor-pointer items-center gap-2.5 px-3 py-1.5 text-sm ${
i18n.language === lang.code
? 'text-teal-600 dark:text-teal-400 font-medium bg-teal-50 dark:bg-teal-900/30'
: 'text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700'

View file

@ -28,6 +28,7 @@ interface MobileMenuProps {
onClose: () => void;
onShare: () => void;
copied: boolean;
sharing: boolean;
}
export default function MobileMenu({
@ -46,6 +47,7 @@ export default function MobileMenu({
onClose,
onShare,
copied,
sharing,
}: MobileMenuProps) {
const { t, i18n } = useTranslation();
@ -53,7 +55,7 @@ export default function MobileMenu({
<a
key={page}
href={PAGE_PATHS[page]}
className={`block w-full text-left px-4 py-3 text-base font-medium rounded ${
className={`block w-full cursor-pointer text-left px-3 py-2 text-sm font-medium rounded ${
activePage === page
? 'bg-navy-700 text-white'
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
@ -69,44 +71,43 @@ export default function MobileMenu({
</a>
);
return (
<>
{/* Backdrop */}
<div className="fixed inset-0 bg-black/50 z-[70]" onClick={onClose} />
{/* Menu panel */}
<div className="fixed top-0 right-0 bottom-0 w-64 bg-navy-900 z-[80] flex flex-col shadow-xl">
<div className="flex items-center justify-between px-4 h-12 border-b border-navy-700">
<span className="font-semibold">{t('mobileMenu.menu')}</span>
<button
onClick={onClose}
className="flex items-center justify-center w-10 h-10 -mr-2 rounded hover:bg-navy-800 transition-colors"
aria-label={t('header.closeMenu')}
>
<CloseIcon className="w-5 h-5" />
</button>
</div>
<nav className="flex-1 flex flex-col gap-1 p-3 overflow-y-auto">
{mobileNavItem('home', t('mobileMenu.home'))}
{mobileNavItem('dashboard', t('header.dashboard'))}
{mobileNavItem('learn', t('header.learn'))}
{user?.subscription !== 'licensed' &&
!user?.isAdmin &&
mobileNavItem('pricing', t('header.pricing'))}
{user && mobileNavItem('invites', t('header.inviteFriends'))}
{user && mobileNavItem('account', t('userMenu.account'))}
const dashboardActionClass =
'w-full flex cursor-pointer items-center justify-center gap-2 px-3 py-2 rounded bg-navy-800 text-sm font-semibold text-white border border-navy-700 shadow-sm hover:bg-navy-700 disabled:cursor-wait disabled:opacity-50 transition-colors';
{/* Dashboard actions */}
{activePage === 'dashboard' && (
<div className="mt-3 pt-3 border-t border-navy-700 flex flex-col gap-1">
const dashboardSavedItem = user && (
<a
href={PAGE_PATHS.saved}
className={dashboardActionClass}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
onPageChange('saved');
onClose();
}}
>
{t('header.saved')}
</a>
);
const dashboardActions = activePage === 'dashboard' && (
<div className="px-2 py-2 border-b border-navy-700">
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => {
onShare();
onClose();
}}
className="w-full flex items-center gap-2 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded"
disabled={sharing}
className={dashboardActionClass}
>
{copied ? <CheckIcon className="w-5 h-5" /> : <ClipboardIcon className="w-5 h-5" />}
{copied ? t('common.copied') : t('common.share')}
{sharing ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
) : copied ? (
<CheckIcon className="w-4 h-4" />
) : (
<ClipboardIcon className="w-4 h-4" />
)}
{sharing ? t('header.sharing') : copied ? t('common.copied') : t('common.share')}
</button>
<button
onClick={() => {
@ -114,9 +115,9 @@ export default function MobileMenu({
onClose();
}}
disabled={!onExport || exporting}
className="w-full flex items-center gap-2 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded disabled:opacity-50"
className={dashboardActionClass}
>
<DownloadIcon className="w-5 h-5" />
<DownloadIcon className="w-4 h-4" />
{exporting ? t('header.exporting') : t('header.exportLabel')}
</button>
{onSaveSearch && (
@ -126,37 +127,65 @@ export default function MobileMenu({
onClose();
}}
disabled={savingSearch}
className="w-full flex items-center gap-2 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded disabled:opacity-50"
className={dashboardActionClass}
>
{savingSearch ? (
<SpinnerIcon className="w-5 h-5 animate-spin" />
<SpinnerIcon className="w-4 h-4 animate-spin" />
) : (
<BookmarkIcon className="w-5 h-5" />
<BookmarkIcon className="w-4 h-4" />
)}
{t('common.save')}
</button>
)}
{user && mobileNavItem('saved', t('header.saved'))}
{dashboardSavedItem}
</div>
)}
</div>
);
return (
<>
{/* Backdrop */}
<div className="fixed inset-0 bg-black/50 z-[70]" onClick={onClose} />
{/* Menu panel */}
<div className="mobile-menu-panel fixed top-0 right-0 bottom-0 w-64 bg-navy-900 text-white z-[80] flex flex-col shadow-xl">
<div className="flex items-center justify-between px-3 h-11 border-b border-navy-700">
<span className="font-semibold">{t('mobileMenu.menu')}</span>
<button
onClick={onClose}
className="flex cursor-pointer items-center justify-center w-10 h-10 -mr-2 rounded hover:bg-navy-800 transition-colors"
aria-label={t('header.closeMenu')}
>
<CloseIcon className="w-5 h-5" />
</button>
</div>
{dashboardActions}
<nav className="flex-1 flex flex-col gap-0.5 p-2 overflow-y-auto">
{mobileNavItem('home', t('mobileMenu.home'))}
{mobileNavItem('dashboard', t('header.dashboard'))}
{mobileNavItem('learn', t('header.learn'))}
{user?.subscription !== 'licensed' &&
!user?.isAdmin &&
mobileNavItem('pricing', t('header.pricing'))}
{user && mobileNavItem('invites', t('header.inviteFriends'))}
{user && mobileNavItem('account', t('userMenu.account'))}
{activePage !== 'dashboard' && user && mobileNavItem('saved', t('header.saved'))}
</nav>
{/* Theme toggle + Auth section at bottom */}
<div className="p-3 border-t border-navy-700 flex flex-col gap-3">
<div className="p-2 border-t border-navy-700 flex flex-col gap-2">
{/* Theme toggle */}
<button
onClick={() => {
onToggleTheme();
}}
className="w-full flex items-center gap-3 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded transition-colors"
className="w-full flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-warm-300 hover:bg-navy-800 hover:text-white rounded transition-colors"
>
{theme === 'light' ? <SunIcon className="w-5 h-5" /> : <MoonIcon className="w-5 h-5" />}
<span>{theme === 'light' ? t('userMenu.themeLight') : t('userMenu.themeDark')}</span>
</button>
{/* Language selector */}
<div className="flex max-w-full gap-1 overflow-x-auto overflow-y-hidden px-4 pb-1 scrollbar-hide">
<div className="flex max-w-full gap-1 overflow-x-auto overflow-y-hidden px-3 pb-1 scrollbar-hide">
{SUPPORTED_LANGUAGES.map((lang) => (
<button
key={lang.code}
@ -165,7 +194,7 @@ export default function MobileMenu({
localStorage.setItem('language', lang.code);
void changeAppLanguage(lang.code);
}}
className={`flex-none min-w-[2.75rem] flex items-center justify-center gap-1.5 px-2 py-2 rounded text-sm ${
className={`flex-none min-w-[2.5rem] flex cursor-pointer items-center justify-center gap-1.5 px-2 py-1.5 rounded text-sm ${
i18n.language === lang.code
? 'bg-navy-700 text-white font-medium'
: 'text-warm-400 hover:bg-navy-800 hover:text-white'
@ -180,14 +209,14 @@ export default function MobileMenu({
{/* Auth buttons */}
<div>
{user ? (
<div className="flex items-center justify-between gap-3 px-4 py-2">
<div className="flex items-center justify-between gap-2 px-3 py-1.5">
<span className="text-sm text-warm-300 truncate">{user.email}</span>
<button
onClick={() => {
onLogout();
onClose();
}}
className="shrink-0 text-sm text-warm-400 hover:text-white"
className="shrink-0 cursor-pointer text-sm text-warm-400 hover:text-white"
>
{t('userMenu.logOut')}
</button>
@ -199,7 +228,7 @@ export default function MobileMenu({
onLoginClick();
onClose();
}}
className="flex-1 px-3 py-2.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm text-center"
className="flex-1 cursor-pointer px-3 py-2 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm text-center"
>
{t('header.logIn')}
</button>
@ -208,7 +237,7 @@ export default function MobileMenu({
onRegisterClick();
onClose();
}}
className="flex-1 px-3 py-2.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium text-center"
className="flex-1 cursor-pointer px-3 py-2 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium text-center"
>
{t('header.createAccount')}
</button>

View file

@ -41,7 +41,7 @@ export default function UserMenu({
<div className="relative" ref={menuRef}>
<button
onClick={() => setOpen((prev) => !prev)}
className="flex items-center justify-center w-8 h-8 rounded-full bg-teal-600 text-white text-sm font-medium hover:bg-teal-700"
className="flex cursor-pointer items-center justify-center w-8 h-8 rounded-full bg-teal-600 text-white text-sm font-medium hover:bg-teal-700"
title={user.email}
>
{initial}
@ -70,7 +70,7 @@ export default function UserMenu({
<div className="p-1">
<button
onClick={onToggleTheme}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
className="w-full flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
>
{theme === 'light' ? (
<SunIcon className="w-4 h-4" />
@ -88,7 +88,7 @@ export default function UserMenu({
setOpen(false);
onNavigate('account');
}}
className="block w-full text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
className="block w-full cursor-pointer text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
>
{t('userMenu.account')}
</a>
@ -97,7 +97,7 @@ export default function UserMenu({
setOpen(false);
onLogout();
}}
className="w-full text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
className="w-full cursor-pointer text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
>
{t('userMenu.logOut')}
</button>

View file

@ -366,7 +366,7 @@ export function useDeckLayers({
},
lineWidthUnits: 'pixels',
updateTriggers: {
getFillColor: [colorTrigger],
getFillColor: [colorTrigger, data],
getLineColor: [colorTrigger],
getLineWidth: [colorTrigger],
...(pieProps.updateTriggers || {}),

View file

@ -5,19 +5,73 @@ import {
SCHOOL_FILTER_NAME,
createSchoolFilterKey,
getDefaultSchoolFeatureName,
getSchoolBackendFeatureName,
getSchoolFilterKeyId,
normalizeSchoolFilters,
} from '../lib/school-filter';
import {
SPECIFIC_CRIMES_FILTER_NAME,
createSpecificCrimeFilterKey,
getDefaultSpecificCrimeFeatureName,
getSpecificCrimeFeatureName,
getSpecificCrimeFilterKeyId,
normalizeSpecificCrimeFilters,
} from '../lib/crime-filter';
interface UseFiltersOptions {
initialFilters: FeatureFilters;
features: FeatureMeta[];
}
function normalizeFilters(filters: FeatureFilters): FeatureFilters {
return normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters));
}
function getBackendFeatureName(name: string): string {
return getSchoolBackendFeatureName(name) ?? getSpecificCrimeFeatureName(name) ?? name;
}
function dropUnknownFilters(filters: FeatureFilters, features: FeatureMeta[]): FeatureFilters {
if (features.length === 0) return filters;
const knownFeatures = new Set(features.map((feature) => feature.name));
let changed = false;
const next: FeatureFilters = {};
for (const [name, value] of Object.entries(filters)) {
if (knownFeatures.has(getBackendFeatureName(name))) {
next[name] = value;
} else {
changed = true;
}
}
return changed ? next : filters;
}
function getNextNumericKeyId(
filters: FeatureFilters,
getId: (name: string) => string | null
): number {
let max = -1;
for (const name of Object.keys(filters)) {
const id = getId(name);
if (id == null) continue;
const numeric = Number(id);
if (Number.isInteger(numeric) && numeric >= 0) {
max = Math.max(max, numeric);
}
}
return max + 1;
}
export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const [filters, setFilters] = useState<FeatureFilters>(() =>
normalizeSchoolFilters(initialFilters)
);
const initialFiltersRef = useRef<FeatureFilters | null>(null);
if (!initialFiltersRef.current) {
initialFiltersRef.current = normalizeFilters(initialFilters);
}
const [filters, setFilters] = useState<FeatureFilters>(() => initialFiltersRef.current!);
const [activeFeature, setActiveFeature] = useState<string | null>(null);
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
@ -25,7 +79,12 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const dragActiveRef = useRef<string | null>(null);
const dragValueRef = useRef<[number, number] | null>(null);
const undoStackRef = useRef<FeatureFilters[]>([]);
const schoolFilterIdRef = useRef(1);
const schoolFilterIdRef = useRef(
getNextNumericKeyId(initialFiltersRef.current!, getSchoolFilterKeyId)
);
const specificCrimeFilterIdRef = useRef(
getNextNumericKeyId(initialFiltersRef.current!, getSpecificCrimeFilterKeyId)
);
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
@ -40,10 +99,25 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
return null;
}, [viewFeature, activeFeature, dragValue, filters]);
useEffect(() => {
if (features.length === 0) return;
const knownFeatures = new Set(features.map((feature) => feature.name));
setFilters((prev) => dropUnknownFilters(prev, features));
setPinnedFeature((prev) => {
if (!prev) return prev;
return knownFeatures.has(getBackendFeatureName(prev)) ? prev : null;
});
setActiveFeature((prev) => {
if (!prev) return prev;
return knownFeatures.has(getBackendFeatureName(prev)) ? prev : null;
});
}, [features]);
const handleAddFilter = useCallback(
(name: string) => {
const meta = features.find((f) => f.name === name);
if (name !== SCHOOL_FILTER_NAME && !meta) return;
if (name !== SCHOOL_FILTER_NAME && name !== SPECIFIC_CRIMES_FILTER_NAME && !meta) return;
trackEvent('Filter Add', { feature: name });
setFilters((prev) => {
undoStackRef.current.push(prev);
@ -67,6 +141,24 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
],
};
}
if (name === SPECIFIC_CRIMES_FILTER_NAME) {
const defaultCrimeFeatureName = getDefaultSpecificCrimeFeatureName(features);
const defaultCrimeFeature = defaultCrimeFeatureName
? features.find((feature) => feature.name === defaultCrimeFeatureName)
: undefined;
if (!defaultCrimeFeatureName) return prev;
return {
...prev,
[createSpecificCrimeFilterKey(
defaultCrimeFeatureName,
specificCrimeFilterIdRef.current++
)]: [
defaultCrimeFeature?.histogram?.min ?? defaultCrimeFeature?.min ?? 0,
defaultCrimeFeature?.histogram?.max ?? defaultCrimeFeature?.max ?? 100,
],
};
}
if (!meta) return prev;
if (meta.type === 'enum' && meta.values) {
return { ...prev, [name]: [...meta.values!] };
@ -105,7 +197,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
if (Array.isArray(value) && value.length === 0) {
const next = { ...prev };
delete next[name];
return normalizeSchoolFilters(next);
return normalizeFilters(next);
}
const schoolKeyId = getSchoolFilterKeyId(name);
@ -122,10 +214,27 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
}
next[existingName] = existingValue;
}
if (replaced) return normalizeSchoolFilters(next);
if (replaced) return normalizeFilters(next);
}
return normalizeSchoolFilters({ ...prev, [name]: value });
const specificCrimeKeyId = getSpecificCrimeFilterKeyId(name);
if (specificCrimeKeyId != null) {
let replaced = false;
const next: FeatureFilters = {};
for (const [existingName, existingValue] of Object.entries(prev)) {
if (getSpecificCrimeFilterKeyId(existingName) === specificCrimeKeyId) {
if (!replaced) {
next[name] = value;
replaced = true;
}
continue;
}
next[existingName] = existingValue;
}
if (replaced) return normalizeFilters(next);
}
return normalizeFilters({ ...prev, [name]: value });
});
}, []);
@ -169,7 +278,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const af = dragActiveRef.current;
const dv = dragValueRef.current;
if (af && dv) {
setFilters((prev) => ({ ...prev, [af]: dv }));
setFilters((prev) => normalizeFilters({ ...prev, [af]: dv }));
}
setActiveFeature(null);
setDragValue(null);
@ -193,7 +302,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
}, []);
const handleSetFilters = useCallback((newFilters: FeatureFilters) => {
setFilters(normalizeSchoolFilters(newFilters));
setFilters(normalizeFilters(newFilters));
setActiveFeature(null);
setDragValue(null);
setPinnedFeature(null);

View file

@ -1,17 +1,19 @@
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { cellToLatLng, cellToParent, latLngToCell } from 'h3-js';
import { cellToParent, latLngToCell } from 'h3-js';
import { trackEvent } from '../lib/analytics';
import type {
FeatureMeta,
FeatureFilters,
HexagonData,
Property,
PostcodeGeometry,
HexagonPropertiesResponse,
HexagonStatsResponse,
} from '../types';
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api';
const CURRENT_LOCATION_HEX_RESOLUTION = 12;
import { findOverlappingMatchingHexagon } from '../lib/h3-selection';
import { SMALLEST_VISIBLE_HEXAGON_RESOLUTION } from '../lib/consts';
import type { TravelTimeEntry } from './useTravelTime';
interface SelectedHexagon {
id: string;
@ -35,8 +37,11 @@ interface PostcodeLookupResponse {
interface UseHexagonSelectionOptions {
filters: FeatureFilters;
features: FeatureMeta[];
hexagonData: HexagonData[];
resolution: number;
usePostcodeView: boolean;
travelTimeEntries: TravelTimeEntry[];
shareCode?: string;
/** First transit destination — used to pick the best central_postcode for journey display. */
journeyDest?: JourneyDest | null;
}
@ -44,8 +49,11 @@ interface UseHexagonSelectionOptions {
export function useHexagonSelection({
filters,
features,
hexagonData,
resolution,
usePostcodeView,
travelTimeEntries,
shareCode,
journeyDest,
}: UseHexagonSelectionOptions) {
const [selectedHexagon, setSelectedHexagon] = useState<SelectedHexagon | null>(null);
@ -61,6 +69,40 @@ export function useHexagonSelection({
const [selectedPostcodeGeometry, setSelectedPostcodeGeometry] = useState<PostcodeGeometry | null>(
null
);
const areaRequestIdRef = useRef(0);
const propertiesRequestIdRef = useRef(0);
const invalidateAreaRequests = useCallback(() => {
areaRequestIdRef.current += 1;
return areaRequestIdRef.current;
}, []);
const invalidatePropertyRequests = useCallback(() => {
propertiesRequestIdRef.current += 1;
return propertiesRequestIdRef.current;
}, []);
const isCurrentAreaRequest = useCallback((requestId: number) => {
return areaRequestIdRef.current === requestId;
}, []);
const isCurrentPropertyRequest = useCallback((requestId: number) => {
return propertiesRequestIdRef.current === requestId;
}, []);
const travelParam = useMemo(() => {
const segments: string[] = [];
for (const entry of travelTimeEntries) {
if (!entry.slug) continue;
let segment = `${entry.mode}:${entry.slug}`;
if (entry.useBest) segment += ':best';
if (entry.timeRange) {
segment += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
}
segments.push(segment);
}
return segments.join('|');
}, [travelTimeEntries]);
const fetchHexagonStats = useCallback(
async (
@ -76,6 +118,8 @@ export function useHexagonSelection({
});
const filterStr = includeFilters ? buildFilterString(filters, features) : '';
if (filterStr) params.append('filters', filterStr);
if (includeFilters && travelParam) params.set('travel', travelParam);
if (shareCode) params.set('share', shareCode);
if (fields) {
params.set('fields', fields.join(';;'));
}
@ -87,7 +131,7 @@ export function useHexagonSelection({
assertOk(response, 'hexagon-stats');
return (await response.json()) as HexagonStatsResponse;
},
[filters, features, journeyDest]
[filters, features, journeyDest, shareCode, travelParam]
);
const fetchPostcodeStats = useCallback(
@ -95,14 +139,20 @@ export function useHexagonSelection({
const params = new URLSearchParams({ postcode });
const filterStr = includeFilters ? buildFilterString(filters, features) : '';
if (filterStr) params.append('filters', filterStr);
if (includeFilters && travelParam) params.set('travel', travelParam);
if (shareCode) params.set('share', shareCode);
const response = await fetch(apiUrl('postcode-stats', params), authHeaders({ signal }));
assertOk(response, 'postcode-stats');
return (await response.json()) as HexagonStatsResponse;
},
[filters, features]
[filters, features, shareCode, travelParam]
);
const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]);
const selectionQueryKey = useMemo(
() => [filterStr, travelParam, shareCode ?? ''].join('|'),
[filterStr, shareCode, travelParam]
);
const fetchUnfilteredAreaCount = useCallback(
async (selection: SelectedHexagon, signal?: AbortSignal) => {
@ -145,6 +195,7 @@ export function useHexagonSelection({
const fetchHexagonProperties = useCallback(
async (h3: string, res: number, offset = 0) => {
const requestId = invalidatePropertyRequests();
setLoadingProperties(true);
try {
const params = new URLSearchParams({
@ -156,10 +207,13 @@ export function useHexagonSelection({
const filterStr = buildFilterString(filters, features);
if (filterStr) params.append('filters', filterStr);
if (travelParam) params.set('travel', travelParam);
if (shareCode) params.set('share', shareCode);
const response = await fetch(apiUrl('hexagon-properties', params), authHeaders());
assertOk(response, 'hexagon-properties');
const data: HexagonPropertiesResponse = await response.json();
if (!isCurrentPropertyRequest(requestId)) return;
if (offset === 0) {
setProperties(data.properties);
@ -171,14 +225,22 @@ export function useHexagonSelection({
} catch (err) {
logNonAbortError('Failed to fetch properties', err);
} finally {
setLoadingProperties(false);
if (isCurrentPropertyRequest(requestId)) setLoadingProperties(false);
}
},
[filters, features]
[
filters,
features,
invalidatePropertyRequests,
isCurrentPropertyRequest,
shareCode,
travelParam,
]
);
const fetchPostcodeProperties = useCallback(
async (postcode: string, offset = 0, focusAddress?: string) => {
const requestId = invalidatePropertyRequests();
setLoadingProperties(true);
try {
const params = new URLSearchParams({
@ -192,10 +254,13 @@ export function useHexagonSelection({
const filterStr = buildFilterString(filters, features);
if (filterStr) params.append('filters', filterStr);
if (travelParam) params.set('travel', travelParam);
if (shareCode) params.set('share', shareCode);
const response = await fetch(apiUrl('postcode-properties', params), authHeaders());
assertOk(response, 'postcode-properties');
const data: HexagonPropertiesResponse = await response.json();
if (!isCurrentPropertyRequest(requestId)) return;
if (offset === 0) {
setProperties(data.properties);
@ -207,15 +272,24 @@ export function useHexagonSelection({
} catch (err) {
logNonAbortError('Failed to fetch postcode properties', err);
} finally {
setLoadingProperties(false);
if (isCurrentPropertyRequest(requestId)) setLoadingProperties(false);
}
},
[filters, features]
[
filters,
features,
invalidatePropertyRequests,
isCurrentPropertyRequest,
shareCode,
travelParam,
]
);
const handleHexagonClick = useCallback(
(id: string, isPostcode = false, geometry?: PostcodeGeometry) => {
if (selectedHexagon?.id === id) {
invalidateAreaRequests();
invalidatePropertyRequests();
setSelectedHexagon(null);
setProperties([]);
setAreaStats(null);
@ -224,6 +298,8 @@ export function useHexagonSelection({
} else {
const type: SelectedHexagon['type'] = isPostcode ? 'postcode' : 'hexagon';
const selection = { id, type, resolution };
const requestId = invalidateAreaRequests();
invalidatePropertyRequests();
trackEvent('Hexagon Click', { type });
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(isPostcode && geometry ? geometry : null);
@ -237,24 +313,39 @@ export function useHexagonSelection({
setLoadingAreaStats(true);
fetchPostcodeStats(id)
.then((stats) => {
if (!isCurrentAreaRequest(requestId)) return;
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
})
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
.finally(() => setLoadingAreaStats(false));
.finally(() => {
if (isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
});
} else {
setLoadingAreaStats(true);
fetchHexagonStats(id, resolution)
.then((stats) => {
if (!isCurrentAreaRequest(requestId)) return;
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
})
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
.finally(() => setLoadingAreaStats(false));
.finally(() => {
if (isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
});
}
}
},
[selectedHexagon, resolution, fetchHexagonStats, fetchPostcodeStats, refreshUnfilteredAreaCount]
[
selectedHexagon,
resolution,
fetchHexagonStats,
fetchPostcodeStats,
invalidateAreaRequests,
invalidatePropertyRequests,
isCurrentAreaRequest,
refreshUnfilteredAreaCount,
]
);
const handleHexagonHover = useCallback((h3: string | null) => {
@ -301,12 +392,14 @@ export function useHexagonSelection({
}, [selectedHexagon, propertiesOffset, fetchHexagonProperties, fetchPostcodeProperties]);
const handleCloseSelection = useCallback(() => {
invalidateAreaRequests();
invalidatePropertyRequests();
setSelectedHexagon(null);
setProperties([]);
setAreaStats(null);
setUnfilteredAreaCount(null);
setSelectedPostcodeGeometry(null);
}, []);
}, [invalidateAreaRequests, invalidatePropertyRequests]);
// Keep the selected area aligned with the active map view as zoom changes.
useEffect(() => {
@ -324,6 +417,18 @@ export function useHexagonSelection({
selection.resolution !== resolution);
if (!shouldSync) return;
const zoomingIntoHexagon =
!usePostcodeView &&
selection.type === 'hexagon' &&
!selection.lockedResolution &&
selection.resolution < resolution;
const overlappingHexagon = zoomingIntoHexagon
? findOverlappingMatchingHexagon(selection.id, hexagonData, resolution)
: null;
if (zoomingIntoHexagon && !overlappingHexagon) return;
const requestId = invalidateAreaRequests();
invalidatePropertyRequests();
let cancelled = false;
const controller = new AbortController();
@ -361,14 +466,15 @@ export function useHexagonSelection({
const nextId =
resolution < selection.resolution
? cellToParent(selection.id, resolution)
: latLngToCell(...cellToLatLng(selection.id), resolution);
: overlappingHexagon?.h3;
if (!nextId) return;
nextSelection = { id: nextId, type: 'hexagon', resolution };
nextStats = await fetchHexagonStats(nextId, resolution, controller.signal);
} else {
return;
}
if (cancelled || !nextSelection || !nextStats) return;
if (cancelled || !isCurrentAreaRequest(requestId) || !nextSelection || !nextStats) return;
setSelectedHexagon(nextSelection);
setSelectedPostcodeGeometry(nextGeometry);
setAreaStats(nextStats);
@ -387,7 +493,7 @@ export function useHexagonSelection({
if (!cancelled) logNonAbortError('Failed to sync selected area with map view', error);
})
.finally(() => {
if (!cancelled) setLoadingAreaStats(false);
if (!cancelled && isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
});
return () => {
@ -396,6 +502,7 @@ export function useHexagonSelection({
};
}, [
selectedHexagon,
hexagonData,
resolution,
usePostcodeView,
areaStats?.central_postcode,
@ -404,16 +511,19 @@ export function useHexagonSelection({
fetchPostcodeLookup,
fetchHexagonProperties,
fetchPostcodeProperties,
invalidateAreaRequests,
invalidatePropertyRequests,
isCurrentAreaRequest,
refreshUnfilteredAreaCount,
rightPaneTab,
]);
// Re-fetch stats when filters change while a hexagon is selected
const prevFilterStr = useRef(filterStr);
// Re-fetch stats when filters or travel constraints change while an area is selected
const prevSelectionQueryKey = useRef(selectionQueryKey);
useEffect(() => {
if (prevFilterStr.current === filterStr) return;
prevFilterStr.current = filterStr;
if (prevSelectionQueryKey.current === selectionQueryKey) return;
prevSelectionQueryKey.current = selectionQueryKey;
if (!selectedHexagon) return;
@ -424,6 +534,7 @@ export function useHexagonSelection({
setLoadingAreaStats(true);
let cancelled = false;
const requestId = invalidateAreaRequests();
const fetchStats =
selectedHexagon.type === 'postcode'
@ -432,7 +543,7 @@ export function useHexagonSelection({
fetchStats
.then((stats) => {
if (cancelled) return;
if (cancelled || !isCurrentAreaRequest(requestId)) return;
setAreaStats(stats);
refreshUnfilteredAreaCount(selectedHexagon, stats.count);
// Re-fetch properties if the properties tab is active and the filtered area still has matches.
@ -445,24 +556,26 @@ export function useHexagonSelection({
}
})
.catch((error) => {
if (cancelled) return;
if (cancelled || !isCurrentAreaRequest(requestId)) return;
logNonAbortError('Failed to refresh stats', error);
})
.finally(() => {
if (!cancelled) setLoadingAreaStats(false);
if (!cancelled && isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
});
return () => {
cancelled = true;
};
}, [
filterStr,
selectionQueryKey,
selectedHexagon,
fetchHexagonStats,
fetchPostcodeStats,
rightPaneTab,
fetchHexagonProperties,
fetchPostcodeProperties,
invalidateAreaRequests,
isCurrentAreaRequest,
refreshUnfilteredAreaCount,
]);
@ -475,6 +588,8 @@ export function useHexagonSelection({
openProperties = false,
focusAddress?: string
) => {
const requestId = invalidateAreaRequests();
invalidatePropertyRequests();
trackEvent(openProperties ? 'Address Search' : 'Postcode Search');
setProperties([]);
setPropertiesTotal(0);
@ -486,6 +601,7 @@ export function useHexagonSelection({
// First try the postcode; if it has no properties, fall back to hexagons
fetchPostcodeStats(postcode)
.then(async (stats) => {
if (!isCurrentAreaRequest(requestId)) return;
if (stats.count > 0) {
const selection = { id: postcode, type: 'postcode' as const, resolution };
setSelectedHexagon(selection);
@ -515,6 +631,7 @@ export function useHexagonSelection({
for (const res of resolutions) {
const h3 = latLngToCell(lat, lng, res);
const hexStats = await fetchHexagonStats(h3, res);
if (!isCurrentAreaRequest(requestId)) return;
if (hexStats.count > 1) {
const selection = { id: h3, type: 'hexagon' as const, resolution: res };
setSelectedHexagon(selection);
@ -529,6 +646,7 @@ export function useHexagonSelection({
// Even the coarsest hexagon has ≤1 property — show whatever the finest has
const h3 = latLngToCell(lat, lng, 9);
const fallbackStats = await fetchHexagonStats(h3, 9);
if (!isCurrentAreaRequest(requestId)) return;
const selection = { id: h3, type: 'hexagon' as const, resolution: 9 };
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(null);
@ -537,24 +655,31 @@ export function useHexagonSelection({
setRightPaneTab('area');
})
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
.finally(() => setLoadingAreaStats(false));
.finally(() => {
if (isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
});
},
[
resolution,
fetchPostcodeStats,
fetchHexagonStats,
fetchPostcodeProperties,
invalidateAreaRequests,
invalidatePropertyRequests,
isCurrentAreaRequest,
refreshUnfilteredAreaCount,
]
);
const handleCurrentLocationSearch = useCallback(
(lat: number, lng: number) => {
const h3 = latLngToCell(lat, lng, CURRENT_LOCATION_HEX_RESOLUTION);
const requestId = invalidateAreaRequests();
invalidatePropertyRequests();
const h3 = latLngToCell(lat, lng, SMALLEST_VISIBLE_HEXAGON_RESOLUTION);
const selection = {
id: h3,
type: 'hexagon' as const,
resolution: CURRENT_LOCATION_HEX_RESOLUTION,
resolution: SMALLEST_VISIBLE_HEXAGON_RESOLUTION,
lockedResolution: true,
};
@ -568,15 +693,24 @@ export function useHexagonSelection({
setRightPaneTab('area');
setLoadingAreaStats(true);
fetchHexagonStats(h3, CURRENT_LOCATION_HEX_RESOLUTION)
fetchHexagonStats(h3, SMALLEST_VISIBLE_HEXAGON_RESOLUTION)
.then((stats) => {
if (!isCurrentAreaRequest(requestId)) return;
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
})
.catch((error) => logNonAbortError('Failed to fetch current location hex stats', error))
.finally(() => setLoadingAreaStats(false));
.finally(() => {
if (isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
});
},
[fetchHexagonStats, refreshUnfilteredAreaCount]
[
fetchHexagonStats,
invalidateAreaRequests,
invalidatePropertyRequests,
isCurrentAreaRequest,
refreshUnfilteredAreaCount,
]
);
return {

View file

@ -0,0 +1,100 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useMapData } from './useMapData';
import type { ApiResponse, Bounds, ViewChangeParams } from '../types';
vi.mock('../lib/pocketbase', () => ({
default: { authStore: { isValid: false, token: '' } },
}));
function response(features: ApiResponse['features']): Response {
return new Response(JSON.stringify({ features }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
function viewChange(bounds: Bounds): ViewChangeParams {
return {
resolution: 8,
bounds,
zoom: 10,
latitude: (bounds.south + bounds.north) / 2,
longitude: (bounds.west + bounds.east) / 2,
};
}
async function flushPromises() {
await Promise.resolve();
await Promise.resolve();
}
describe('useMapData', () => {
const requests: Array<{ url: string; resolve: (response: Response) => void }> = [];
beforeEach(() => {
vi.useFakeTimers();
requests.length = 0;
vi.stubGlobal(
'fetch',
vi.fn((url: string | URL | Request) => {
return new Promise<Response>((resolve) => {
requests.push({ url: String(url), resolve });
});
})
);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('ignores a stale map response after the view has already changed', async () => {
const { result } = renderHook(() =>
useMapData({
filters: {},
features: [],
viewFeature: null,
activeFeature: null,
pinnedFeature: null,
travelTimeEntries: [],
})
);
await act(async () => {
result.current.handleViewChange(
viewChange({ south: 1, west: 1, north: 2, east: 2 })
);
});
await act(async () => {
vi.advanceTimersByTime(150);
});
expect(requests).toHaveLength(1);
await act(async () => {
result.current.handleViewChange(
viewChange({ south: 3, west: 3, north: 4, east: 4 })
);
});
await act(async () => {
requests[0].resolve(response([{ h3: 'old', count: 99, lat: 1.5, lon: 1.5 }]));
await flushPromises();
});
expect(result.current.data).toEqual([]);
await act(async () => {
vi.advanceTimersByTime(150);
});
expect(requests).toHaveLength(2);
await act(async () => {
requests[1].resolve(response([{ h3: 'new', count: 7, lat: 3.5, lon: 3.5 }]));
await flushPromises();
});
expect(result.current.data).toEqual([{ h3: 'new', count: 7, lat: 3.5, lon: 3.5 }]);
});
});

View file

@ -17,6 +17,7 @@ import {
isAbortError,
} from '../lib/api';
import { getSchoolBackendFeatureName } from '../lib/school-filter';
import { getSpecificCrimeFeatureName } from '../lib/crime-filter';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
import { type TravelTimeEntry } from './useTravelTime';
@ -75,19 +76,26 @@ export function useMapData({
const dragFeatureRef = useRef<string | null>(null);
const dragAbortRef = useRef<AbortController | null>(null);
const activeFeatureRef = useRef<string | null>(null);
const latestDataRequestKeyRef = useRef<string>('');
const latestDragRequestKeyRef = useRef<string>('');
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const prevBoundsRef = useRef<string>('');
const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD;
const getBackendFeatureName = useCallback(
(name: string) =>
getSchoolBackendFeatureName(name) ?? getSpecificCrimeFeatureName(name) ?? name,
[]
);
const dataViewFeature = useMemo(
() => (viewFeature ? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature) : null),
[viewFeature]
() => (viewFeature ? getBackendFeatureName(viewFeature) : null),
[getBackendFeatureName, viewFeature]
);
const pinnedDataViewFeature = useMemo(
() => (pinnedFeature ? (getSchoolBackendFeatureName(pinnedFeature) ?? pinnedFeature) : null),
[pinnedFeature]
() => (pinnedFeature ? getBackendFeatureName(pinnedFeature) : null),
[getBackendFeatureName, pinnedFeature]
);
// Determine if the current viewFeature is an enum (for enum_dist param)
@ -166,6 +174,14 @@ export function useMapData({
activeFeatureRef.current = activeFeature;
}, [activeFeature]);
useEffect(() => {
if (activeFeature) return;
latestDragRequestKeyRef.current = '';
dragFeatureRef.current = null;
setDragHexData(null);
setDragPostcodeData(null);
}, [activeFeature]);
// Drag prefetch: when activeFeature starts, fetch data excluding that filter.
// For regular filters: excludes the filter from the filter string.
// For travel time: excludes the time range from that entry's travel param segment.
@ -178,11 +194,23 @@ export function useMapData({
const filtersStr = buildFilterString(filters, features, activeFeature);
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const isTravelTimeDrag = activeFeature.startsWith('tt_');
const dataActiveFeature = getSchoolBackendFeatureName(activeFeature) ?? activeFeature;
const dataActiveFeature = getBackendFeatureName(activeFeature);
const dragTravelParam = isTravelTimeDrag ? buildTravelParam(activeFeature) : travelParam;
// Travel time fields are computed from the travel param, not regular feature columns.
// Sending a tt_* name as fields would cause a 400 (unknown field). Use empty string instead.
const fieldsParam = isTravelTimeDrag ? '' : dataActiveFeature;
const requestKey = [
usePostcodeView ? 'postcodes' : 'hexagons',
activeFeature,
resolution,
boundsStr,
filtersStr,
fieldsParam,
dragTravelParam,
viewFeatureIsEnum && dataViewFeature ? dataViewFeature : '',
shareCode ?? '',
].join('|');
latestDragRequestKeyRef.current = requestKey;
if (usePostcodeView) {
const params = new URLSearchParams({ bounds: boundsStr });
@ -195,6 +223,7 @@ export function useMapData({
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json())
.then((json: { features: PostcodeFeature[] }) => {
if (latestDragRequestKeyRef.current !== requestKey) return;
setDragPostcodeData(json.features);
setDragHexData(null);
dragFeatureRef.current = activeFeature;
@ -214,6 +243,7 @@ export function useMapData({
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json())
.then((json: ApiResponse) => {
if (latestDragRequestKeyRef.current !== requestKey) return;
setDragHexData(json.features);
setDragPostcodeData(null);
dragFeatureRef.current = activeFeature;
@ -226,6 +256,9 @@ export function useMapData({
dragAbortRef.current.abort();
dragAbortRef.current = null;
}
if (latestDragRequestKeyRef.current === requestKey) {
latestDragRequestKeyRef.current = '';
}
};
}, [
activeFeature,
@ -237,13 +270,18 @@ export function useMapData({
travelParam,
buildTravelParam,
dataViewFeature,
getBackendFeatureName,
viewFeatureIsEnum,
shareCode,
]);
// Fetch hexagons or postcodes when bounds/filters change
useEffect(() => {
if (!bounds) return;
if (!bounds) {
latestDataRequestKeyRef.current = '';
return;
}
latestDataRequestKeyRef.current = dataRequestKey;
if (debounceRef.current) {
clearTimeout(debounceRef.current);
@ -255,10 +293,9 @@ export function useMapData({
}
abortControllerRef.current = new AbortController();
const requestKey = dataRequestKey;
setLoading(true);
try {
const requestKey = dataRequestKey;
if (usePostcodeView) {
const params = new URLSearchParams({ bounds: boundsParam });
if (filtersParam) params.set('filters', filtersParam);
@ -279,6 +316,7 @@ export function useMapData({
);
if (res.status === 403) {
const errBody = await res.json();
if (requestKey !== latestDataRequestKeyRef.current) return;
if (errBody.error === 'license_required' && errBody.free_zone) {
setLicenseRequired(true);
setFreeZone(errBody.free_zone);
@ -287,8 +325,10 @@ export function useMapData({
}
}
assertOk(res, 'postcodes');
if (requestKey !== latestDataRequestKeyRef.current) return;
setLicenseRequired(false);
const json: { features: PostcodeFeature[] } = await res.json();
if (requestKey !== latestDataRequestKeyRef.current) return;
setPostcodeData(json.features);
setRawData([]);
setLoadedDataKey(requestKey);
@ -315,6 +355,7 @@ export function useMapData({
);
if (res.status === 403) {
const errBody = await res.json();
if (requestKey !== latestDataRequestKeyRef.current) return;
if (errBody.error === 'license_required' && errBody.free_zone) {
setLicenseRequired(true);
setFreeZone(errBody.free_zone);
@ -323,8 +364,10 @@ export function useMapData({
}
}
assertOk(res, 'hexagons');
if (requestKey !== latestDataRequestKeyRef.current) return;
setLicenseRequired(false);
const json: ApiResponse = await res.json();
if (requestKey !== latestDataRequestKeyRef.current) return;
setRawData(json.features);
setPostcodeData([]);
setLoadedDataKey(requestKey);
@ -338,7 +381,7 @@ export function useMapData({
}
setLoading(false);
} catch (err) {
if (!isAbortError(err)) {
if (requestKey === latestDataRequestKeyRef.current && !isAbortError(err)) {
logNonAbortError('Failed to fetch data', err);
setLoading(false);
}
@ -349,6 +392,10 @@ export function useMapData({
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
};
}, [
resolution,
@ -366,10 +413,12 @@ export function useMapData({
// Use drag data when it matches the current view feature, otherwise fall back to rawData
const data =
(viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ?? rawData;
(activeFeature && viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ??
rawData;
const effectivePostcodeData =
(viewFeature && dragFeatureRef.current === viewFeature ? dragPostcodeData : null) ??
postcodeData;
(activeFeature && viewFeature && dragFeatureRef.current === viewFeature
? dragPostcodeData
: null) ?? postcodeData;
// Compute p5/p95 from committed data for the viewed feature.
// Always uses rawData/postcodeData (not drag preview data) so the color
@ -548,6 +597,7 @@ export function useMapData({
return {
data,
committedHexagonData: rawData,
postcodeData: effectivePostcodeData,
resolution,
bounds,

View file

@ -49,14 +49,12 @@ const descriptions: Record<string, Record<string, string>> = {
'Collèges/lycées notés Excellent par Ofsted dans un rayon de 5 km',
'Education, Skills and Training Score':
'Score de qualité éducative du secteur (plus élevé = meilleur)',
'Income Score (rate)': 'Taux de précarité de revenu, inversé (plus élevé = moins précaire)',
'Employment Score (rate)': 'Taux de précarité demploi, inversé (plus élevé = moins précaire)',
'Income Score': 'Taux de précarité de revenu, inversé (plus élevé = moins précaire)',
'Employment Score': 'Taux de précarité demploi, inversé (plus élevé = moins précaire)',
'Health Deprivation and Disability Score':
'Score de santé et handicap (plus élevé = meilleurs résultats)',
'Living Environment Score':
'Qualité de lenvironnement intérieur et extérieur (plus élevé = meilleur)',
'Indoors Sub-domain Score': 'Qualité et état du logement (plus élevé = meilleur)',
'Outdoors Sub-domain Score': 'Qualité de lair et sécurité routière (plus élevé = meilleur)',
'Housing Conditions Score': 'Qualité et état du logement (plus élevé = meilleur)',
'Air Quality and Road Safety Score': 'Qualité de lair et sécurité routière (plus élevé = meilleur)',
'Serious crime per 1k residents (avg/yr)': 'Taux de crimes graves pour 1 000 habitants par an',
'Minor crime per 1k residents (avg/yr)': 'Taux de délits mineurs pour 1 000 habitants par an',
'Serious crime (avg/yr)': 'Agrégat des catégories de crimes graves par an',
@ -139,15 +137,14 @@ const descriptions: Record<string, Record<string, string>> = {
'Outstanding secondary schools within 5km':
'Von Ofsted mit Hervorragend bewertete weiterführende Schulen im Umkreis von 5 km',
'Education, Skills and Training Score': 'Bildungsqualitätsscore der Gegend (höher = besser)',
'Income Score (rate)':
'Income Score':
'Einkommensbenachteiligungsrate, invertiert (höher = weniger benachteiligt)',
'Employment Score (rate)':
'Employment Score':
'Beschäftigungsbenachteiligungsrate, invertiert (höher = weniger benachteiligt)',
'Health Deprivation and Disability Score':
'Gesundheits- und Behinderungsscore (höher = bessere Ergebnisse)',
'Living Environment Score': 'Qualität der Innen- und Außenumgebung (höher = besser)',
'Indoors Sub-domain Score': 'Wohnqualität und -zustand (höher = besser)',
'Outdoors Sub-domain Score': 'Luftqualität und Verkehrssicherheit (höher = besser)',
'Housing Conditions Score': 'Wohnqualität und -zustand (höher = besser)',
'Air Quality and Road Safety Score': 'Luftqualität und Verkehrssicherheit (höher = besser)',
'Serious crime per 1k residents (avg/yr)':
'Rate schwerer Straftaten pro 1.000 Einwohner pro Jahr',
'Minor crime per 1k residents (avg/yr)':
@ -224,12 +221,11 @@ const descriptions: Record<string, Record<string, string>> = {
'Outstanding primary schools within 5km': 'Ofsted评为优秀的5公里内小学',
'Outstanding secondary schools within 5km': 'Ofsted评为优秀的5公里内中学',
'Education, Skills and Training Score': '当地教育质量得分(越高越好)',
'Income Score (rate)': '收入贫困率,反向指标(越高越不贫困)',
'Employment Score (rate)': '就业贫困率,反向指标(越高越不贫困)',
'Income Score': '收入贫困率,反向指标(越高越不贫困)',
'Employment Score': '就业贫困率,反向指标(越高越不贫困)',
'Health Deprivation and Disability Score': '健康与残障得分(越高健康状况越好)',
'Living Environment Score': '室内外环境质量(越高越好)',
'Indoors Sub-domain Score': '住房质量和状况(越高越好)',
'Outdoors Sub-domain Score': '空气质量和道路安全(越高越好)',
'Housing Conditions Score': '住房质量和状况(越高越好)',
'Air Quality and Road Safety Score': '空气质量和道路安全(越高越好)',
'Serious crime per 1k residents (avg/yr)': '每千人每年严重犯罪率',
'Minor crime per 1k residents (avg/yr)': '每千人每年轻微犯罪率',
'Serious crime (avg/yr)': '严重犯罪类别年度总计',
@ -297,12 +293,11 @@ const descriptions: Record<string, Record<string, string>> = {
'Outstanding primary schools within 5km': '5 किमी के भीतर Ofsted Outstanding प्राइमरी स्कूल',
'Outstanding secondary schools within 5km': '5 किमी के भीतर Ofsted Outstanding सेकेंडरी स्कूल',
'Education, Skills and Training Score': 'स्थानीय शिक्षा गुणवत्ता स्कोर (अधिक = बेहतर)',
'Income Score (rate)': 'आय वंचना दर, उलटी की गई (अधिक = कम वंचना)',
'Employment Score (rate)': 'रोजगार वंचना दर, उलटी की गई (अधिक = कम वंचना)',
'Income Score': 'आय वंचना दर, उलटी की गई (अधिक = कम वंचना)',
'Employment Score': 'रोजगार वंचना दर, उलटी की गई (अधिक = कम वंचना)',
'Health Deprivation and Disability Score': 'स्वास्थ्य और विकलांगता स्कोर (अधिक = बेहतर परिणाम)',
'Living Environment Score': 'घर और बाहरी वातावरण की गुणवत्ता (अधिक = बेहतर)',
'Indoors Sub-domain Score': 'आवास गुणवत्ता और स्थिति (अधिक = बेहतर)',
'Outdoors Sub-domain Score': 'हवा की गुणवत्ता और सड़क सुरक्षा (अधिक = बेहतर)',
'Housing Conditions Score': 'आवास गुणवत्ता और स्थिति (अधिक = बेहतर)',
'Air Quality and Road Safety Score': 'हवा की गुणवत्ता और सड़क सुरक्षा (अधिक = बेहतर)',
'Serious crime per 1k residents (avg/yr)': 'प्रति 1,000 निवासियों सालाना गंभीर अपराध दर',
'Minor crime per 1k residents (avg/yr)': 'प्रति 1,000 निवासियों सालाना मामूली अपराध दर',
'Serious crime (avg/yr)': 'गंभीर अपराध श्रेणियों का सालाना कुल',
@ -380,14 +375,13 @@ const descriptions: Record<string, Record<string, string>> = {
'Ofsted által Kiváló minősítésű középiskolák 5 km-en belül',
'Education, Skills and Training Score':
'A környék oktatási minőségi pontszáma (magasabb = jobb)',
'Income Score (rate)': 'Jövedelmi deprivációs ráta, invertálva (magasabb = kevésbé hátrányos)',
'Employment Score (rate)':
'Income Score': 'Jövedelmi deprivációs ráta, invertálva (magasabb = kevésbé hátrányos)',
'Employment Score':
'Foglalkoztatási deprivációs ráta, invertálva (magasabb = kevésbé hátrányos)',
'Health Deprivation and Disability Score':
'Egészségügyi és fogyatékossági pontszám (magasabb = jobb eredmények)',
'Living Environment Score': 'Belső és külső környezet minősége (magasabb = jobb)',
'Indoors Sub-domain Score': 'Lakásminőség és állapot (magasabb = jobb)',
'Outdoors Sub-domain Score': 'Levegőminőség és közlekedésbiztonság (magasabb = jobb)',
'Housing Conditions Score': 'Lakásminőség és állapot (magasabb = jobb)',
'Air Quality and Road Safety Score': 'Levegőminőség és közlekedésbiztonság (magasabb = jobb)',
'Serious crime per 1k residents (avg/yr)': 'Súlyos bűncselekmények aránya 1000 lakosra évente',
'Minor crime per 1k residents (avg/yr)': 'Kisebb bűncselekmények aránya 1000 lakosra évente',
'Serious crime (avg/yr)': 'Súlyos bűncselekményi kategóriák éves összesítése',

View file

@ -55,17 +55,15 @@ export const details: Record<string, Record<string, string>> = {
"Lycées et collèges financés par l'État dans un rayon de 5km ayant une note Ofsted actuelle d'Exceptionnel. Les établissements n'ayant pas encore été inspectés sont exclus.",
'Education, Skills and Training Score':
"Provient des Indices de Déprivation anglais (inversé afin que plus le score est élevé, meilleur est le résultat). Couvre les résultats scolaires, l'accès à l'enseignement supérieur, les qualifications des adultes et la maîtrise de la langue anglaise. Des scores plus élevés indiquent moins de déprivation.",
'Income Score (rate)':
'Income Score':
"Provient des Indices de Déprivation anglais (inversé afin que plus le score est élevé, meilleur est le résultat). Des valeurs plus élevées indiquent moins de déprivation de revenus. Basé sur les allocations de soutien au revenu, l'allocation de demandeur d'emploi sous condition de ressources, l'allocation d'emploi et de soutien sous condition de ressources, le crédit de retraite, le crédit d'impôt pour le travail et les enfants, l'Universal Credit et les demandeurs d'asile.",
'Employment Score (rate)':
'Employment Score':
"Provient des Indices de Déprivation anglais (inversé afin que plus le score est élevé, meilleur est le résultat). Des valeurs plus élevées indiquent moins de déprivation d'emploi. Basé sur les allocataires de l'allocation de demandeur d'emploi, de l'allocation d'emploi et de soutien, de l'allocation d'incapacité, de l'allocation de handicap sévère, de l'allocation d'aidant et les bénéficiaires pertinents de l'Universal Credit.",
'Health Deprivation and Disability Score':
"Provient des Indices de Déprivation anglais (inversé afin que plus le score est élevé, meilleur est le résultat). Des scores plus élevés indiquent un risque de décès prématuré plus faible et une meilleure qualité de vie. Dérivé des années de vie potentielle perdues, du ratio comparatif de maladie et d'invalidité, de la morbidité aiguë et des troubles de l'humeur et d'anxiété.",
'Living Environment Score':
"Provient des Indices de Déprivation anglais (inversé afin que plus le score est élevé, meilleur est le résultat). Combine la qualité du logement (état, chauffage central) et l'environnement extérieur (qualité de l'air, sécurité routière). Des scores plus élevés indiquent de meilleurs environnements de vie.",
'Indoors Sub-domain Score':
'Housing Conditions Score':
'Provient des Indices de Déprivation anglais, domaine Environnement de Vie (inversé afin que plus le score est élevé, meilleur est le résultat). Mesure la qualité du parc immobilier : disponibilité du chauffage central, état des logements et normes Decent Homes. Des scores plus élevés indiquent de meilleures conditions de logement.',
'Outdoors Sub-domain Score':
'Air Quality and Road Safety Score':
"Provient des Indices de Déprivation anglais, domaine Environnement de Vie (inversé afin que plus le score est élevé, meilleur est le résultat). Mesure la qualité de l'environnement de vie extérieur à travers des indicateurs de qualité de l'air et les victimes d'accidents de la route impliquant des piétons et des cyclistes. Des scores plus élevés indiquent de meilleurs environnements extérieurs.",
'Serious crime per 1k residents (avg/yr)':
"Violences, braquages, cambriolages et possession d'armes pour 1 000 résidents habituels par an dans le LSOA. Utilise les données de criminalité au niveau de la rue de police.uk (2023-2025) et les décomptes de population du Census 2021. Normalise en fonction de la densité de population afin que les zones soient comparables quelle que soit leur taille.",
@ -195,17 +193,15 @@ export const details: Record<string, Record<string, string>> = {
'Staatlich geförderte weiterführende Schulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Education, Skills and Training Score':
'Aus den englischen Deprivationsindizes (invertiert, sodass höher = besser bedeutet). Umfasst Schulleistungen, Hochschulzugang, Qualifikationen Erwachsener und Englischsprachkenntnisse. Höhere Werte weisen auf geringere Benachteiligung hin.',
'Income Score (rate)':
'Income Score':
"Aus den englischen Deprivationsindizes (invertiert, sodass höher = besser bedeutet). Höhere Werte weisen auf geringere Einkommensbenachteiligung hin. Basiert auf Income Support, einkommensbasiertem Jobseeker's Allowance, einkommensbasiertem Employment and Support Allowance, Pension Credit, Working Tax Credit und Child Tax Credit, Universal Credit sowie Asylbewerbern.",
'Employment Score (rate)':
'Employment Score':
"Aus den englischen Deprivationsindizes (invertiert, sodass höher = besser bedeutet). Höhere Werte weisen auf geringere Beschäftigungsbenachteiligung hin. Basiert auf Empfängern von Jobseeker's Allowance, Employment and Support Allowance, Incapacity Benefit, Severe Disablement Allowance, Carer's Allowance und relevanten Universal Credit-Empfängern.",
'Health Deprivation and Disability Score':
'Aus den englischen Deprivationsindizes (invertiert, sodass höher = besser bedeutet). Höhere Werte weisen auf ein geringeres Risiko eines vorzeitigen Todes und eine bessere Lebensqualität hin. Abgeleitet aus verlorenen Lebensjahren, vergleichender Krankheits- und Behinderungsquote, akuter Morbidität sowie Stimmungs- und Angststörungen.',
'Living Environment Score':
'Aus den englischen Deprivationsindizes (invertiert, sodass höher = besser bedeutet). Kombiniert Wohnqualität (Zustand, Zentralheizung) und Außenumgebung (Luftqualität, Verkehrssicherheit). Höhere Werte weisen auf bessere Wohnumgebungen hin.',
'Indoors Sub-domain Score':
'Housing Conditions Score':
'Aus den englischen Deprivationsindizes, Bereich Wohnumgebung (invertiert, sodass höher = besser bedeutet). Misst die Qualität des Wohnungsbestands: Verfügbarkeit von Zentralheizung, Wohnungszustand und Decent Homes-Standards. Höhere Werte weisen auf bessere Wohnbedingungen hin.',
'Outdoors Sub-domain Score':
'Air Quality and Road Safety Score':
'Aus den englischen Deprivationsindizes, Bereich Wohnumgebung (invertiert, sodass höher = besser bedeutet). Misst die Qualität der Außenwohnumgebung anhand von Luftqualitätsindikatoren und Straßenverkehrsunfällen mit Fußgängern und Radfahrern. Höhere Werte weisen auf bessere Außenumgebungen hin.',
'Serious crime per 1k residents (avg/yr)':
'Gewalt, Raub, Einbruch und Waffenbesitz pro 1.000 Einwohner pro Jahr im LSOA. Verwendet police.uk-Kriminalitätsdaten auf Straßenebene (20232025) und Census 2021-Bevölkerungszahlen. Normalisiert nach Bevölkerungsdichte, sodass Gebiete unabhängig von ihrer Größe vergleichbar sind.',
@ -335,17 +331,15 @@ export const details: Record<string, Record<string, string>> = {
'5km范围内Ofsted评级为"Outstanding"的公立中学数量。尚未接受评估的学校不计入。',
'Education, Skills and Training Score':
'来自英格兰剥夺指数(取反后越高越好)。涵盖学校成绩、高等教育入学率、成人学历和英语水平。分数越高表示剥夺程度越低。',
'Income Score (rate)':
'Income Score':
'来自英格兰剥夺指数(取反后越高越好)。数值越高表示收入剥夺程度越低。基于收入支持、基于收入的求职者津贴、基于收入的就业与支持津贴、养老金补贴、工作税收抵免和子女税收抵免、普惠信用以及寻求庇护者等数据。',
'Employment Score (rate)':
'Employment Score':
'来自英格兰剥夺指数(取反后越高越好)。数值越高表示就业剥夺程度越低。基于求职者津贴、就业与支持津贴、丧失劳动能力津贴、严重残疾津贴、护理者津贴申领者及相关普惠信用申领者等数据。',
'Health Deprivation and Disability Score':
'来自英格兰剥夺指数(取反后越高越好)。分数越高表示过早死亡风险越低、生活质量越好。来源于潜在寿命损失年、比较疾病和残疾率、急性发病率以及情绪和焦虑障碍等指标。',
'Living Environment Score':
'来自英格兰剥夺指数(取反后越高越好)。综合住房质量(状况、中央供暖)和室外环境(空气质量、道路安全)。分数越高表示居住环境越好。',
'Indoors Sub-domain Score':
'Housing Conditions Score':
'来自英格兰剥夺指数的居住环境领域取反后越高越好。衡量住房存量质量中央供暖覆盖率、住房状况以及Decent Homes标准。分数越高表示住房条件越好。',
'Outdoors Sub-domain Score':
'Air Quality and Road Safety Score':
'来自英格兰剥夺指数的居住环境领域(取反后越高越好)。通过空气质量指标以及涉及行人和骑行者的道路交通事故伤亡人数衡量室外生活环境质量。分数越高表示室外环境越好。',
'Serious crime per 1k residents (avg/yr)':
'LSOA内每1,000名常住居民每年发生的暴力、抢劫、入室盗窃和持有武器犯罪数量。使用police.uk街道级犯罪数据2023-2025年和Census 2021人口数据。按人口密度标准化便于不同规模地区之间的比较。',
@ -469,17 +463,15 @@ export const details: Record<string, Record<string, string>> = {
'5km के भीतर state-funded secondary schools जिनकी current Ofsted rating Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'Education, Skills and Training Score':
'English Indices of Deprivation से लिया गया (invert किया गया ताकि higher = better). School attainment, higher education entry, adult qualifications और English language proficiency को cover करता है. Higher scores कम deprivation दिखाते हैं.',
'Income Score (rate)':
'Income Score':
'English Indices of Deprivation से लिया गया (invert किया गया ताकि higher = better). Higher values कम income deprivation दिखाते हैं. Income support, income-based Jobseekers Allowance, income-based Employment and Support Allowance, Pension Credit, Working and Child Tax Credit, Universal Credit और asylum seekers पर आधारित.',
'Employment Score (rate)':
'Employment Score':
'English Indices of Deprivation से लिया गया (invert किया गया ताकि higher = better). Higher values कम employment deprivation दिखाते हैं. Jobseekers Allowance, Employment and Support Allowance, Incapacity Benefit, Severe Disablement Allowance, Carers Allowance और relevant Universal Credit claimants पर आधारित.',
'Health Deprivation and Disability Score':
'English Indices of Deprivation से लिया गया (invert किया गया ताकि higher = better). Higher scores premature death का कम risk और बेहतर quality of life दिखाते हैं. Years of potential life lost, comparative illness and disability ratio, acute morbidity और mood/anxiety disorders से derived.',
'Living Environment Score':
'English Indices of Deprivation से लिया गया (invert किया गया ताकि higher = better). Housing quality (condition, central heating) और outdoor environment (air quality, road safety) को combine करता है. Higher scores बेहतर living environments दिखाते हैं.',
'Indoors Sub-domain Score':
'Housing Conditions Score':
'English Indices of Deprivation, Living Environment domain से लिया गया (invert किया गया ताकि higher = better). Housing stock की quality मापता है: central heating availability, housing condition और Decent Homes standards. Higher scores बेहतर housing conditions दिखाते हैं.',
'Outdoors Sub-domain Score':
'Air Quality and Road Safety Score':
'English Indices of Deprivation, Living Environment domain से लिया गया (invert किया गया ताकि higher = better). Air quality indicators और pedestrians/cyclists से जुड़े road traffic accident casualties के जरिए outdoor living environment quality मापता है. Higher scores बेहतर outdoor environments दिखाते हैं.',
'Serious crime per 1k residents (avg/yr)':
'LSOA में प्रति 1,000 usual residents प्रति वर्ष violence, robbery, burglary और possession of weapons. police.uk street-level crime data (2023-2025) और Census 2021 population counts का उपयोग करता है. Population density normalize करता है ताकि areas size की परवाह किए बिना comparable हों.',
@ -609,17 +601,15 @@ export const details: Record<string, Record<string, string>> = {
'5 km-en belüli állami fenntartású középiskolák, amelyek aktuális Ofsted besorolása Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Education, Skills and Training Score':
'Az Angol Nélkülözési Indexekből (megfordítva, így magasabb = jobb). Az iskolai teljesítményt, a felsőoktatásba való bejutást, a felnőttkori képesítéseket és az angol nyelvi jártasságot foglalja magában. A magasabb pontszámok kisebb mértékű nélkülözést jeleznek.',
'Income Score (rate)':
'Income Score':
'Az Angol Nélkülözési Indexekből (megfordítva, így magasabb = jobb). A magasabb értékek kisebb mértékű jövedelmi nélkülözést jeleznek. A jövedelempótló támogatás, jövedelemalapú Munkaügyi Segély, jövedelemalapú Foglalkoztatási és Támogatási Segély, Nyugdíjkiegészítés, Munkavállalói és Gyermekadókedvezmény, Univerzális Hitel és menedékkérők alapján.',
'Employment Score (rate)':
'Employment Score':
'Az Angol Nélkülözési Indexekből (megfordítva, így magasabb = jobb). A magasabb értékek kisebb mértékű foglalkoztatási nélkülözést jeleznek. A Munkaügyi Segély, Foglalkoztatási és Támogatási Segély, Munkaképtelenségi Juttatás, Súlyos Rokkantsági Pótlék, Gondozói Juttatás igénylői és a vonatkozó Univerzális Hitel igénylői alapján.',
'Health Deprivation and Disability Score':
'Az Angol Nélkülözési Indexekből (megfordítva, így magasabb = jobb). A magasabb pontszámok alacsonyabb korai halálozási kockázatot és jobb életminőséget jeleznek. Az elveszített potenciális életévekből, a komparatív betegségi és rokkantsági arányból, az akut morbiditásból, valamint a hangulati és szorongásos zavarokból vezethető le.',
'Living Environment Score':
'Az Angol Nélkülözési Indexekből (megfordítva, így magasabb = jobb). Ötvözi a lakásminőséget (állapot, gázfűtés) és a külső környezetet (levegőminőség, közlekedésbiztonság). A magasabb pontszámok jobb lakókörnyezetet jeleznek.',
'Indoors Sub-domain Score':
'Housing Conditions Score':
'Az Angol Nélkülözési Indexek Lakókörnyezet tartományából (megfordítva, így magasabb = jobb). A lakásállomány minőségét méri: gázfűtés rendelkezésre állása, lakásállapot és Decent Homes szabványok. A magasabb pontszámok jobb lakáskörülményeket jeleznek.',
'Outdoors Sub-domain Score':
'Air Quality and Road Safety Score':
'Az Angol Nélkülözési Indexek Lakókörnyezet tartományából (megfordítva, így magasabb = jobb). A külső lakókörnyezet minőségét méri a levegőminőségi mutatók és a gyalogosokat, kerékpárosokat érintő közúti közlekedési baleseti áldozatok alapján. A magasabb pontszámok jobb külső környezetet jeleznek.',
'Serious crime per 1k residents (avg/yr)':
'Erőszakos bűncselekmények, rablás, betörés és fegyverbirtoklás 1 000 szokásos lakóra vetítve évente az LSOA-ban. A police.uk utcai szintű bűnügyi adatait (20232025) és a Census 2021 népességszámait használja. Normalizálja a népsűrűséget, így a területek mérettől függetlenül összehasonlíthatók.',

View file

@ -391,8 +391,7 @@ const de: Translations = {
showcaseStep4ColScore: 'Fit',
showcaseStep4ColCommute: 'Pendeln',
showcaseStep4ColPrice: 'Median verkauft',
showcaseStep4Conclusion:
'Von hier aus können Sie Ihre Suche beginnen. Sie sind nicht mehr orientierungslos.',
showcaseStep4Conclusion: 'Von hier aus können Sie Ihre Suche beginnen.',
statProperties: 'historische Verkäufe',
statFilters: 'kombinierbare Filter',
statEvery: 'Jede',
@ -549,98 +548,101 @@ const de: Translations = {
dsElectionUse:
'Ergebnisse auf Kandidatenebene der britischen Parlamentswahl vom Juli 2024. Aggregiert auf Wahlkreisebene: Wahlbeteiligung (%) und Parteistimmenanteile (%). Über den Wahlkreiscode (pcon) aus dem NSPL-Postleitzahlenverzeichnis mit Immobilien verknüpft.',
// FAQ section titles
faqFindingTitle: 'Suchstrategie',
faqCommuteTitle: 'Reisezeit-Routing',
faqFindingTitle: 'Wo suchen',
faqCommuteTitle: 'Reisezeiten',
faqBudgetTitle: 'Geschätzte Preise',
faqSafetyTitle: 'Sicherheit und Nachbarschaft',
faqFamiliesTitle: 'Familien und Schulen',
faqEnvironmentTitle: 'Umwelt und Lebensqualität',
faqDueDiligenceTitle: 'Umfang und Due Diligence',
faqDueDiligenceTitle: 'Was prüfen',
faqPrivacyTitle: 'Datenschutz',
faqWhyTitle: 'Warum Perfect Postcode',
faqPricingTitle: 'Zugang',
faqTipsTitle: 'Tipps und Tricks',
faqTipsTitle: 'Kartentipps',
// FAQ items — Finding Your Area
faqFinding1Q: 'Wo soll ich suchen, wenn die offensichtlichen Gebiete zu teuer sind?',
faqFinding1A:
'Setzen Sie Budget, Immobilientyp, Wohnfläche, Pendelzeit, Schulen, Kriminalität, Lärm, Breitband, Parks und andere Muss-Kriterien. Die Karte entfernt Postleitzahlen, die diese Tests nicht bestehen, sodass übersehene Gebiete sichtbar werden, bevor Sie in Portalen suchen.',
'Beginnen Sie mit dem, worauf Sie nicht verzichten können: Budget, Haustyp, Platz, Pendelzeit, Schulen, Sicherheit, Lärm, Breitband, Parks und alles Weitere, was wichtig ist. Die Karte blendet Orte aus, die nicht passen, sodass weniger offensichtliche Gebiete sichtbar werden, bevor Sie Inserate durchsehen.',
faqFinding2Q: 'Wie finde ich gute Postleitzahlen in Gegenden, die ich kaum kenne?',
faqFinding2A:
'Filtern Sie die ganze Karte nach Ihren festen Anforderungen und prüfen Sie dann die verbleibenden Cluster. Unbekannte Postleitzahlen lassen sich nach Pendelzeit, Verkaufspreisen, Schulen, Kriminalität, Breitband, Lärm und Ausstattung vergleichen, statt sich auf den Ruf eines Gebiets zu verlassen.',
'Setzen Sie Ihre Muss-Anforderungen auf der ganzen Karte und prüfen Sie dann die verbleibenden Gruppen. Unbekannte Postleitzahlen lassen sich nach Pendelzeit, Verkaufspreisen, Schulen, Kriminalität, Breitband, Lärm sowie Läden oder Parks in der Nähe vergleichen, statt sich auf den Ruf eines Gebiets zu verlassen.',
faqFinding3Q: 'Was mache ich, wenn die Suche zu viele oder zu wenige Gebiete zeigt?',
faqFinding3A:
'Beginnen Sie mit harten Grenzen und färben Sie die Karte dann nach einem Trade-off wie Preis pro m², Straßenlärm, Schulscore oder Reisezeit. Wenn die Karte zu eng wird, lockern Sie einen Regler und sehen sofort, welcher Kompromiss neue Optionen öffnet.',
'Lassen Sie Ihre Muss-Anforderungen aktiv und färben Sie die Karte nach einer Sache ein, die Sie vergleichen möchten, etwa Preis pro m², Straßenlärm, Schulscore oder Reisezeit. Wenn fast nichts übrig bleibt, lockern Sie einen Regler und sehen, welche Änderung neue Optionen öffnet.',
// FAQ items — Commute and Travel
faqCommute1Q: 'Wie werden die Reisezeiten berechnet?',
faqCommute1A:
'Die Reisezeiten werden mit Conveyal R5 vorab berechnet, einer Routing-Engine für Verkehrsanalysen. Für jedes unterstützte Ziel routen wir zu erreichbaren Postleitzahlen über das Straßen- und ÖPNV-Netz und speichern schlanke Postleitzahl-Reisezeitdateien für Auto, Fahrrad, Fußweg und öffentliche Verkehrsmittel. Dadurch kann die Karte viele Postleitzahlen schnell filtern, statt jede Route einzeln abzufragen.',
'Die Reisezeiten werden im Voraus für jedes gespeicherte Ziel berechnet. Wir prüfen, welche Postleitzahlen dieses Ziel per Auto, Fahrrad, zu Fuß oder mit öffentlichen Verkehrsmitteln erreichen können, und speichern diese Ergebnisse, damit die Karte beim Filtern schnell reagiert.',
faqCommute2Q: 'Was sollte ich über die Reisezeitwerte wissen?',
faqCommute2A:
'ÖPNV-Zeiten verwenden ein morgendliches Abfahrtsfenster von 07:30 bis 08:30. Standard ist die Medianzeit, also der typische Wert in diesem Fenster; die Best-Case-Option nutzt das 5. Perzentil für gut getimte Abfahrten. Es sind modellierte Vergleichswerte, keine Live-Störungen, Verkehrslagen oder Bahnsteigwechsel-Prognosen.',
'Zeiten für öffentliche Verkehrsmittel basieren auf einem Pendelweg an Wochentagen morgens, mit Abfahrten zwischen 07:30 und 08:30. Die normale Einstellung zeigt eine typische Fahrt in diesem Zeitraum. Es sind Planungswerte, keine Live-Verspätungen, Verkehrsmeldungen oder kurzfristigen Gleiswechsel.',
faqCommute3Q: 'Wann sollte ich den Bestfall-Button nutzen?',
faqCommute3A:
'Nutzen Sie den Bestfall-Button für öffentliche Verkehrsmittel, wenn Sie die Fahrt mit gut gewählter Abfahrt und guten Anschlüssen sehen möchten. Lassen Sie ihn ausgeschaltet, wenn Sie den Alltagsvergleich möchten.',
// FAQ items — Budget and Value
faqBudget1Q: 'Wie funktioniert der Algorithmus für den geschätzten aktuellen Preis?',
faqBudget1Q: 'Wie schätzen Sie aktuelle Immobilienpreise?',
faqBudget1A:
'Die proprietäre Schätzung beginnt mit dem letzten HM-Land-Registry-Verkaufspreis. Dieser wird mit einem Repeat-Sales-Index auf heute angepasst, gelernt aus Immobilien mit mehreren Verkäufen und gegliedert nach Postleitzahlsektor und Immobilientyp. Dünn besetzte Gebiete werden Richtung District-, Area-, nationalem und hedonischem Fallback-Modell geschrumpft und räumlich geglättet. Danach wird das Ergebnis mit einer Nächste-Nachbarn-Schätzung aus nahe gelegenen, kürzlich verkauften, ähnlichen Immobilien auf Basis von Preis pro m² und EPC-Wohnfläche gemischt.',
'Die Schätzung beginnt mit dem letzten bei HM Land Registry erfassten Verkaufspreis. Wir bringen diesen Verkauf näher an den heutigen Markt, indem wir ansehen, wie sich ähnliche Häuser entwickelt haben, besonders Häuser desselben Typs in der Nähe. Wenn es nur wenige lokale Verkäufe gibt, stützt sich die Schätzung stärker auf Trends in einem größeren Gebiet. Danach wird sie mit nahegelegenen jüngeren Verkäufen und der Wohnfläche abgeglichen.',
faqBudget2Q: 'Warum den geschätzten aktuellen Preis statt des letzten Verkaufspreises nutzen?',
faqBudget2A:
'Der letzte Verkaufspreis kann Jahre oder Jahrzehnte alt sein, und aktuelle Angebotspreise zeigen nur, was gerade auf dem Markt ist. Der geschätzte aktuelle Preis bringt alte Verkäufe näher an heutige Marktbedingungen, damit Sie mehr Immobilien vergleichen, geschätzten Preis pro m² berechnen und gute Werte erkennen können, bevor passende Inserate erscheinen. Es ist ein Screening-Wert, keine formale Bewertung.',
'Der letzte Verkaufspreis kann Jahre oder Jahrzehnte alt sein, und Angebotspreise zeigen nur, was heute zum Verkauf steht. Der geschätzte aktuelle Preis bringt ältere Verkäufe näher an den heutigen Markt, damit Sie mehr Häuser vergleichen und Gebiete mit möglichem Wert erkennen können, bevor passende Inserate erscheinen. Nutzen Sie ihn als Orientierung für Ihre Shortlist, nicht als Bankbewertung.',
// FAQ items — Safety and Neighbourhood
faqSafety1Q: 'Welche Art von Kriminalität ist rund um diese Postleitzahl häufig?',
faqSafety1A:
'Polizeilich erfasste Kriminalität ist nach Art aufgeschlüsselt, etwa Gewalt, Einbruch, Raub, Fahrzeugkriminalität, antisoziales Verhalten, Ladendiebstahl, Drogen und öffentliche Ordnung. Sie können nach den Risiken filtern, die Ihnen wichtig sind, statt sich auf einen vagen Sicherheitswert zu verlassen.',
'Kriminalität ist nach Art aufgeschlüsselt, etwa Gewalt, Einbruch, Raub, Fahrzeugkriminalität, antisoziales Verhalten, Ladendiebstahl, Drogen und öffentliche Ordnung. Sie können sich auf die Risiken konzentrieren, die Ihnen wichtig sind, statt sich auf einen vagen Sicherheitswert zu verlassen.',
faqSafety2Q: 'Was sollte ich vor einer Besichtigung in einer unbekannten Straße prüfen?',
faqSafety2A:
'Prüfen Sie Kriminalität, Straßenlärm, Benachteiligung, Breitband, Parks, Lebensmittelgeschäfte, Schulen und Pendelzeit, bevor Sie buchen. Inseratsfotos können nützlich sein, sollten aber nicht das Erste sein, woraus Sie erfahren, wie die Straße ist.',
'Prüfen Sie Kriminalität, Straßenlärm, Breitband, Parks, Lebensmittelgeschäfte, Schulen und Pendelzeit, bevor Sie buchen. Inseratsfotos können nützlich sein, sollten aber nicht das Erste sein, woraus Sie erfahren, wie die Straße ist.',
// FAQ items — Families and Schools
faqFamilies1Q:
'Welche Gebiete haben die richtige Mischung aus Schulen, Platz, Sicherheit und Pendelzeit?',
faqFamilies1A:
'Legen Sie Ofsted-Bewertungen, Kriminalität, Parks, Pendelzeit, Wohnfläche, Immobilientyp und Budget auf eine Karte. Das Ergebnis ist eine praktische Familien-Shortlist statt vieler getrennter Schul-, Kriminalitäts-, Inserats- und Verkehrssuchen.',
'Legen Sie Schulbewertungen, Kriminalität, Parks, Pendelzeit, Platz, Haustyp und Budget auf eine Karte. Das Ergebnis ist eine praktische Familien-Shortlist statt vieler getrennter Suchen.',
faqFamilies2Q: 'Beweist das, dass ich im Einzugsgebiet einer Schule liege?',
faqFamilies2A:
'Nein. Wir zeigen nahegelegene Schulqualität und Bildungsdaten auf Gebietsebene, aber Aufnahmegrenzen und Prioritätsregeln können sich ändern. Nutzen Sie Perfect Postcode als Shortlist-Werkzeug und prüfen Sie Einzugsgebiete und Aufnahmen anschließend bei der Schule oder lokalen Behörde.',
'Nein. Wir zeigen nahegelegene Schulqualität und lokale Bildungsinformationen, aber Aufnahmegebiete und Prioritätsregeln können sich ändern. Nutzen Sie Perfect Postcode zur Auswahl und prüfen Sie Einzugsgebiete anschließend bei der Schule oder Gemeinde.',
// FAQ items — Environment and Quality of Life
faqEnv1Q:
'Wie vermeide ich eine laute Straße, ohne Pendelzeit oder Breitbandqualität zu verlieren?',
faqEnv1A:
'Filtern Sie nach Straßenlärm und lassen Sie Pendelzeit, Breitbandgeschwindigkeit, Preis und Immobilienfilter aktiv. Sie können die Karte nach einem Merkmal einfärben, während die übrigen Kriterien die Shortlist realistisch halten.',
'Filtern Sie nach Straßenlärm und lassen Sie Pendelzeit, Breitband, Preis und Hausfilter aktiv. Sie können die Karte nach einem Merkmal einfärben, während die anderen Filter die Shortlist realistisch halten.',
faqEnv2Q: 'Zeigt es Hochwasser-, Senkungs- oder Gutachterrisiken?',
faqEnv2A:
'Nicht als Live-Filter. Wir zeigen Daten wie Straßenlärm, EPC, Baualter und lokale Umweltindikatoren, aber Hochwassersuchen, Grundbuchthemen, bauliche Mängel und Finanzierbarkeit müssen weiterhin durch Anwälte, Kreditgeber und Gutachter geprüft werden.',
'Nicht heute. Wir zeigen Straßenlärm, Energieklasse, Baualter und die lokale Umgebung rund um die Postleitzahl. Hochwasserrisiko, rechtliche Fragen, bauliche Mängel, Finanzierungsthemen und Gutachten müssen vor dem Kauf separat geprüft werden.',
faqEnv3Q: 'Welche laufenden Kosten kann ich vor einer Besichtigung prüfen?',
faqEnv3A:
'Sie können vor der Besichtigung EPC-Bewertung, Gesamtwohnfläche, Baujahr, Council-Tax-Behörde, Breitband und Lärm prüfen. Das sagt Ihre genauen Rechnungen nicht voraus, hilft aber, offensichtliche Fehlgriffe früh auszusortieren.',
'Sie können vor der Besichtigung Energieklasse, Wohnfläche, Baualter, Council-Tax-Gebiet, Breitband und Lärm prüfen. Das sagt Ihre genauen Rechnungen nicht voraus, hilft aber, offensichtliche Fehlgriffe früh auszusortieren.',
// FAQ items — Listing Portals and Due Diligence
faqDueDiligence1Q: 'Sollte ich das vor oder nach Rightmove nutzen?',
faqDueDiligence1A:
'Nutzen Sie Perfect Postcode vor und neben Listing-Portalen. Rightmove, Zoopla und OnTheMarket bleiben für aktuelle Verfügbarkeit, Fotos, Maklerkontakt, Besichtigungen und Benachrichtigungen zuständig. Perfect Postcode hilft zu entscheiden, welche Postleitzahlen die Suche überhaupt wert sind.',
'Nutzen Sie Perfect Postcode vor und neben Listing-Seiten. Rightmove, Zoopla und OnTheMarket bleiben die Orte für aktuell verfügbare Häuser, Fotos, Makler, Besichtigungen und Benachrichtigungen. Perfect Postcode hilft zu entscheiden, welche Postleitzahlen die Suche wert sind.',
faqDueDiligence2Q: 'Kann ich nach Garten, Garage, Grundriss oder Anzeigentext filtern?',
faqDueDiligence2A:
'Nur, wenn die Information in strukturierten offiziellen Daten vorhanden ist. Perfect Postcode kann nach Wohnfläche, Immobilientyp, Eigentumsform, EPC, Verkaufspreisen und lokalen Daten filtern. Gärten, Garagen, Ausrichtung, Raumaufteilung und Maklerformulierungen müssen weiterhin in der Anzeige und bei der Besichtigung geprüft werden.',
'Diese Details sind nicht für jedes Haus zuverlässig verfügbar. Perfect Postcode kann nach Wohnfläche, Haustyp, Eigentumsform, Energieklasse, Verkaufspreisen und lokalen Informationen filtern. Gärten, Garagen, Ausrichtung, Raumaufteilung und Maklerformulierungen müssen weiterhin in der Anzeige und bei der Besichtigung geprüft werden.',
faqDueDiligence3Q: 'Kann ich Preisreduzierungen oder die Online-Dauer eines Listings sehen?',
faqDueDiligence3A:
'Derzeit nicht. Perfect Postcode basiert auf offiziellen Verkaufspreis-, EPC-, Postleitzahl-, Reisezeit- und Nachbarschaftsdaten, nicht auf Live-Listing-Feeds. Sie können aber Transaktionsdatum, Verkaufshistorie, geschätzten aktuellen Wert und Preis pro m² nutzen, um einen Angebotspreis einzuordnen.',
'Derzeit nicht. Perfect Postcode basiert auf Verkaufspreisen, Energieklassen, Postleitzahlen, Reisezeiten und Nachbarschaftsinformationen, nicht auf Live-Änderungen in Inseraten. Sie können aber Verkaufshistorie, geschätzten aktuellen Wert und Preis pro m² nutzen, um einzuschätzen, ob ein Angebotspreis hoch wirkt.',
faqDueDiligence4Q: 'Was sollte ich vor einem Angebot trotzdem prüfen?',
faqDueDiligence4A:
'Nutzen Sie Perfect Postcode für den Gebiets- und Wert-Check, prüfen Sie danach aber Listing-Details, Eigentumsform, Laufzeit eines Leaseholds, Service Charge, Planungshistorie, Hochwasserrisiko, Grundbuchthemen, Kreditgeberanforderungen und Gutachten über den üblichen professionellen Prozess.',
'Nutzen Sie Perfect Postcode, um Gebiet und wahrscheinlichen Wert zu prüfen, und bestätigen Sie dann die Inseratsdetails vor einem Angebot. Prüfen Sie außerdem Eigentumsform, Leasehold-Details, Nebenkosten, Planungshistorie, Hochwasserrisiko, rechtliche Fragen, Hypothekenanforderungen und Gutachten.',
// FAQ items — Privacy and Data Protection
faqPrivacy1Q: 'Speichern Sie personenbezogene Daten über mich?',
faqPrivacy1A:
'Wir speichern keine personenbezogenen Daten in den Immobilien- und Nachbarschaftsdatensätzen. Diese Datensätze stammen aus offiziellen und öffentlichen Quellen und dienen der Postleitzahlen- und Immobilienrecherche. Wenn Sie ein Konto erstellen, speichern wir nur, was für den Betrieb des Dienstes erforderlich ist, etwa E-Mail-Adresse, Lizenzstatus, Newsletter-Einstellung, gespeicherte Suchen, gespeicherte Immobilien und Zahlungskennungen, die über Stripe verarbeitet werden. Diese Kontodaten behandeln wir nach UK GDPR und dem Data Protection Act 2018.',
'Die Immobilien- und Nachbarschaftsinformationen enthalten keine persönlichen Angaben über Sie. Wenn Sie ein Konto erstellen, speichern wir nur, was für den Dienst nötig ist, etwa E-Mail-Adresse, Zugangsstatus, Newsletter-Auswahl, gespeicherte Suchen, gespeicherte Immobilien und von Stripe verwaltete Zahlungen. Diese Kontodaten behandeln wir nach britischem Datenschutzrecht.',
// FAQ items — Why Perfect Postcode
faqWhy1Q: 'Was zeigt das, was Immobilienportale normalerweise nicht zeigen?',
faqWhy1A:
'Immobilienportale beginnen mit Häusern, die gerade zum Verkauf stehen. Perfect Postcode beginnt mit Orten, die zu Ihrem Leben und Budget passen, mit Verkaufspreisen, Wohnfläche, Pendelzeit, Schulen, Kriminalität, Lärm, Breitband, EPC, Eigentumsform und Ausstattung, bevor Sie Inserate öffnen.',
'Listing-Seiten beginnen mit Häusern, die gerade zum Verkauf stehen. Perfect Postcode beginnt mit Orten, die zu Ihrem Leben und Budget passen, mit Verkaufspreisen, Platz, Pendelzeit, Schulen, Kriminalität, Lärm, Breitband, Energieklasse, Eigentumsform und Ausstattung, bevor Sie Inserate öffnen.',
faqWhy2Q: 'Wie viel manuelle Recherche spart das?',
faqWhy2A:
'Sie können das selbst tun, aber dann müssen Sie Land Registry, EPC, Polizei, Ofsted, Ofcom, ONS, Defra, Reisezeit- und Kartendaten Postleitzahl für Postleitzahl zusammenführen. Perfect Postcode macht diese Quellen in ganz England an einem Ort filterbar.',
faqWhy3Q: 'Wie verlässlich sind die zugrunde liegenden Quellen?',
'Sie könnten das selbst tun, aber dann müssten Sie Verkaufspreise, Energieklassen, Kriminalität, Schulen, Breitband, lokale Fakten, Umwelt, Reisezeiten und Karten Postleitzahl für Postleitzahl prüfen. Perfect Postcode bringt diese Quellen in einer durchsuchbaren Karte für England zusammen.',
faqWhy3Q: 'Wie verlässlich sind die Daten?',
faqWhy3A:
'Die Kerndatensätze stammen aus offiziellen oder maßgeblichen Quellen wie HM Land Registry, EPC-Daten, ONS, Ofsted, Ofcom, data.police.uk, Defra, Ordnance Survey und OpenStreetMap. Sie eignen sich sehr gut für Shortlists und Vergleiche, aber jede Kaufentscheidung braucht aktuelle Prüfungen und professionelle Beratung.',
'Die wichtigsten Quellen sind offizielle oder weit genutzte öffentliche Daten: Verkaufspreise, Energieklassen, lokale Fakten, Schulbewertungen, Breitband, Kriminalität, Umwelt, Karten und Straßendaten. Sie sind nützlich für Shortlists und Vergleiche, aber jede Kaufentscheidung braucht aktuelle Prüfungen und bei Bedarf fachlichen Rat.',
// FAQ items — Pricing and Access
faqPricing1Q: 'Warum bezahlen, wenn Postleitzahlberichte kostenlos sind?',
faqPricing1A:
'Kostenlose Postleitzahltools sind nützlich, wenn Sie schon wissen, was Sie prüfen wollen. Perfect Postcode scannt jede Postleitzahl in England gegen Ihre Kriterien, kombiniert Filter, vergleicht Kompromisse, speichert Suchen und exportiert eine Shortlist, bevor Sie Wochenenden für Besichtigungen einplanen.',
'Kostenlose Postleitzahltools sind nützlich, wenn Sie schon wissen, was Sie prüfen wollen. Perfect Postcode durchsucht jede Postleitzahl in England nach Ihren Bedürfnissen, kombiniert Filter, vergleicht Optionen, speichert Suchen und exportiert eine Shortlist, bevor Sie Wochenenden für Besichtigungen einplanen.',
faqPricing2Q: 'Was bedeutet lebenslanger Zugang?',
faqPricing2A:
'Lebenslanger Zugang bedeutet, dass eine Zahlung Ihrem Konto laufenden Zugriff auf die kostenpflichtige Perfect-Postcode-Karte für die Lebensdauer des Dienstes gibt. Es ist kein Monats- oder Jahresabo, und normale Datenaktualisierungen sind enthalten. Sie können es während dieser Suche nutzen, später zurückkommen und weiterhin Zugriff haben, wenn Sie erneut umziehen.',
@ -649,15 +651,15 @@ const de: Translations = {
'Kostenlose Nutzer können alle Funktionen im Demogebiet erkunden (Innenstadt London, ungefähr Zonen 1 bis 2). Für den Zugang zu Daten für den Rest Englands benötigen Sie den lebenslangen Zugang.',
// FAQ items — Tips and Tricks
faqTips1Q: 'Wie beschreibe ich eine Suche in Alltagssprache?',
faqTips1Q: 'Wie sehe ich eine Filtervorschau auf der Karte?',
faqTips1A:
'Tippen Sie etwas wie „freehold 3-bed under £550k, 45 minutes to work, quiet, good broadband“, und der KI-Filter richtet die passenden Filter ein, die er versteht. Er sagt Ihnen auch, wenn eine Anforderung wie Gartengröße nicht als strukturierter Filter verfügbar ist.',
faqTips2Q: 'Kann ich eine Suche speichern und später darauf zurückkommen?',
'Klicken Sie auf das Augen-Symbol neben einem Filter oder Merkmal, um die Karte nach diesem Punkt einzufärben. Ihre aktiven Filter bleiben bestehen, sodass Sie schnell eine Sache wie Preis, Pendelzeit, Schulen, Kriminalität oder Lärm vergleichen können, ohne die Shortlist zu ändern.',
faqTips2Q: 'Wie erfahre ich, was ein Filter bedeutet?',
faqTips2A:
'Klicken Sie auf Speichern und alles wird erfasst: Ihre Filter, die Zoomstufe und die angezeigte Datenebene. Machen Sie genau dort weiter, wo Sie aufgehört haben, oder teilen Sie den Link mit Ihrem Partner.',
faqTips3Q: 'Kann ich die angezeigten Daten exportieren?',
'Klicken Sie auf den i-Infobutton neben einem Filter oder Merkmal, um eine kurze Erklärung zu sehen, was es bedeutet und wie Sie es lesen. Einige Teile der Karte, etwa Reisezeitkarten, haben ebenfalls einen eigenen Infobutton.',
faqTips3Q: 'Wie aktualisiere ich die Kartenfarben?',
faqTips3A:
'Nutzen Sie den Export-Button, um die aktuell gefilterten Immobilien als Tabelle herunterzuladen. Der Export berücksichtigt Ihre aktiven Filter, sodass Sie eine saubere Shortlist in Portale, Besichtigungen, Tabellen oder Gespräche mit einer mitkaufenden Person mitnehmen können.',
'Wenn eine Augen-Vorschau die Karte einfärbt, nutzen Sie Farbskala zurücksetzen in der Legende, um die Farben für die aktuell angezeigten Ergebnisse zu aktualisieren. Das ist nach Verschieben, Zoomen oder geänderten Filtern nützlich.',
},
// ── Account Page ───────────────────────────────────
@ -839,12 +841,11 @@ const de: Translations = {
'Education, Skills and Training Score': 'Score für Bildung, Kompetenzen und Ausbildung',
// ─ Feature names (Deprivation) ─
'Income Score (rate)': 'Einkommensscore (Rate)',
'Employment Score (rate)': 'Beschäftigungsscore (Rate)',
'Income Score': 'Einkommensscore',
'Employment Score': 'Beschäftigungsscore',
'Health Deprivation and Disability Score': 'Score für Gesundheit und Behinderung',
'Living Environment Score': 'Score der Wohnumgebung',
'Indoors Sub-domain Score': 'Score der Wohnqualität (innen)',
'Outdoors Sub-domain Score': 'Score der Umgebungsqualität (außen)',
'Housing Conditions Score': 'Score der Wohnbedingungen',
'Air Quality and Road Safety Score': 'Score für Luftqualität und Verkehrssicherheit',
// ─ Feature names (Crime) ─
'Serious crime per 1k residents (avg/yr)':

View file

@ -386,7 +386,7 @@ const en = {
showcaseStep4ColScore: 'Fit',
showcaseStep4ColCommute: 'Commute',
showcaseStep4ColPrice: 'Median sold',
showcaseStep4Conclusion: 'You can start your journey from here. You are no longer lost.',
showcaseStep4Conclusion: 'You can start your journey from here.',
statProperties: 'historical sales',
statFilters: 'combinable filters',
statEvery: 'Every',
@ -540,113 +540,116 @@ const en = {
dsElectionUse:
'Candidate-level results for the July 2024 UK General Election. Aggregated to constituency level: voter turnout (%) and party vote shares (%). Joined to properties via the parliamentary constituency code (pcon) from the NSPL postcode lookup.',
// FAQ section titles
faqFindingTitle: 'Search Strategy',
faqCommuteTitle: 'Travel-Time Routing',
faqFindingTitle: 'Where To Look',
faqCommuteTitle: 'Travel Times',
faqBudgetTitle: 'Estimated Prices',
faqSafetyTitle: 'Safety and Neighbourhood',
faqFamiliesTitle: 'Families and Schools',
faqEnvironmentTitle: 'Environment and Quality of Life',
faqDueDiligenceTitle: 'Scope and Due Diligence',
faqPrivacyTitle: 'Privacy and Data Protection',
faqDueDiligenceTitle: 'What To Check',
faqPrivacyTitle: 'Privacy',
faqWhyTitle: 'Why Perfect Postcode',
faqPricingTitle: 'Access',
faqTipsTitle: 'Tips and Tricks',
faqTipsTitle: 'Map Tips',
// FAQ items — Finding Your Area
faqFinding1Q: 'Where should I look once the obvious areas are too expensive?',
faqFinding1A:
'Set your budget, property type, floor area, commute, schools, crime, noise, broadband, parks, and other must-haves. The map removes postcodes that fail those tests, so overlooked areas can surface before you start searching listings.',
'Start with the things you cannot compromise on: budget, home type, space, commute, schools, safety, noise, broadband, parks, and anything else that matters. The map hides places that do not fit, so less obvious areas can surface before you start scrolling listings.',
faqFinding2Q: 'How do I find good postcodes in places I do not know well?',
faqFinding2A:
'Filter the whole map by your hard requirements, then inspect the clusters that remain. You can compare unfamiliar postcodes by commute, sold prices, schools, crime, broadband, noise, and amenities instead of relying on reputation.',
'Set your must-haves across the whole map, then look closely at the groups of places that remain. You can compare unfamiliar postcodes by commute, sold prices, schools, crime, broadband, noise, and shops or parks nearby instead of relying on reputation.',
faqFinding3Q: 'What should I do when my search returns too many or too few areas?',
faqFinding3A:
'Start with hard limits, then colour the map by a trade-off such as price per sqm, road noise, school score, or commute time. If the map gets too narrow, relax one slider and you can see exactly which compromise opens up more options.',
'Keep your must-haves in place, then colour the map by one thing you want to compare, such as price per square metre, road noise, school score, or commute time. If almost nothing is left, loosen one slider and see which change opens up more options.',
// FAQ items — Commute and Travel
faqCommute1Q: 'How are the travel times calculated?',
faqCommute1A:
'Travel times are precomputed with Conveyal R5, a routing engine used for transport analysis. For each supported destination we route to reachable postcodes over the street and transit network, then store sparse postcode travel-time files for car, cycling, walking, and public transport. That lets the map filter thousands of postcodes quickly instead of calling a route API one postcode at a time.',
'Travel times are calculated in advance for each saved destination. We work out which postcodes can reach that destination by car, bike, walking, or public transport, then save those results so the map can respond quickly while you filter.',
faqCommute2Q: 'What should I know about the travel-time numbers?',
faqCommute2A:
'Public transport times use a morning peak departure window, 07:30 to 08:30. The default is the median journey time, which is the typical result across that window; the best-case toggle uses the 5th percentile for well-timed departures. They are modelled comparison times, not live disruption, traffic, or platform-change predictions.',
'Public transport times are based on a weekday morning commute, using departures between 07:30 and 08:30. The normal setting shows a typical journey in that window. These are planning estimates, so they do not include live delays, traffic, or last-minute platform changes.',
faqCommute3Q: 'When should I use the Best case button?',
faqCommute3A:
'Use the Best case button on public-transport searches when you want to see the journey with a well-timed departure and good connections. Leave it off for the everyday comparison, because the normal setting is closer to what you should expect most days.',
// FAQ items — Budget and Value
faqBudget1Q: 'How does the estimated current price algorithm work?',
faqBudget1Q: 'How do you estimate current property prices?',
faqBudget1A:
'The proprietary estimate starts with the last HM Land Registry sale price. It adjusts that price to today using a repeat-sales index learned from properties that sold more than once, stratified by postcode sector and property type. Sparse areas are shrunk toward district, area, national, and hedonic fallback models, then spatially smoothed. Finally, the result is blended with a nearest-neighbour estimate from nearby, recently sold, same-type homes using adjusted price per sqm and EPC floor area.',
'The estimate starts with the homes last recorded sale price from HM Land Registry. We bring that sale up to todays market by looking at how similar homes have changed in value over time, especially homes of the same type nearby. Where there are fewer local sales, the estimate leans more on wider area trends. It is then checked against nearby recent sales and floor area so the result is useful for comparison.',
faqBudget2Q: 'Why use estimated current price instead of last sold price?',
faqBudget2A:
'Last sold price can be years or decades old, and live asking prices only cover whatever happens to be listed today. Estimated current price puts old sales into current-market terms so you can compare more properties, calculate estimated price per sqm, and spot areas that look good value before listings appear. It is a screening estimate, not a formal valuation.',
'Last sold price can be years or decades old, while asking prices only cover homes listed today. Estimated current price puts older sales into todays market, so you can compare more homes and spot areas that may offer better value before listings appear. Treat it as a guide for shortlisting, not a bank valuation.',
// FAQ items — Safety and Neighbourhood
faqSafety1Q: 'What type of crime is common around this postcode?',
faqSafety1A:
'Police-recorded crime is broken down by type, including violence, burglary, robbery, vehicle crime, antisocial behaviour, shoplifting, drugs, and public order. You can filter by the specific risks you care about instead of relying on a single vague safety score.',
'Crime is broken down by type, including violence, burglary, robbery, vehicle crime, antisocial behaviour, shoplifting, drugs, and public order. You can focus on the risks that matter to you instead of relying on one vague safety score.',
faqSafety2Q: 'What should I check before viewing an unfamiliar street?',
faqSafety2A:
'Check crime, road noise, deprivation, broadband, parks, groceries, schools, and commute before you book. The listing photos can still be useful, but they should not be the first time you learn what the street is like.',
'Check crime, road noise, broadband, parks, food shops, schools, and commute before you book. Listing photos are useful, but they should not be the first time you learn what the street is like.',
// FAQ items — Families and Schools
faqFamilies1Q: 'Which areas have the right mix of schools, space, safety, and commute?',
faqFamilies1A:
'Stack Ofsted ratings, crime, parks, commute, floor area, property type, and budget on one map. The result is a practical family shortlist rather than a pile of separate school, crime, listing, and transport searches.',
'Put school ratings, crime, parks, commute, space, home type, and budget on one map. The result is a practical family shortlist instead of a pile of separate school, crime, listing, and transport searches.',
faqFamilies2Q: 'Does this prove I am inside a school catchment?',
faqFamilies2A:
'No. We show nearby school quality and area-level education data, but admissions boundaries and priority rules can change. Treat Perfect Postcode as a shortlist tool, then verify catchments and admissions with the school or local authority.',
'No. We show nearby school quality and local education data, but admissions areas and priority rules can change. Use Perfect Postcode to shortlist places, then check catchments and admissions with the school or local council.',
// FAQ items — Environment and Quality of Life
faqEnv1Q: 'How do I avoid a noisy road without losing commute or broadband quality?',
faqEnv1A:
'Filter by road noise, then keep commute time, broadband speed, price, and property filters active. You can colour by any one feature while the others keep the shortlist honest.',
'Filter by road noise, then keep your commute, broadband, price, and home filters switched on. You can colour the map by one feature while the others keep the shortlist honest.',
faqEnv2Q: 'Do you show flood risk, subsidence, or survey issues?',
faqEnv2A:
'Not as live filters today. We show data such as road noise, EPC, construction age, and local environment indicators, but flood searches, title checks, structural issues, and mortgageability still need proper conveyancing, lender checks, and a survey.',
'Not today. We show things such as road noise, energy rating, building age, and the local environment around the postcode. Flood risk, legal issues, structural issues, mortgage concerns, and survey findings still need to be checked separately before you buy.',
faqEnv3Q: 'What running-cost checks can I do before viewing?',
faqEnv3A:
'You can screen for EPC rating, total floor area, construction age, council tax authority, broadband, and noise before viewing. That will not predict your exact bills, but it helps you avoid obvious mismatches early.',
'You can check energy rating, floor area, building age, council tax area, broadband, and noise before viewing. It will not predict your exact bills, but it helps you avoid obvious mismatches early.',
// FAQ items — Listing Portals and Due Diligence
faqDueDiligence1Q: 'Should I use this before or after checking Rightmove?',
faqDueDiligence1A:
'Use Perfect Postcode before and alongside listing portals. Rightmove, Zoopla, and OnTheMarket are still where you check live availability, photos, agent contact, viewings, and alerts. Perfect Postcode helps you work out which postcodes are worth searching in the first place.',
'Use Perfect Postcode before and alongside listing sites. Rightmove, Zoopla, and OnTheMarket are still where you check what is for sale now, photos, agents, viewings, and alerts. Perfect Postcode helps you decide which postcodes are worth searching in the first place.',
faqDueDiligence2Q: 'Why cant I filter by garden, garage, or layout?',
faqDueDiligence2A:
'Only where the information exists in structured official data. Perfect Postcode can filter by things like floor area, property type, tenure, EPC, sold prices, and local data. Gardens, garages, aspect, room layout, and estate-agent wording still need to be checked in the listing and at the viewing.',
'Those details are not available consistently for every home. Perfect Postcode can filter by things such as floor area, home type, ownership type, energy rating, sold prices, and local area data. Gardens, garages, aspect, room layout, and estate-agent wording still need to be checked in the listing and at the viewing.',
faqDueDiligence3Q: 'Do you track listing price cuts and time on market?',
faqDueDiligence3A:
'Not currently. Perfect Postcode is built around official sold-price, EPC, postcode, travel-time, and neighbourhood data rather than live listing feeds. You can still use date of last transaction, sold price history, estimated current value, and price per sqm to judge whether a live asking price looks stretched.',
'Not currently. Perfect Postcode is built around sold prices, energy ratings, postcode data, travel times, and neighbourhood data rather than live listing changes. You can still use sale history, estimated current value, and price per square metre to judge whether an asking price looks stretched.',
faqDueDiligence4Q: 'What should I still verify before making an offer?',
faqDueDiligence4A:
'Use Perfect Postcode to sanity-check the area and value, then verify the live listing details, tenure, lease terms, service charge, planning history, flood risk, title issues, lender requirements, and survey findings through the usual professional process.',
'Use Perfect Postcode to check the area and likely value, then confirm the listing details before making an offer. You should still check ownership type, lease details, service charges, planning history, flood risk, legal issues, mortgage requirements, and survey results.',
// FAQ items — Privacy and Data Protection
faqPrivacy1Q: 'Do you store personal data about me?',
faqPrivacy1A:
'We do not store personal data in the property and neighbourhood datasets. Those datasets are built from official and public sources for postcode and property research. If you create an account, we store only what is needed to run the service, such as your email address, licence status, newsletter preference, saved searches, saved properties, and payment identifiers handled through Stripe. We handle that account data under UK GDPR and the Data Protection Act 2018.',
'The property and neighbourhood data does not contain your personal details. If you create an account, we store only what is needed to run the service, such as your email address, access status, newsletter choice, saved searches, saved properties, and payment records handled by Stripe. We handle account data under UK privacy law.',
// FAQ items — Why Perfect Postcode
faqWhy1Q: 'What does this show that listing portals usually do not?',
faqWhy1A:
'Listing portals start from homes that are for sale right now. Perfect Postcode starts from the places that fit your life and budget, using sold prices, floor area, commute, schools, crime, noise, broadband, EPC, tenure, and amenities before you open the listings.',
'Listing sites start from homes that are for sale right now. Perfect Postcode starts from the places that fit your life and budget, using sold prices, space, commute, schools, crime, noise, broadband, energy rating, ownership type, and local amenities before you open the listings.',
faqWhy2Q: 'How much manual research does this save?',
faqWhy2A:
'You can, but it means stitching together Land Registry, EPC, police, Ofsted, Ofcom, ONS, Defra, travel-time, and map data one postcode at a time. Perfect Postcode makes those sources filterable across England in one place.',
faqWhy3Q: 'How reliable are the underlying sources?',
'You could do the research yourself, but it means checking sold prices, energy ratings, crime, schools, broadband, local facts, environment details, travel times, and maps one postcode at a time. Perfect Postcode puts those sources in one searchable map for England.',
faqWhy3Q: 'How reliable is the data?',
faqWhy3A:
'The core datasets come from official or authoritative sources such as HM Land Registry, EPC records, ONS, Ofsted, Ofcom, data.police.uk, Defra, Ordnance Survey, and OpenStreetMap. They are excellent for shortlisting and comparison, but any purchase decision still needs current checks and professional advice.',
'The main sources are official or widely used public data, including sold prices, energy ratings, local facts, school ratings, broadband, crime, environment, map, and street data. They are useful for shortlisting and comparison, but any purchase decision still needs current checks and expert advice where needed.',
// FAQ items — Pricing and Access
faqPricing1Q: 'Why pay when postcode reports are free?',
faqPricing1A:
'Free postcode tools are useful once you already know what to check. Perfect Postcode is for scanning every postcode in England against your criteria, combining filters, comparing trade-offs, saving searches, and exporting a shortlist before you commit weekends to viewings.',
'Free postcode tools are useful once you already know what to check. Perfect Postcode is for scanning every postcode in England against your needs, combining filters, comparing options, saving searches, and exporting a shortlist before you spend weekends on viewings.',
faqPricing2Q: 'What does lifetime access mean?',
faqPricing2A:
'Lifetime access means one payment gives your account ongoing access to the paid Perfect Postcode map for the lifetime of the service. It is not a monthly or annual subscription, and normal data updates are included. You can use it during this search, come back later, and still have access if you move again.',
'Lifetime access means one payment gives your account ongoing access to the paid Perfect Postcode map for as long as the service runs. It is not a monthly or annual subscription, and normal data updates are included. You can use it during this search, come back later, and still have access if you move again.',
faqPricing3Q: 'What can I access on the free tier?',
faqPricing3A:
'Free users can explore all features within the demo area (inner London, roughly zones 1 to 2). To access data for the rest of England, you need lifetime access.',
'Free users can explore all features within the demo area: inner London, roughly zones 1 to 2. To access data for the rest of England, you need lifetime access.',
// FAQ items — Tips and Tricks
faqTips1Q: 'How do I describe a search in plain English?',
faqTips1Q: 'How do I preview a filter on the map?',
faqTips1A:
'Type something like "freehold 3-bed under £550k, 45 minutes to work, quiet, good broadband", and the AI filter will set up the matching filters it understands. It will also tell you when a request, such as garden size, is not available as a structured filter.',
faqTips2Q: 'Can I save a search and come back to it later?',
'Click the eye icon beside a filter or feature to colour the map by that item. Your active filters stay in place, so the eye preview is a quick way to compare one thing, such as price, commute time, schools, crime, or noise, without changing your shortlist.',
faqTips2Q: 'How do I learn what a filter means?',
faqTips2A:
'Hit the save button and everything is captured: your filters, zoom level, and which data layer youre colouring by. Pick up exactly where you left off or share the link with your partner.',
faqTips3Q: 'Can I export the data Im looking at?',
'Click the i info button next to a filter or feature to open a short explanation of what it means and how to read it. Some areas of the map, such as travel-time cards, also have their own info button.',
faqTips3Q: 'How do I refresh the map colours?',
faqTips3A:
'Use the export button to download the currently filtered properties as a spreadsheet. The export respects your active filters, so you can take a clean shortlist into portals, viewings, spreadsheets, or conversations with someone buying with you.',
'When an eye preview is colouring the map, use Reset colour scale in the map legend to refresh the colours for the results you are looking at now. This is useful after moving the map, zooming, or changing filters.',
},
// ── Account Page ───────────────────────────────────
@ -824,12 +827,11 @@ const en = {
'Education, Skills and Training Score': 'Education, Skills and Training Score',
// ─ Feature names (Deprivation) ─
'Income Score (rate)': 'Income Score (rate)',
'Employment Score (rate)': 'Employment Score (rate)',
'Income Score': 'Income Score',
'Employment Score': 'Employment Score',
'Health Deprivation and Disability Score': 'Health Deprivation and Disability Score',
'Living Environment Score': 'Living Environment Score',
'Indoors Sub-domain Score': 'Indoors Sub-domain Score',
'Outdoors Sub-domain Score': 'Outdoors Sub-domain Score',
'Housing Conditions Score': 'Housing Conditions Score',
'Air Quality and Road Safety Score': 'Air Quality and Road Safety Score',
// ─ Feature names (Crime) ─
'Serious crime per 1k residents (avg/yr)': 'Serious crime per 1k residents (avg/yr)',

View file

@ -394,7 +394,7 @@ const fr: Translations = {
showcaseStep4ColScore: 'Ajust.',
showcaseStep4ColCommute: 'Trajet',
showcaseStep4ColPrice: 'Prix médian',
showcaseStep4Conclusion: 'Vous pouvez commencer votre recherche ici. Vous nêtes plus perdu.',
showcaseStep4Conclusion: 'Vous pouvez commencer votre recherche ici.',
statProperties: 'ventes historiques',
statFilters: 'filtres combinables',
statEvery: 'Chaque',
@ -549,99 +549,102 @@ const fr: Translations = {
dsElectionUse:
'Résultats par candidat des élections générales britanniques de juillet 2024. Agrégés au niveau de la circonscription : participation électorale (%) et parts des voix par parti (%). Reliés aux propriétés via le code de circonscription parlementaire (pcon) du répertoire de codes postaux NSPL.',
// FAQ section titles
faqFindingTitle: 'Stratégie de recherche',
faqCommuteTitle: 'Calcul des temps de trajet',
faqFindingTitle: 'Où chercher',
faqCommuteTitle: 'Temps de trajet',
faqBudgetTitle: 'Prix estimés',
faqSafetyTitle: 'Sécurité et voisinage',
faqFamiliesTitle: 'Familles et écoles',
faqEnvironmentTitle: 'Environnement et qualité de vie',
faqDueDiligenceTitle: 'Périmètre et vérifications',
faqPrivacyTitle: 'Confidentialité et protection des données',
faqDueDiligenceTitle: 'À vérifier',
faqPrivacyTitle: 'Confidentialité',
faqWhyTitle: 'Pourquoi Perfect Postcode',
faqPricingTitle: 'Accès',
faqTipsTitle: 'Astuces',
faqTipsTitle: 'Astuces carte',
// FAQ items — Finding Your Area
faqFinding1Q: 'Où chercher quand les zones évidentes sont trop chères ?',
faqFinding1A:
'Définissez le budget, le type de bien, la surface, le trajet, les écoles, la criminalité, le bruit, le débit internet, les parcs et vos autres critères indispensables. La carte retire les codes postaux qui échouent à ces tests, ce qui fait apparaître des zones moins évidentes avant même de chercher des annonces.',
'Commencez par ce qui est non négociable : budget, type de logement, espace, trajet, écoles, sécurité, bruit, internet, parcs et tout ce qui compte pour vous. La carte masque les lieux qui ne correspondent pas, afin de faire apparaître des zones moins évidentes avant de parcourir les annonces.',
faqFinding2Q: 'Comment trouver de bons codes postaux dans des lieux que je connais mal ?',
faqFinding2A:
'Filtrez toute la carte selon vos exigences fortes, puis inspectez les grappes qui restent. Vous pouvez comparer des codes postaux inconnus par trajet, prix vendus, écoles, criminalité, débit internet, bruit et services au lieu de vous fier à leur réputation.',
'Appliquez vos indispensables sur toute la carte, puis regardez de près les groupes de lieux restants. Vous pouvez comparer des codes postaux inconnus par trajet, prix vendus, écoles, criminalité, internet, bruit, commerces ou parcs à proximité, au lieu de vous fier à leur réputation.',
faqFinding3Q: 'Que faire si ma recherche renvoie trop ou trop peu de zones ?',
faqFinding3A:
'Commencez par vos limites fermes, puis colorez la carte selon un compromis comme le prix au m², le bruit routier, le score des écoles ou le temps de trajet. Si la carte devient trop restrictive, relâchez un curseur et voyez immédiatement quel compromis ouvre de nouvelles options.',
'Gardez vos indispensables, puis colorez la carte selon une chose à comparer, comme le prix au m², le bruit routier, le score des écoles ou le temps de trajet. Sil ne reste presque rien, relâchez un curseur et voyez quel changement ouvre de nouvelles options.',
// FAQ items — Commute and Travel
faqCommute1Q: 'Comment les temps de trajet sont-ils calculés ?',
faqCommute1A:
'Les temps de trajet sont précalculés avec Conveyal R5, un moteur de routage utilisé pour lanalyse des transports. Pour chaque destination prise en charge, nous calculons les trajets vers les codes postaux atteignables sur le réseau de rues et de transports, puis stockons des fichiers de temps de trajet par code postal pour la voiture, le vélo, la marche et les transports publics. La carte peut ainsi filtrer beaucoup de codes postaux rapidement au lieu dappeler une API itinéraire un par un.',
'Les temps de trajet sont calculés à lavance pour chaque destination enregistrée. Nous déterminons quels codes postaux peuvent atteindre cette destination en voiture, à vélo, à pied ou en transports publics, puis nous conservons ces résultats pour que la carte réponde vite pendant vos filtres.',
faqCommute2Q: 'Que faut-il savoir sur ces temps de trajet ?',
faqCommute2A:
'Les temps en transports publics utilisent une fenêtre de départ du matin, de 07:30 à 08:30. Par défaut, nous affichons la médiane, cest-à-dire le résultat typique sur cette fenêtre ; loption best-case utilise le 5e percentile pour un départ bien synchronisé. Ce sont des temps de comparaison modélisés, pas des données en direct sur les perturbations, le trafic ou les changements de quai.',
'Les temps en transports publics sont basés sur un trajet du matin en semaine, avec des départs entre 07:30 et 08:30. Le réglage normal montre un trajet typique sur cette période. Ce sont des estimations de planification, sans retards en direct, trafic ou changements de quai de dernière minute.',
faqCommute3Q: 'Quand utiliser le bouton Meilleur cas ?',
faqCommute3A:
'Utilisez le bouton Meilleur cas pour les trajets en transports publics lorsque vous voulez voir le résultat avec un départ bien choisi et de bonnes correspondances. Laissez-le désactivé pour une comparaison de tous les jours.',
// FAQ items — Budget and Value
faqBudget1Q: 'Comment fonctionne lalgorithme de prix actuel estimé ?',
faqBudget1Q: 'Comment estimez-vous les prix actuels ?',
faqBudget1A:
'Lestimation propriétaire part du dernier prix de vente HM Land Registry. Elle lajuste à aujourdhui avec un indice de ventes répétées appris à partir de biens vendus plusieurs fois, segmenté par secteur de code postal et type de bien. Les zones peu fournies sont rapprochées de modèles district, zone, national et hédonique, puis lissées spatialement. Le résultat est ensuite mélangé avec une estimation par plus proches voisins, à partir de biens similaires vendus récemment à proximité, du prix au m² ajusté et de la surface EPC.',
'Lestimation part du dernier prix de vente enregistré par HM Land Registry. Nous le ramenons au marché actuel en observant comment des logements similaires ont évolué, surtout les logements du même type à proximité. Quand il y a peu de ventes locales, lestimation sappuie davantage sur les tendances dune zone plus large. Elle est ensuite comparée aux ventes récentes proches et à la surface du logement.',
faqBudget2Q: 'Pourquoi utiliser le prix actuel estimé plutôt que le dernier prix vendu ?',
faqBudget2A:
'Le dernier prix vendu peut dater de plusieurs années ou décennies, et les prix demandés ne couvrent que les biens en vente aujourdhui. Le prix actuel estimé remet les anciennes ventes dans des conditions de marché plus actuelles, afin de comparer davantage de biens, calculer un prix estimé au m² et repérer les zones de valeur avant larrivée des annonces. Cest un outil de tri, pas une évaluation formelle.',
'Le dernier prix vendu peut dater de plusieurs années ou décennies, alors que les prix demandés ne couvrent que les biens en vente aujourdhui. Le prix actuel estimé rapproche les anciennes ventes du marché actuel, pour comparer plus de logements et repérer des zones qui semblent offrir une meilleure valeur. Cest un guide pour préparer une sélection, pas une estimation bancaire.',
// FAQ items — Safety and Neighbourhood
faqSafety1Q: 'Quels types de criminalité sont courants autour de ce code postal ?',
faqSafety1A:
'La criminalité enregistrée par la police est ventilée par type, notamment violences, cambriolages, vols avec violence, infractions liées aux véhicules, comportements antisociaux, vols à létalage, stupéfiants et ordre public. Vous pouvez filtrer les risques précis qui vous importent au lieu de dépendre dun score de sécurité vague.',
faqSafety2Q: 'Que vérifier avant de visiter une rue que je ne connais pas ?',
faqSafety2A:
'Vérifiez criminalité, bruit routier, défaveur, débit internet, parcs, commerces alimentaires, écoles et trajet avant de réserver. Les photos dannonce peuvent être utiles, mais elles ne devraient pas être votre première source sur la rue.',
'Vérifiez criminalité, bruit routier, internet, parcs, commerces alimentaires, écoles et trajet avant de réserver. Les photos dannonce peuvent être utiles, mais elles ne devraient pas être votre première source sur la rue.',
// FAQ items — Families and Schools
faqFamilies1Q:
'Quelles zones offrent le bon équilibre entre écoles, espace, sécurité et trajet ?',
faqFamilies1A:
'Superposez notes Ofsted, criminalité, parcs, trajet, surface, type de bien et budget sur une seule carte. Le résultat est une sélection familiale pratique, pas une pile de recherches séparées sur les écoles, la criminalité, les annonces et les transports.',
'Mettez les notes décoles, la criminalité, les parcs, le trajet, lespace, le type de logement et le budget sur une seule carte. Le résultat est une sélection familiale pratique, pas une pile de recherches séparées.',
faqFamilies2Q: 'Est-ce que cela prouve que je suis dans le secteur dune école ?',
faqFamilies2A:
'Non. Nous montrons la qualité des écoles proches et les données éducatives de la zone, mais les secteurs dadmission et règles de priorité peuvent changer. Utilisez Perfect Postcode comme outil de sélection, puis vérifiez les secteurs et admissions auprès de lécole ou de lautorité locale.',
'Non. Nous montrons la qualité des écoles proches et les informations locales sur léducation, mais les secteurs dadmission et règles de priorité peuvent changer. Utilisez Perfect Postcode pour sélectionner des lieux, puis vérifiez les admissions auprès de lécole ou de la mairie.',
// FAQ items — Environment and Quality of Life
faqEnv1Q:
'Comment éviter une route bruyante sans perdre en qualité de trajet ou de débit internet ?',
faqEnv1A:
'Filtrez par bruit routier, puis gardez actifs les filtres de trajet, débit internet, prix et bien. Vous pouvez colorer la carte selon un critère pendant que les autres gardent la sélection réaliste.',
'Filtrez par bruit routier, puis gardez actifs les filtres de trajet, internet, prix et logement. Vous pouvez colorer la carte selon un critère pendant que les autres gardent la sélection réaliste.',
faqEnv2Q: 'Affichez-vous le risque dinondation, daffaissement ou de survey ?',
faqEnv2A:
'Pas sous forme de filtres en direct aujourdhui. Nous affichons des données comme le bruit routier, lEPC, lâge de construction et certains indicateurs locaux, mais les recherches dinondation, les titres, les problèmes structurels et la capacité de financement doivent toujours être vérifiés par les professionnels habituels.',
'Pas aujourdhui. Nous affichons le bruit routier, la note énergétique, lâge du bâtiment et lenvironnement local autour du code postal. Le risque dinondation, les questions juridiques, les problèmes de structure, le prêt immobilier et le survey doivent encore être vérifiés séparément avant dacheter.',
faqEnv3Q: 'Quels coûts dusage puis-je vérifier avant une visite ?',
faqEnv3A:
'Vous pouvez préfiltrer par DPE, surface totale, âge de construction, autorité de council tax, débit internet et bruit avant la visite. Cela ne prédit pas vos factures exactes, mais aide à éviter tôt les incompatibilités évidentes.',
'Vous pouvez vérifier la note énergétique, la surface, lâge du bâtiment, la zone de council tax, internet et le bruit avant la visite. Cela ne prédit pas vos factures exactes, mais aide à éviter tôt les incompatibilités évidentes.',
// FAQ items — Listing Portals and Due Diligence
faqDueDiligence1Q: 'Faut-il lutiliser avant ou après Rightmove ?',
faqDueDiligence1A:
'Utilisez Perfect Postcode avant et en parallèle des portails dannonces. Rightmove, Zoopla et OnTheMarket restent utiles pour la disponibilité en temps réel, les photos, le contact avec lagent, les visites et les alertes. Perfect Postcode sert à décider quels codes postaux valent la peine dêtre recherchés.',
'Utilisez Perfect Postcode avant et en parallèle des sites dannonces. Rightmove, Zoopla et OnTheMarket restent les endroits où vérifier ce qui est en vente maintenant, les photos, les agents, les visites et les alertes. Perfect Postcode vous aide à décider quels codes postaux valent la peine dêtre cherchés.',
faqDueDiligence2Q: 'Puis-je filtrer par jardin, garage, agencement ou texte dannonce ?',
faqDueDiligence2A:
'Seulement lorsque linformation existe dans des données officielles structurées. Perfect Postcode peut filtrer la surface, le type de bien, le régime foncier, lEPC, les prix de vente et les données locales. Les jardins, garages, orientation, agencements et formulations dagent doivent encore être vérifiés dans lannonce et lors de la visite.',
'Ces détails ne sont pas disponibles de façon fiable pour chaque logement. Perfect Postcode peut filtrer la surface, le type de logement, le type de propriété, la note énergétique, les prix vendus et les informations locales. Les jardins, garages, orientation, agencements et formulations dagent doivent encore être vérifiés dans lannonce et lors de la visite.',
faqDueDiligence3Q:
'Puis-je voir lhistorique des baisses de prix ou la durée de mise en ligne ?',
faqDueDiligence3A:
'Pas actuellement. Perfect Postcode sappuie sur les prix de vente officiels, lEPC, les codes postaux, les temps de trajet et les données de quartier plutôt que sur des flux dannonces en direct. Vous pouvez tout de même utiliser la date de dernière transaction, lhistorique des ventes, la valeur actuelle estimée et le prix au m² pour juger un prix demandé.',
'Pas actuellement. Perfect Postcode sappuie sur les prix vendus, les notes énergétiques, les codes postaux, les temps de trajet et les informations de quartier plutôt que sur les changements dannonces en direct. Vous pouvez tout de même utiliser lhistorique des ventes, la valeur actuelle estimée et le prix au m² pour juger si un prix demandé semble élevé.',
faqDueDiligence4Q: 'Que dois-je encore vérifier avant de faire une offre ?',
faqDueDiligence4A:
'Utilisez Perfect Postcode pour vérifier la zone et la valeur, puis contrôlez les détails de lannonce, le régime foncier, les conditions de leasehold, les charges, lhistorique durbanisme, le risque dinondation, les titres, les exigences du prêteur et le survey via le processus professionnel habituel.',
'Utilisez Perfect Postcode pour vérifier la zone et la valeur probable, puis confirmez les détails de lannonce avant de faire une offre. Vérifiez aussi le type de propriété, les conditions de leasehold, les charges, lhistorique durbanisme, le risque dinondation, les questions juridiques, les exigences du prêt et le survey.',
// FAQ items — Privacy and Data Protection
faqPrivacy1Q: 'Stockez-vous des données personnelles me concernant ?',
faqPrivacy1A:
'Nous ne stockons pas de données personnelles dans les jeux de données immobilières et de quartier. Ces données proviennent de sources officielles et publiques et servent à la recherche par code postal et par propriété. Si vous créez un compte, nous stockons uniquement ce qui est nécessaire au fonctionnement du service, comme votre adresse e-mail, votre statut de licence, votre préférence newsletter, vos recherches enregistrées, vos biens enregistrés et les identifiants de paiement traités par Stripe. Ces données de compte sont traitées conformément au UK GDPR et au Data Protection Act 2018.',
'Les informations immobilières et de quartier ne contiennent pas vos données personnelles. Si vous créez un compte, nous stockons seulement ce qui est nécessaire au service, comme votre adresse e-mail, votre statut daccès, votre choix de newsletter, vos recherches enregistrées, vos biens enregistrés et les paiements gérés par Stripe. Ces données de compte sont traitées selon la loi britannique sur la confidentialité.',
// FAQ items — Why Perfect Postcode
faqWhy1Q: 'Que montre cet outil que les portails dannonces ne montrent généralement pas ?',
faqWhy1A:
'Les portails dannonces partent des logements à vendre aujourdhui. Perfect Postcode part des lieux qui correspondent à votre vie et votre budget, avec prix vendus, surface, trajet, écoles, criminalité, bruit, débit internet, DPE, tenure et services avant douvrir les annonces.',
'Les sites dannonces partent des logements à vendre aujourdhui. Perfect Postcode part des lieux qui correspondent à votre vie et votre budget, avec prix vendus, espace, trajet, écoles, criminalité, bruit, internet, note énergétique, type de propriété et services avant douvrir les annonces.',
faqWhy2Q: 'Combien de recherche manuelle cela économise-t-il ?',
faqWhy2A:
'Vous pouvez le faire, mais cela revient à assembler Land Registry, EPC, police, Ofsted, Ofcom, ONS, Defra, temps de trajet et données cartographiques code postal par code postal. Perfect Postcode rend ces sources filtrables dans toute lAngleterre au même endroit.',
faqWhy3Q: 'Quelle est la fiabilité des sources sous-jacentes ?',
'Vous pourriez le faire vous-même, mais il faudrait vérifier prix vendus, notes énergétiques, criminalité, écoles, internet, informations locales, environnement, trajets et cartes code postal par code postal. Perfect Postcode rassemble ces sources dans une carte consultable pour lAngleterre.',
faqWhy3Q: 'Quelle est la fiabilité des données ?',
faqWhy3A:
'Les jeux de données principaux proviennent de sources officielles ou reconnues comme HM Land Registry, les registres EPC, ONS, Ofsted, Ofcom, data.police.uk, Defra, Ordnance Survey et OpenStreetMap. Ils sont excellents pour sélectionner et comparer, mais toute décision dachat exige encore des vérifications à jour et des conseils professionnels.',
'Les principales sources sont officielles ou largement utilisées : prix vendus, notes énergétiques, informations locales, écoles, internet, criminalité, environnement, cartes et rues. Elles sont utiles pour sélectionner et comparer, mais toute décision dachat demande encore des vérifications à jour et, si besoin, un avis expert.',
// FAQ items — Pricing and Access
faqPricing1Q: 'Pourquoi payer alors que les rapports de code postal sont gratuits ?',
faqPricing1A:
'Les outils gratuits par code postal sont utiles quand vous savez déjà quoi vérifier. Perfect Postcode sert à analyser chaque code postal dAngleterre selon vos critères, combiner les filtres, comparer les compromis, enregistrer les recherches et exporter une sélection avant dengager vos week-ends de visites.',
'Les outils gratuits par code postal sont utiles quand vous savez déjà quoi vérifier. Perfect Postcode sert à analyser chaque code postal dAngleterre selon vos besoins, combiner les filtres, comparer les options, enregistrer les recherches et exporter une sélection avant dengager vos week-ends de visites.',
faqPricing2Q: 'Que signifie laccès à vie ?',
faqPricing2A:
'Laccès à vie signifie quun paiement donne à votre compte un accès continu à la carte payante Perfect Postcode pendant la durée de vie du service. Ce nest pas un abonnement mensuel ou annuel, et les mises à jour normales des données sont incluses. Vous pouvez lutiliser pendant cette recherche, revenir plus tard et conserver laccès si vous déménagez à nouveau.',
@ -650,15 +653,15 @@ const fr: Translations = {
'Les utilisateurs gratuits peuvent explorer toutes les fonctionnalités dans la zone de démonstration (centre de Londres, approximativement zones 1 à 2). Pour accéder aux données du reste de lAngleterre, il faut laccès à vie.',
// FAQ items — Tips and Tricks
faqTips1Q: 'Comment décrire une recherche en langage courant ?',
faqTips1Q: 'Comment prévisualiser un filtre sur la carte ?',
faqTips1A:
'Tapez par exemple « freehold 3-bed under £550k, 45 minutes to work, quiet, good broadband », et le filtre IA configurera les filtres correspondants quil comprend. Il vous indiquera aussi lorsquune demande, comme la taille du jardin, nest pas disponible comme filtre structuré.',
faqTips2Q: 'Puis-je enregistrer une recherche et y revenir plus tard ?',
'Cliquez sur licône en forme dœil à côté dun filtre ou dune donnée pour colorer la carte selon cet élément. Vos filtres actifs restent en place, ce qui permet de comparer rapidement une chose comme le prix, le trajet, les écoles, la criminalité ou le bruit sans modifier la sélection.',
faqTips2Q: 'Comment comprendre ce que signifie un filtre ?',
faqTips2A:
'Cliquez sur le bouton denregistrement et tout est capturé : vos filtres, le niveau de zoom et la couche de données affichée. Reprenez exactement où vous en étiez ou partagez le lien avec votre conjoint.',
faqTips3Q: 'Puis-je exporter les données que je consulte ?',
'Cliquez sur le bouton dinformation i à côté dun filtre ou dune donnée pour voir une courte explication de ce que cela signifie et comment le lire. Certaines parties de la carte, comme les cartes de temps de trajet, ont aussi leur propre bouton dinformation.',
faqTips3Q: 'Comment actualiser les couleurs de la carte ?',
faqTips3A:
'Utilisez le bouton dexportation pour télécharger les biens actuellement filtrés sous forme de tableur. Lexport respecte vos filtres actifs, afin demporter une sélection propre vers les portails, visites, tableurs ou discussions avec la personne qui achète avec vous.',
'Lorsquune prévisualisation avec lœil colore la carte, utilisez Réinitialiser léchelle de couleur dans la légende pour actualiser les couleurs des résultats affichés. Cest utile après un déplacement, un zoom ou une modification des filtres.',
},
// ── Account Page ───────────────────────────────────
@ -838,12 +841,11 @@ const fr: Translations = {
'Education, Skills and Training Score': 'Score éducation, compétences et formation',
// ─ Feature names (Deprivation) ─
'Income Score (rate)': 'Score de revenu (taux)',
'Employment Score (rate)': 'Score demploi (taux)',
'Income Score': 'Score de revenu',
'Employment Score': 'Score demploi',
'Health Deprivation and Disability Score': 'Score de santé et handicap',
'Living Environment Score': 'Score du cadre de vie',
'Indoors Sub-domain Score': 'Score du sous-domaine intérieur',
'Outdoors Sub-domain Score': 'Score du sous-domaine extérieur',
'Housing Conditions Score': 'Score des conditions de logement',
'Air Quality and Road Safety Score': 'Score qualité de lair et sécurité routière',
// ─ Feature names (Crime) ─
'Serious crime per 1k residents (avg/yr)': 'Crimes graves pour 1k habitants (moy./an)',

View file

@ -366,7 +366,7 @@ const hi: Translations = {
showcaseStep4ColScore: 'फिट',
showcaseStep4ColCommute: 'आवागमन',
showcaseStep4ColPrice: 'मीडियन बिक्री',
showcaseStep4Conclusion: 'आप अपनी यात्रा यहां से शुरू कर सकते हैं. अब आप भटके हुए नहीं हैं.',
showcaseStep4Conclusion: 'आप अपनी यात्रा यहां से शुरू कर सकते हैं.',
statProperties: 'ऐतिहासिक बिक्री',
statFilters: 'जोड़े जा सकने वाले फिल्टर',
statEvery: 'हर',
@ -515,101 +515,104 @@ const hi: Translations = {
dsElectionOrigin: 'UK Parliament',
dsElectionUse:
'July 2024 UK General Election के candidate-level results. Constituency level पर aggregated: voter turnout (%) और party vote shares (%). NSPL postcode lookup से parliamentary constituency code (pcon) के जरिए properties से joined.',
faqFindingTitle: 'खोज रणनीति',
faqCommuteTitle: 'यात्रा-समय रूटिंग',
faqFindingTitle: 'कहां देखें',
faqCommuteTitle: 'यात्रा समय',
faqBudgetTitle: 'अनुमानित कीमतें',
faqSafetyTitle: 'सुरक्षा और पड़ोस',
faqFamiliesTitle: 'परिवार और स्कूल',
faqEnvironmentTitle: 'पर्यावरण और जीवन गुणवत्ता',
faqDueDiligenceTitle: 'दायरा और Due Diligence',
faqPrivacyTitle: 'गोपनीयता और डेटा संरक्षण',
faqDueDiligenceTitle: 'क्या जांचें',
faqPrivacyTitle: 'गोपनीयता',
faqWhyTitle: 'Perfect Postcode क्यों',
faqPricingTitle: 'एक्सेस',
faqTipsTitle: 'टिप्स और ट्रिक्स',
faqTipsTitle: 'मानचित्र टिप्स',
faqFinding1Q: 'जब स्पष्ट क्षेत्र बहुत महंगे हों तो मुझे कहां देखना चाहिए?',
faqFinding1A:
'अपना बजट, संपत्ति प्रकार, फर्श क्षेत्र, आवागमन, स्कूल, अपराध, शोर, ब्रॉडबैंड, पार्क और अन्य अनिवार्य जरूरतें सेट करें. मानचित्र उन पोस्टकोड को हटाता है जो इन कसौटियों पर खरे नहीं उतरते, इसलिए उपेक्षित क्षेत्र लिस्टिंग खोजना शुरू करने से पहले सामने आ सकते हैं.',
'जिन बातों पर आप समझौता नहीं कर सकते उनसे शुरू करें: बजट, घर का प्रकार, जगह, आवागमन, स्कूल, सुरक्षा, शोर, इंटरनेट, पार्क और बाकी जरूरी बातें. मानचित्र वे जगहें छिपा देता है जो फिट नहीं बैठतीं, ताकि लिस्टिंग देखने से पहले कम स्पष्ट विकल्प सामने आ सकें.',
faqFinding2Q: 'जिन जगहों को मैं अच्छी तरह नहीं जानता, वहां अच्छे पोस्टकोड कैसे खोजूं?',
faqFinding2A:
'पूरे मानचित्र को अपनी कठोर जरूरतों से फिल्टर करें, फिर बचे हुए समूहों को जांचें. आप प्रतिष्ठा पर निर्भर रहने के बजाय अनजान पोस्टकोड की आवागमन, बेचे गए दाम, स्कूल, अपराध, ब्रॉडबैंड, शोर और सुविधाओं से तुलना कर सकते हैं.',
'पूरे मानचित्र पर अपनी जरूरी शर्तें लगाएं, फिर बचे हुए क्षेत्रों को करीब से देखें. आप अनजान पोस्टकोड की तुलना आवागमन, बेचे गए दाम, स्कूल, अपराध, इंटरनेट, शोर और पास की दुकानों या पार्कों से कर सकते हैं, सिर्फ प्रतिष्ठा से नहीं.',
faqFinding3Q: 'जब मेरी खोज बहुत ज्यादा या बहुत कम क्षेत्र लौटाए तो क्या करूं?',
faqFinding3A:
'कठोर सीमाओं से शुरू करें, फिर प्रति वर्ग मी कीमत, सड़क शोर, स्कूल स्कोर या आवागमन समय जैसे समझौते से मानचित्र को रंगें. अगर मानचित्र बहुत संकरा हो जाए, एक स्लाइडर ढीला करें और आप देख सकते हैं कौन सा समझौता ज्यादा विकल्प खोलता है.',
'अपनी जरूरी शर्तें रखें, फिर मानचित्र को उस एक चीज से रंगें जिसकी तुलना करनी है, जैसे प्रति वर्ग मी कीमत, सड़क शोर, स्कूल स्कोर या आवागमन समय. अगर लगभग कुछ नहीं बचता, एक स्लाइडर ढीला करें और देखें कौन सा बदलाव नए विकल्प खोलता है.',
faqCommute1Q: 'यात्रा समय कैसे गणना किए जाते हैं?',
faqCommute1A:
'यात्रा समय Conveyal R5 से पहले से गणना किए गए हैं, जो परिवहन विश्लेषण में इस्तेमाल होने वाला रूटिंग इंजन है. हर समर्थित गंतव्य के लिए हम सड़क और सार्वजनिक परिवहन नेटवर्क पर पहुंचने योग्य पोस्टकोड तक मार्ग निकालते हैं, फिर कार, साइकिल, पैदल और सार्वजनिक परिवहन के लिए संक्षिप्त पोस्टकोड यात्रा-समय फाइलें रखते हैं. इससे मानचित्र हजारों पोस्टकोड को एक-एक रूट API कॉल करने के बजाय तेजी से फिल्टर कर सकता है.',
'यात्रा समय हर सहेजे गए गंतव्य के लिए पहले से निकाले जाते हैं. हम देखते हैं कि कौन से पोस्टकोड कार, साइकिल, पैदल या सार्वजनिक परिवहन से उस गंतव्य तक पहुंच सकते हैं, फिर परिणाम सहेजते हैं ताकि फिल्टर करते समय मानचित्र तेजी से जवाब दे.',
faqCommute2Q: 'Travel-time numbers के बारे में क्या जानना चाहिए?',
faqCommute2A:
'सार्वजनिक परिवहन समय सुबह के व्यस्त प्रस्थान समय, 07:30 से 08:30, का उपयोग करते हैं. डिफॉल्ट मीडियन यात्रा समय है, जो उस अवधि का सामान्य परिणाम है; best-case टॉगल सही समय पर निकलने पर 5वें पर्सेंटाइल का उपयोग करता है. ये मॉडल किए गए तुलनात्मक समय हैं, लाइव बाधा, ट्रैफिक या प्लेटफॉर्म-परिवर्तन की भविष्यवाणी नहीं.',
faqBudget1Q: 'Estimated current price algorithm कैसे काम करता है?',
'सार्वजनिक परिवहन समय सप्ताह के दिन सुबह के आवागमन पर आधारित हैं, 07:30 से 08:30 के बीच निकलने पर. सामान्य सेटिंग उस समय की आम यात्रा दिखाती है. ये योजना बनाने के अनुमान हैं, लाइव देरी, ट्रैफिक या आखिरी समय के प्लेटफॉर्म बदलाव नहीं.',
faqCommute3Q: 'Best case button कब उपयोग करें?',
faqCommute3A:
'सार्वजनिक परिवहन के लिए Best case button तब उपयोग करें जब आप अच्छी timing और अच्छे connections वाली यात्रा देखना चाहते हैं. रोजमर्रा की तुलना के लिए इसे off रखें.',
faqBudget1Q: 'आप मौजूदा property prices कैसे estimate करते हैं?',
faqBudget1A:
'Proprietary estimate अंतिम HM Land Registry sale price से शुरू होता है. यह repeat-sales index से कीमत को आज तक adjust करता है, जिसे postcode sector और property type के अनुसार सीखा गया है. Sparse areas को district, area, national और hedonic fallback models की ओर shrink किया जाता है, फिर spatially smooth किया जाता है. अंत में result को nearby, recently sold, same-type homes से adjusted price per sqm और EPC floor area का उपयोग करके nearest-neighbour estimate के साथ blend किया जाता है.',
'Estimate घर की HM Land Registry में दर्ज आखिरी बिक्री कीमत से शुरू होता है. हम उसे आज के बाजार के करीब लाते हैं, यह देखकर कि इसी तरह के घरों की कीमत समय के साथ कैसे बदली है, खासकर पास के उसी प्रकार के घरों की. जहां स्थानीय बिक्री कम है, estimate बड़े क्षेत्र के रुझानों पर ज्यादा भरोसा करता है. फिर इसे पास की हाल की बिक्री और floor area से मिलाकर जांचा जाता है.',
faqBudget2Q: 'Last sold price के बजाय estimated current price क्यों उपयोग करें?',
faqBudget2A:
'अंतिम बिक्री कीमत कई साल या दशकों पुरानी हो सकती है, और लाइव मांग कीमतें केवल आज सूचीबद्ध घरों को कवर करती हैं. अनुमानित मौजूदा कीमत पुराने सौदों को मौजूदा बाजार के संदर्भ में रखती है ताकि आप अधिक संपत्तियों की तुलना कर सकें, अनुमानित प्रति वर्ग मी कीमत निकाल सकें और लिस्टिंग आने से पहले अच्छी वैल्यू वाले क्षेत्रों को पहचान सकें. यह छांटने के लिए अनुमान है, औपचारिक मूल्यांकन नहीं.',
'अंतिम बिक्री कीमत कई साल या दशकों पुरानी हो सकती है, जबकि मांग कीमतें केवल आज सूचीबद्ध घरों को दिखाती हैं. अनुमानित मौजूदा कीमत पुराने सौदों को आज के बाजार के करीब लाती है, ताकि आप अधिक घरों की तुलना कर सकें और बेहतर value वाले क्षेत्र पहचान सकें. इसे shortlist बनाने की guide मानें, bank valuation नहीं.',
faqSafety1Q: 'इस पोस्टकोड के आसपास किस तरह का अपराध आम है?',
faqSafety1A:
'पुलिस-रिकॉर्ड अपराध प्रकार के अनुसार बंटा होता है, जिसमें हिंसा, सेंधमारी, लूट, वाहन अपराध, असामाजिक व्यवहार, दुकान से चोरी, ड्रग्स और सार्वजनिक व्यवस्था शामिल हैं. आप अस्पष्ट सुरक्षा स्कोर पर निर्भर रहने के बजाय खास जोखिमों से फिल्टर कर सकते हैं.',
faqSafety2Q: 'किसी अनजान सड़क पर viewing से पहले क्या जांचना चाहिए?',
faqSafety2A:
'मकान देखने की बुकिंग से पहले अपराध, सड़क शोर, वंचना, ब्रॉडबैंड, पार्क, किराना, स्कूल और आवागमन जांचें. लिस्टिंग फोटो उपयोगी हो सकती हैं, पर सड़क कैसी है यह पहली बार उनसे नहीं पता चलना चाहिए.',
'मकान देखने की बुकिंग से पहले अपराध, सड़क शोर, इंटरनेट, पार्क, किराना, स्कूल और आवागमन जांचें. लिस्टिंग फोटो उपयोगी हो सकती हैं, पर सड़क कैसी है यह पहली बार उनसे नहीं पता चलना चाहिए.',
faqFamilies1Q: 'किन क्षेत्रों में schools, space, safety और commute का सही मिश्रण है?',
faqFamilies1A:
'Ofsted रेटिंग, अपराध, पार्क, आवागमन, फर्श क्षेत्र, संपत्ति प्रकार और बजट को एक मानचित्र पर साथ रखें. परिणाम अलग-अलग स्कूल, अपराध, लिस्टिंग और परिवहन खोजों के ढेर के बजाय व्यावहारिक पारिवारिक शॉर्टलिस्ट है.',
'School ratings, अपराध, पार्क, आवागमन, जगह, घर का प्रकार और बजट को एक मानचित्र पर रखें. परिणाम अलग-अलग स्कूल, अपराध, लिस्टिंग और परिवहन खोजों के ढेर के बजाय व्यावहारिक पारिवारिक shortlist है.',
faqFamilies2Q: 'क्या यह साबित करता है कि मैं school catchment के अंदर हूं?',
faqFamilies2A:
'नहीं. हम नजदीकी स्कूल गुणवत्ता और क्षेत्र-स्तर शिक्षा डेटा दिखाते हैं, लेकिन प्रवेश सीमाएं और प्राथमिकता नियम बदल सकते हैं. Perfect Postcode को शॉर्टलिस्ट टूल मानें, फिर स्कूल या स्थानीय प्राधिकरण से कैचमेंट और प्रवेश सत्यापित करें.',
'नहीं. हम नजदीकी स्कूल गुणवत्ता और स्थानीय शिक्षा जानकारी दिखाते हैं, लेकिन प्रवेश सीमाएं और प्राथमिकता नियम बदल सकते हैं. Perfect Postcode से जगहें shortlist करें, फिर स्कूल या स्थानीय council से catchment और admission जांचें.',
faqEnv1Q: 'Commute या broadband quality खोए बिना noisy road से कैसे बचूं?',
faqEnv1A:
'सड़क शोर से फिल्टर करें, फिर आवागमन समय, ब्रॉडबैंड स्पीड, कीमत और संपत्ति फिल्टर सक्रिय रखें. आप एक फीचर से मानचित्र को रंग सकते हैं जबकि बाकी शॉर्टलिस्ट को वास्तविक बनाए रखते हैं.',
'सड़क शोर से filter करें, फिर आवागमन, इंटरनेट, कीमत और घर के filters चालू रखें. आप एक feature से map रंग सकते हैं जबकि बाकी shortlist को उपयोगी बनाए रखते हैं.',
faqEnv2Q: 'क्या आप flood risk, subsidence या survey issues दिखाते हैं?',
faqEnv2A:
'आज लाइव फिल्टर के रूप में नहीं. हम सड़क शोर, EPC, निर्माण आयु और स्थानीय पर्यावरण संकेतकों जैसे डेटा दिखाते हैं, पर बाढ़ खोज, टाइटल जांच, संरचनात्मक समस्याएं और मॉर्गेज योग्यता के लिए अभी भी सही कन्वेयंसिंग, ऋणदाता जांच और survey चाहिए.',
'आज नहीं. हम सड़क शोर, energy rating, building age और postcode के आसपास का local environment दिखाते हैं. Flood risk, legal issues, structural issues, mortgage concerns और survey findings खरीदने से पहले अलग से जांचने होंगे.',
faqEnv3Q: 'Viewing से पहले running-cost checks क्या कर सकता हूं?',
faqEnv3A:
'आप मकान देखने से पहले EPC रेटिंग, कुल फर्श क्षेत्र, निर्माण आयु, council tax authority, ब्रॉडबैंड और शोर की छंटाई कर सकते हैं. यह आपके सटीक बिलों की भविष्यवाणी नहीं करेगा, पर साफ तौर पर गलत विकल्पों से जल्दी बचने में मदद करेगा.',
'आप मकान देखने से पहले energy rating, floor area, building age, council tax area, इंटरनेट और शोर देख सकते हैं. यह आपके सटीक बिलों की भविष्यवाणी नहीं करेगा, पर साफ तौर पर गलत विकल्पों से जल्दी बचने में मदद करेगा.',
faqDueDiligence1Q: 'क्या मुझे Rightmove देखने से पहले या बाद में इसका उपयोग करना चाहिए?',
faqDueDiligence1A:
'Perfect Postcode का उपयोग प्रॉपर्टी पोर्टल से पहले और साथ-साथ करें. Rightmove, Zoopla और OnTheMarket अभी भी लाइव उपलब्धता, फोटो, एजेंट संपर्क, देखने की बुकिंग और अलर्ट जांचने की जगह हैं. Perfect Postcode आपको पहले यह समझने में मदद करता है कि किन पोस्टकोड में खोज करना सार्थक है.',
'Perfect Postcode का उपयोग listing sites से पहले और साथ-साथ करें. Rightmove, Zoopla और OnTheMarket अभी भी अभी बिक रहे घर, फोटो, एजेंट, viewing और alerts देखने की जगह हैं. Perfect Postcode आपको तय करने में मदद करता है कि किन पोस्टकोड में खोज करन है.',
faqDueDiligence2Q: 'मैं garden, garage या layout से filter क्यों नहीं कर सकता?',
faqDueDiligence2A:
'केवल वहां जहां जानकारी संरचित आधिकारिक डेटा में मौजूद है. Perfect Postcode फर्श क्षेत्र, संपत्ति प्रकार, टेन्योर, EPC, बेचे गए दाम और स्थानीय डेटा जैसी चीजों से फिल्टर कर सकता है. बगीचे, गैरेज, दिशा, कमरों की बनावट और एजेंट की भाषा अभी भी लिस्टिंग और मकान देखने के दौरान जांचनी होगी.',
'ये details हर घर के लिए भरोसेमंद तरीके से उपलब्ध नहीं होतीं. Perfect Postcode floor area, home type, ownership type, energy rating, sold prices और local information से filter कर सकता है. बगीचे, गैरेज, दिशा, कमरे और agent wording अभी भी listing और viewing में जांचनी होगी.',
faqDueDiligence3Q: 'क्या आप listing price cuts और time on market track करते हैं?',
faqDueDiligence3A:
'अभी नहीं. Perfect Postcode लाइव लिस्टिंग फीड के बजाय आधिकारिक बेचे गए दाम, EPC, पोस्टकोड, यात्रा-समय और पड़ोस डेटा पर बना है. आप फिर भी अंतिम लेनदेन की तारीख, बिक्री इतिहास, अनुमानित मौजूदा मूल्य और प्रति वर्ग मी कीमत से अंदाजा लगा सकते हैं कि लाइव मांग कीमत ज्यादा तो नहीं लगती.',
'अभी नहीं. Perfect Postcode sold prices, energy ratings, postcode data, travel times और neighbourhood information पर बना है, live listing changes पर नहीं. आप फिर भी sale history, estimated current value और price per square metre से अंदाजा लगा सकते हैं कि asking price ज्यादा तो नहीं लगती.',
faqDueDiligence4Q: 'Offer करने से पहले मुझे क्या verify करना चाहिए?',
faqDueDiligence4A:
'क्षेत्र और मूल्य की समझदारी से जांच के लिए Perfect Postcode का उपयोग करें, फिर सामान्य पेशेवर प्रक्रिया से लाइव लिस्टिंग विवरण, टेन्योर, लीज शर्तें, सर्विस चार्ज, योजना इतिहास, बाढ़ जोखिम, टाइटल समस्याएं, ऋणदाता आवश्यकताएं और survey निष्कर्ष सत्यापित करें.',
'क्षेत्र और संभावित value जांचने के लिए Perfect Postcode उपयोग करें, फिर offer करने से पहले listing details confirm करें. Ownership type, lease details, service charges, planning history, flood risk, legal issues, mortgage requirements और survey results भी जांचें.',
faqPrivacy1Q: 'क्या आप मेरे बारे में personal data store करते हैं?',
faqPrivacy1A:
'हम संपत्ति और पड़ोस डेटासेट में व्यक्तिगत डेटा नहीं रखते. ये डेटासेट पोस्टकोड और संपत्ति शोध के लिए आधिकारिक और सार्वजनिक स्रोतों से बने हैं. अगर आप खाता बनाते हैं, तो सेवा चलाने के लिए जरूरी डेटा ही रखते हैं, जैसे ईमेल पता, लाइसेंस स्थिति, newsletter पसंद, सहेजी गई खोजें, सहेजी गई संपत्तियां और Stripe द्वारा संभाले गए भुगतान पहचानकर्ता. हम खाता डेटा को UK GDPR और Data Protection Act 2018 के तहत संभालते हैं.',
'Property और neighbourhood information में आपके personal details नहीं होते. अगर आप account बनाते हैं, तो हम service चलाने के लिए जरूरी चीजें रखते हैं, जैसे email address, access status, newsletter choice, saved searches, saved properties और Stripe द्वारा handled payments. Account data को UK privacy law के तहत संभाला जाता है.',
faqWhy1Q: 'यह क्या दिखाता है जो listing portals आमतौर पर नहीं दिखाते?',
faqWhy1A:
'प्रॉपर्टी पोर्टल आज बिक्री पर मौजूद घरों से शुरू करते हैं. Perfect Postcode उन जगहों से शुरू करता है जो आपके जीवन और बजट से मेल खाती हैं, बेचे गए दाम, फर्श क्षेत्र, आवागमन, स्कूल, अपराध, शोर, ब्रॉडबैंड, EPC, टेन्योर और सुविधाओं के साथ, लिस्टिंग खोलने से पहले.',
'Listing sites आज बिक रहे घरों से शुरू करती हैं. Perfect Postcode उन जगहों से शुरू करता है जो आपके जीवन और बजट से मेल खाती हैं, sold prices, space, commute, schools, crime, noise, internet, energy rating, ownership type और amenities के साथ, listings खोलने से पहले.',
faqWhy2Q: 'यह कितनी manual research बचाता है?',
faqWhy2A:
'आप कर सकते हैं, लेकिन इसका मतलब Land Registry, EPC, पुलिस, Ofsted, Ofcom, ONS, Defra, यात्रा-समय और मानचित्र डेटा को एक-एक पोस्टकोड से जोड़ना है. Perfect Postcode इन स्रोतों को पूरे इंग्लैंड में एक जगह फिल्टर करने योग्य बनाता है.',
faqWhy3Q: 'Underlying sources कितने reliable हैं?',
'आप खुद कर सकते हैं, लेकिन तब sold prices, energy ratings, crime, schools, internet, local facts, environment, travel times और maps को एक-एक postcode से जांचना होगा. Perfect Postcode इन्हें England के searchable map में साथ रखता है.',
faqWhy3Q: 'Data कितना reliable है?',
faqWhy3A:
'मुख्य डेटासेट HM Land Registry, EPC रिकॉर्ड, ONS, Ofsted, Ofcom, data.police.uk, Defra, Ordnance Survey और OpenStreetMap जैसे आधिकारिक या विश्वसनीय स्रोतों से आते हैं. वे शॉर्टलिस्ट और तुलना के लिए बहुत अच्छे हैं, लेकिन किसी भी खरीद निर्णय के लिए मौजूदा जांच और पेशेवर सलाह जरूरी है.',
'मुख्य sources official या widely used public data हैं: sold prices, energy ratings, local facts, school ratings, internet, crime, environment, maps और street data. ये shortlist और comparison के लिए उपयोगी हैं, लेकिन खरीद निर्णय से पहले current checks और जरूरत हो तो expert advice जरूरी है.',
faqPricing1Q: 'जब postcode reports free हैं तो भुगतान क्यों करें?',
faqPricing1A:
'मुफ्त पोस्टकोड टूल तब उपयोगी हैं जब आप पहले से जानते हैं कि क्या जांचना है. Perfect Postcode इंग्लैंड के हर पोस्टकोड को आपकी कसौटियों पर स्कैन करने, फिल्टर जोड़ने, समझौतों की तुलना करने, खोजें सहेजने और देखने में सप्ताहांत लगाने से पहले शॉर्टलिस्ट निर्यात करने के लिए है.',
'मुफ्त postcode tools तब उपयोगी हैं जब आप पहले से जानते हैं कि क्या जांचना है. Perfect Postcode England के हर postcode को आपकी जरूरतों के हिसाब से देखने, filters जोड़ने, options compare करने, searches save करने और viewings से पहले shortlist export करने के लिए है.',
faqPricing2Q: 'Lifetime access का क्या मतलब है?',
faqPricing2A:
'लाइफटाइम एक्सेस का मतलब है कि एक भुगतान आपके खाते को सेवा की अवधि तक paid Perfect Postcode मानचित्र का लगातार एक्सेस देता है. यह मासिक या वार्षिक सदस्यता नहीं है, और सामान्य डेटा अपडेट शामिल हैं. आप इसे इस खोज में उपयोग कर सकते हैं, बाद में लौट सकते हैं और फिर भी एक्सेस रहेगा अगर आप फिर स्थान बदलते हैं.',
faqPricing3Q: 'Free tier पर मैं क्या access कर सकता हूं?',
faqPricing3A:
'मुफ्त उपयोगकर्ता डेमो क्षेत्र (inner London, लगभग zones 1 to 2) के अंदर सभी फीचर देख सकते हैं. England के बाकी डेटा के लिए लाइफटाइम एक्सेस चाहिए.',
faqTips1Q: 'Plain English में search कैसे describe करूं?',
faqTips1Q: 'Map पर filter preview कैसे करें?',
faqTips1A:
'कुछ ऐसा लिखें: "freehold 3-bed under £550k, 45 minutes to work, quiet, good broadband", और AI फिल्टर समझे गए मिलान वाले फिल्टर सेट करेगा. यह आपको यह भी बताएगा जब कोई मांग, जैसे बगीचे का आकार, संरचित फिल्टर के रूप में उपलब्ध नहीं है.',
faqTips2Q: 'क्या मैं search save करके बाद में वापस आ सकता हूं?',
'किसी filter या feature के पास eye icon पर click करें ताकि map उसी item से colour हो जाए. आपके active filters वैसे ही रहते हैं, इसलिए आप price, commute time, schools, crime या noise जैसी एक चीज shortlist बदले बिना compare कर सकते हैं.',
faqTips2Q: 'किसी filter का मतलब कैसे जानूं?',
faqTips2A:
'सेव बटन दबाएं और सब दर्ज हो जाता है: आपके फिल्टर, zoom स्तर और कौन सी डेटा लेयर रंगी हुई है. जहां छोड़ा था वहीं से शुरू करें या लिंक अपने साथी के साथ साझा करें.',
faqTips3Q: 'क्या मैं जो data देख रहा हूं उसे export कर सकता हूं?',
'किसी filter या feature के पास i info button पर click करें ताकि छोटा explanation खुले कि उसका मतलब क्या है और उसे कैसे पढ़ें. Map के कुछ हिस्सों, जैसे travel-time cards, का अपना info button भी होता है.',
faqTips3Q: 'Map colours कैसे refresh करें?',
faqTips3A:
'मौजूदा फिल्टर की गई संपत्तियों को स्प्रेडशीट में डाउनलोड करने के लिए export बटन उपयोग करें. Export आपके सक्रिय फिल्टर का पालन करता है, इसलिए आप पोर्टल, मकान देखने, स्प्रेडशीट या साथ में खरीद रहे किसी व्यक्ति से बातचीत के लिए साफ शॉर्टलिस्ट ले जा सकते हैं.',
'जब eye preview map को colour कर रहा हो, तो map legend में Reset colour scale उपयोग करें ताकि अभी दिख रहे results के colours refresh हों. Map move, zoom या filters बदलने के बाद यह उपयोगी है.',
},
accountPage: {
@ -767,12 +770,11 @@ const hi: Translations = {
'Outstanding primary schools within 5km': '5 किमी के अंदर Outstanding प्राथमिक स्कूल',
'Outstanding secondary schools within 5km': '5 किमी के अंदर Outstanding सेकेंडरी स्कूल',
'Education, Skills and Training Score': 'शिक्षा, कौशल और प्रशिक्षण स्कोर',
'Income Score (rate)': 'आय स्कोर (दर)',
'Employment Score (rate)': 'रोजगार स्कोर (दर)',
'Income Score': 'आय स्कोर',
'Employment Score': 'रोजगार स्कोर',
'Health Deprivation and Disability Score': 'स्वास्थ्य वंचना और विकलांगता स्कोर',
'Living Environment Score': 'रहने के वातावरण का स्कोर',
'Indoors Sub-domain Score': 'इनडोर उप-क्षेत्र स्कोर',
'Outdoors Sub-domain Score': 'आउटडोर उप-क्षेत्र स्कोर',
'Housing Conditions Score': 'आवास स्थिति स्कोर',
'Air Quality and Road Safety Score': 'हवा की गुणवत्ता और सड़क सुरक्षा स्कोर',
'Serious crime per 1k residents (avg/yr)': 'प्रति 1k निवासियों गंभीर अपराध (औसत/वर्ष)',
'Minor crime per 1k residents (avg/yr)': 'प्रति 1k निवासियों मामूली अपराध (औसत/वर्ष)',
'Serious crime (avg/yr)': 'गंभीर अपराध (औसत/वर्ष)',

View file

@ -388,7 +388,7 @@ const hu: Translations = {
showcaseStep4ColScore: 'Egyezés',
showcaseStep4ColCommute: 'Ingázás',
showcaseStep4ColPrice: 'Medián eladási ár',
showcaseStep4Conclusion: 'Innen már el tudod indítani a keresést. Nem a nulláról indulsz.',
showcaseStep4Conclusion: 'Innen már el tudod indítani a keresést.',
statProperties: 'korábbi eladás',
statFilters: 'kombinálható szűrő',
statEvery: 'Minden',
@ -543,98 +543,101 @@ const hu: Translations = {
dsElectionUse:
'Jelöltszintű eredmények a 2024. júliusi brit parlamenti választásról. Választókerületi szintre aggregálva: részvételi arány (%) és pártszavazatarányok (%). Az ingatlanokhoz az NSPL irányítószám-keresőből származó parlamenti választókerületi kódon (pcon) keresztül csatolva.',
// FAQ section titles
faqFindingTitle: 'Keresési stratégia',
faqCommuteTitle: 'Utazási idő számítása',
faqFindingTitle: 'Hol keress',
faqCommuteTitle: 'Utazási idők',
faqBudgetTitle: 'Becsült árak',
faqSafetyTitle: 'Biztonság és szomszédság',
faqFamiliesTitle: 'Családok és iskolák',
faqEnvironmentTitle: 'Környezet és életminőség',
faqDueDiligenceTitle: 'Korlátok és ellenőrzések',
faqDueDiligenceTitle: 'Mit ellenőrizz',
faqPrivacyTitle: 'Adatvédelem',
faqWhyTitle: 'Miért a Perfect Postcode',
faqPricingTitle: 'Hozzáférés',
faqTipsTitle: 'Tippek és trükkök',
faqTipsTitle: 'Térképtippek',
// FAQ items — Finding Your Area
faqFinding1Q: 'Hol keressek, ha a nyilvánvaló környékek túl drágák?',
faqFinding1A:
'Állítsd be a költségvetést, ingatlantípust, alapterületet, ingázást, iskolákat, bűnözést, zajt, szélessávot, parkokat és más kötelező feltételeket. A térkép eltávolítja azokat az irányítószámokat, amelyek nem felelnek meg, így a kevésbé nyilvánvaló területek is láthatóvá válnak, mielőtt hirdetéseket keresnél.',
'Kezdd azokkal, amikből nem engedsz: költségvetés, otthontípus, tér, ingázás, iskolák, biztonság, zaj, internet, parkok és minden más fontos szempont. A térkép elrejti a nem illő helyeket, így kevésbé nyilvánvaló területek is előkerülhetnek a hirdetések böngészése előtt.',
faqFinding2Q: 'Hogyan találok jó irányítószámokat kevéssé ismert helyeken?',
faqFinding2A:
'Szűrd az egész térképet a kemény feltételeid szerint, majd vizsgáld meg a megmaradó klasztereket. Ismeretlen irányítószámokat hasonlíthatsz össze ingázás, eladási árak, iskolák, bűnözés, szélessáv, zaj és szolgáltatások alapján, nem csak hírnév szerint.',
'Állítsd be a kötelező feltételeket az egész térképen, majd nézd meg közelebbről a megmaradó helycsoportokat. Ismeretlen irányítószámokat hasonlíthatsz össze ingázás, eladási árak, iskolák, bűnözés, internet, zaj, közeli boltok vagy parkok alapján, nem csak hírnév szerint.',
faqFinding3Q: 'Mit tegyek, ha a keresés túl sok vagy túl kevés területet ad?',
faqFinding3A:
'Kezdd a kemény korlátokkal, majd színezd a térképet egy kompromisszum szerint, például négyzetméterár, közúti zaj, iskolai pontszám vagy utazási idő alapján. Ha túl szűk a találat, lazíts egy csúszkán, és azonnal látod, melyik kompromisszum nyit új lehetőségeket.',
'Tartsd meg a kötelező feltételeket, majd színezd a térképet egy összehasonlítandó dolog szerint, például négyzetméterár, közúti zaj, iskolai pontszám vagy utazási idő alapján. Ha szinte semmi sem marad, lazíts egy csúszkán, és lásd, melyik változtatás nyit új lehetőségeket.',
// FAQ items — Commute and Travel
faqCommute1Q: 'Hogyan számítjátok az utazási időket?',
faqCommute1A:
'Az utazási idők előre vannak kiszámítva a Conveyal R5 közlekedési elemző útvonaltervezővel. Minden támogatott célhoz elérhető irányítószámokra számítunk útvonalakat az utcai és tömegközlekedési hálózaton, majd ritka irányítószám-utazási idő fájlokat tárolunk autóra, kerékpárra, gyaloglásra és tömegközlekedésre. Így a térkép sok irányítószámot gyorsan tud szűrni, nem kell egyesével útvonal API-t hívni.',
'Az utazási időket minden mentett célhoz előre kiszámítjuk. Megnézzük, mely irányítószámok érhetik el az adott célt autóval, kerékpárral, gyalog vagy tömegközlekedéssel, majd eltároljuk az eredményeket, hogy a térkép gyorsan reagáljon szűrés közben.',
faqCommute2Q: 'Mit kell tudni az utazási idő számokról?',
faqCommute2A:
'A tömegközlekedési idők a reggeli 07:30-08:30 indulási ablakot használják. Az alapértelmezett érték a medián, vagyis a tipikus eredmény ebben az ablakban; a best-case kapcsoló az 5. percentilist használja jól időzített indulásokhoz. Ezek modellezett összehasonlító idők, nem élő fennakadási, forgalmi vagy peronváltási előrejelzések.',
'A tömegközlekedési idők hétköznap reggeli ingázásra épülnek, 07:30 és 08:30 közötti indulásokkal. A normál beállítás tipikus utat mutat ebben az időszakban. Ezek tervezési becslések, nem élő késések, forgalmi hírek vagy utolsó pillanatos peronváltások.',
faqCommute3Q: 'Mikor használjam a Legjobb eset gombot?',
faqCommute3A:
'A Legjobb eset gombot tömegközlekedésnél használd, ha azt szeretnéd látni, milyen az út jól időzített indulással és jó csatlakozásokkal. Hagyd kikapcsolva a hétköznapi összehasonlításhoz.',
// FAQ items — Budget and Value
faqBudget1Q: 'Hogyan működik a becsült jelenlegi ár algoritmusa?',
faqBudget1Q: 'Hogyan becsülitek a jelenlegi ingatlanárakat?',
faqBudget1A:
'A saját becslés a legutóbbi HM Land Registry eladási árból indul. Ezt egy ismételt eladásokból tanult index igazítja a jelenhez, irányítószám-szektor és ingatlantípus szerint. A kevés adattal rendelkező területek district, area, országos és hedonikus tartalékmodellek felé vannak húzva, majd térben simítva. Végül az eredmény keveredik közeli, frissen eladott, hasonló típusú ingatlanok legközelebbi szomszéd becslésével, korrigált négyzetméterár és EPC alapterület alapján.',
'A becslés a HM Land Registryben rögzített legutóbbi eladási árból indul. Ezt közelebb hozzuk a mai piachoz azzal, hogy megnézzük, hogyan változott hasonló otthonok értéke, különösen azonos típusú közeli otthonoké. Ha kevés a helyi eladás, a becslés nagyobb terület trendjeire támaszkodik. Végül összevetjük közeli friss eladásokkal és az alapterülettel.',
faqBudget2Q: 'Miért használjam a becsült jelenlegi árat a legutóbbi eladási ár helyett?',
faqBudget2A:
'A legutóbbi eladási ár akár évekkel vagy évtizedekkel korábbi lehet, az aktuális irányárak pedig csak a ma hirdetett ingatlanokat fedik le. A becsült jelenlegi ár a régi eladásokat közelebb hozza a mai piachoz, így több ingatlant hasonlíthatsz össze, becsült négyzetméterárat számíthatsz, és jó értékű területeket találhatsz még a hirdetések előtt. Ez szűrési becslés, nem hivatalos értékbecslés.',
'A legutóbbi eladási ár akár évekkel vagy évtizedekkel korábbi lehet, az aktuális irányárak pedig csak a ma hirdetett otthonokat fedik le. A becsült jelenlegi ár a régebbi eladásokat közelebb hozza a mai piachoz, így több otthont hasonlíthatsz össze és jobb értékűnek tűnő területeket találhatsz. Shortlisthez való iránymutatás, nem banki értékbecslés.',
// FAQ items — Safety and Neighbourhood
faqSafety1Q: 'Milyen bűncselekmények gyakoriak az irányítószám környékén?',
faqSafety1A:
'A rendőrségi bűnözési adatok típusokra vannak bontva, például erőszak, betörés, rablás, járművel kapcsolatos bűncselekmények, közösségellenes magatartás, bolti lopás, kábítószer és közrend. Szűrhetsz a számodra fontos konkrét kockázatokra egy homályos biztonsági pontszám helyett.',
faqSafety2Q: 'Mit ellenőrizzek egy ismeretlen utca megtekintése előtt?',
faqSafety2A:
'Foglalás előtt ellenőrizd a bűnözést, közúti zajt, deprivációt, szélessávot, parkokat, élelmiszerboltokat, iskolákat és ingázást. A hirdetési fotók hasznosak lehetnek, de ne azokból derüljön ki először, milyen az utca.',
'Foglalás előtt ellenőrizd a bűnözést, közúti zajt, internetet, parkokat, élelmiszerboltokat, iskolákat és ingázást. A hirdetési fotók hasznosak lehetnek, de ne azokból derüljön ki először, milyen az utca.',
// FAQ items — Families and Schools
faqFamilies1Q:
'Mely területeken jó az iskolák, tér, biztonság és ingázás keveréke?',
faqFamilies1A:
'Tedd egy térképre az Ofsted-minősítéseket, bűnözést, parkokat, ingázást, alapterületet, ingatlantípust és költségvetést. Az eredmény gyakorlati családi lista, nem külön iskolai, bűnözési, hirdetési és közlekedési keresések halmaza.',
'Tedd egy térképre az iskolaminősítéseket, bűnözést, parkokat, ingázást, teret, otthontípust és költségvetést. Az eredmény gyakorlati családi lista, nem sok külön keresés halmaza.',
faqFamilies2Q: 'Ez bizonyítja, hogy iskola-felvételi körzeten belül vagyok?',
faqFamilies2A:
'Nem. Közeli iskolaminőséget és területi oktatási adatokat mutatunk, de a felvételi határok és elsőbbségi szabályok változhatnak. A Perfect Postcode legyen shortlist-eszköz, utána ellenőrizd a körzeteket és felvételit az iskolánál vagy a helyi hatóságnál.',
'Nem. Közeli iskolaminőséget és helyi oktatási információkat mutatunk, de a felvételi határok és elsőbbségi szabályok változhatnak. A Perfect Postcode-dal válogass helyeket, majd ellenőrizd a körzeteket és felvételit az iskolánál vagy a helyi önkormányzatnál.',
// FAQ items — Environment and Quality of Life
faqEnv1Q: 'Hogyan kerülhetek el zajos utat az ingázás vagy internet minőségének elvesztése nélkül?',
faqEnv1A:
'Szűrj közúti zajra, miközben az ingázási idő, szélessáv-sebesség, ár és ingatlanszűrők aktívak maradnak. Egy jellemző szerint színezheted a térképet, a többi pedig reálisan tartja a listát.',
'Szűrj közúti zajra, miközben az ingázás, internet, ár és otthonszűrők aktívak maradnak. Egy jellemző szerint színezheted a térképet, a többi pedig reálisan tartja a listát.',
faqEnv2Q: 'Mutat árvíz-, süllyedés- vagy felmérési kockázatot?',
faqEnv2A:
'Jelenleg nem élő szűrőként. Mutatunk például közúti zajt, EPC-t, építési kort és helyi környezeti mutatókat, de az árvízkeresést, tulajdoni lapot, szerkezeti problémákat és hitelezhetőséget továbbra is ügyvéddel, hitelezővel és felmérővel kell ellenőrizni.',
'Ma még nem. Mutatunk például közúti zajt, energiaértékelést, építési kort és az irányítószám környezetét. Árvízkockázatot, jogi kérdéseket, szerkezeti problémákat, hitellel kapcsolatos ügyeket és felmérési eredményeket továbbra is külön kell ellenőrizni vásárlás előtt.',
faqEnv3Q: 'Milyen fenntartási költségeket ellenőrizhetek megtekintés előtt?',
faqEnv3A:
'Megtekintés előtt előszűrhetsz EPC-minősítésre, teljes alapterületre, építési korra, council tax hatóságra, szélessávra és zajra. Ez nem jósolja meg a pontos számláidat, de segít korán elkerülni a nyilvánvalóan rossz illeszkedéseket.',
'Megtekintés előtt ellenőrizhetsz energiaértékelést, alapterületet, építési kort, council tax területet, internetet és zajt. Ez nem jósolja meg a pontos számláidat, de segít korán elkerülni a nyilvánvalóan rossz illeszkedéseket.',
// FAQ items — Listing Portals and Due Diligence
faqDueDiligence1Q: 'Rightmove előtt vagy után használjam?',
faqDueDiligence1A:
'Használd a Perfect Postcode-ot a hirdetési portálok előtt és mellett. A Rightmove, Zoopla és OnTheMarket továbbra is az aktuális elérhetőség, fotók, ügynöki kapcsolat, megtekintések és értesítések helye. A Perfect Postcode abban segít, hogy mely irányítószámokat érdemes egyáltalán keresni.',
'Használd a Perfect Postcode-ot a hirdetési oldalak előtt és mellett. A Rightmove, Zoopla és OnTheMarket továbbra is az aktuálisan eladó otthonok, fotók, ügynökök, megtekintések és értesítések helye. A Perfect Postcode abban segít, hogy mely irányítószámokat érdemes keresni.',
faqDueDiligence2Q: 'Szűrhetek kertre, garázsra, alaprajzra vagy hirdetésszövegre?',
faqDueDiligence2A:
'Csak akkor, ha az információ strukturált hivatalos adatban elérhető. A Perfect Postcode tud szűrni alapterületre, ingatlantípusra, tulajdonformára, EPC-re, eladási árakra és helyi adatokra. A kertet, garázst, tájolást, szobaelrendezést és ügynöki megfogalmazást továbbra is a hirdetésben és a megtekintésen kell ellenőrizni.',
'Ezek a részletek nem érhetők el megbízhatóan minden otthonról. A Perfect Postcode tud szűrni alapterületre, otthontípusra, tulajdonformára, energiaértékelésre, eladási árakra és helyi információkra. A kertet, garázst, tájolást, szobaelrendezést és ügynöki megfogalmazást továbbra is a hirdetésben és a megtekintésen kell ellenőrizni.',
faqDueDiligence3Q:
'Láthatom az árcsökkentések történetét vagy hogy mióta van fent egy hirdetés?',
faqDueDiligence3A:
'Jelenleg nem. A Perfect Postcode hivatalos eladási árakra, EPC-re, irányítószám-, utazási idő- és környékadatokra épül, nem élő hirdetésfolyamokra. A legutóbbi tranzakció dátumát, eladási előzményeket, becsült aktuális értéket és négyzetméterárat viszont használhatod az irányár megítéléséhez.',
'Jelenleg nem. A Perfect Postcode eladási árakra, energiaértékelésekre, irányítószámokra, utazási időkre és környékinformációkra épül, nem élő hirdetésváltozásokra. Az eladási előzményeket, becsült aktuális értéket és négyzetméterárat viszont használhatod annak megítélésére, hogy egy irányár magasnak tűnik-e.',
faqDueDiligence4Q: 'Mit kell még ellenőriznem ajánlattétel előtt?',
faqDueDiligence4A:
'A Perfect Postcode-dal ellenőrizd a környéket és az értéket, majd a szokásos szakmai folyamatban vizsgáld meg a hirdetés részleteit, tulajdonformát, leasehold feltételeket, service charge-ot, tervezési előzményeket, árvízkockázatot, tulajdoni kérdéseket, hitelezői feltételeket és felmérési eredményeket.',
'A Perfect Postcode-dal ellenőrizd a környéket és a valószínű értéket, majd ajánlattétel előtt erősítsd meg a hirdetés részleteit. Ellenőrizd a tulajdonformát, leasehold részleteket, service charge-ot, tervezési előzményeket, árvízkockázatot, jogi kérdéseket, hitelfeltételeket és felmérési eredményeket is.',
// FAQ items — Privacy and Data Protection
faqPrivacy1Q: 'Tároltok rólam személyes adatokat?',
faqPrivacy1A:
'Az ingatlan- és környékadatok között nem tárolunk személyes felhasználói adatokat. Ezek az adatkészletek hivatalos és nyilvános forrásokból készülnek, irányítószám- és ingatlankutatáshoz. Ha fiókot hozol létre, csak a szolgáltatás működtetéséhez szükséges adatokat tároljuk, például az e-mail címet, licencállapotot, hírlevél-beállítást, mentett kereséseket, mentett ingatlanokat és a Stripe-on keresztül kezelt fizetési azonosítókat. Ezeket a fiókadatokat a UK GDPR és a Data Protection Act 2018 szerint kezeljük.',
'Az ingatlan- és környékinformációk nem tartalmazzák a személyes adataidat. Ha fiókot hozol létre, csak a szolgáltatáshoz szükséges adatokat tároljuk, például e-mail címet, hozzáférési állapotot, hírlevél-választást, mentett kereséseket, mentett ingatlanokat és a Stripe által kezelt fizetéseket. A fiókadatokat az Egyesült Királyság adatvédelmi törvényei szerint kezeljük.',
// FAQ items — Why Perfect Postcode
faqWhy1Q: 'Mit mutat ez, amit a hirdetési portálok általában nem?',
faqWhy1A:
'A hirdetési portálok a most eladó otthonokból indulnak ki. A Perfect Postcode azokból a helyekből indul, amelyek illenek az életedhez és költségvetésedhez, eladási árakkal, alapterülettel, ingázással, iskolákkal, bűnözéssel, zajjal, szélessávval, EPC-vel, tulajdonformával és szolgáltatásokkal, még a hirdetések megnyitása előtt.',
'A hirdetési oldalak a most eladó otthonokból indulnak ki. A Perfect Postcode azokból a helyekből indul, amelyek illenek az életedhez és költségvetésedhez, eladási árakkal, térrel, ingázással, iskolákkal, bűnözéssel, zajjal, internettel, energiaértékeléssel, tulajdonformával és szolgáltatásokkal, még a hirdetések megnyitása előtt.',
faqWhy2Q: 'Mennyi kézi kutatást takarít meg?',
faqWhy2A:
'Megteheted, de ez azt jelenti, hogy Land Registry, EPC, rendőrségi, Ofsted, Ofcom, ONS, Defra, utazási idő és térképadatokat kell összefűzni irányítószámonként. A Perfect Postcode ezeket a forrásokat egész Angliában egy helyen szűrhetővé teszi.',
faqWhy3Q: 'Mennyire megbízhatóak az alapforrások?',
'Megtehetnéd egyedül is, de akkor eladási árakat, energiaértékeléseket, bűnözést, iskolákat, internetet, helyi tényeket, környezetet, utazási időket és térképeket kellene ellenőrizned irányítószámonként. A Perfect Postcode ezeket egy kereshető angliai térképbe rendezi.',
faqWhy3Q: 'Mennyire megbízhatóak az adatok?',
faqWhy3A:
'A fő adatkészletek hivatalos vagy mérvadó forrásokból származnak, például HM Land Registry, EPC-rekordok, ONS, Ofsted, Ofcom, data.police.uk, Defra, Ordnance Survey és OpenStreetMap. Kiválóak shortlisthez és összehasonlításhoz, de minden vásárlási döntéshez aktuális ellenőrzések és szakmai tanács szükséges.',
'A fő források hivatalos vagy széles körben használt nyilvános adatok: eladási árak, energiaértékelések, helyi tények, iskolaminősítések, internet, bűnözés, környezet, térképek és utcai adatok. Hasznosak shortlisthez és összehasonlításhoz, de minden vásárlási döntéshez aktuális ellenőrzések és szükség esetén szakértői tanács kell.',
// FAQ items — Pricing and Access
faqPricing1Q: 'Miért fizessek, ha vannak ingyenes irányítószám-jelentések?',
faqPricing1A:
'Az ingyenes irányítószám-eszközök hasznosak, ha már tudod, mit kell ellenőrizni. A Perfect Postcode arra való, hogy Anglia minden irányítószámát végigpásztázd a feltételeid alapján, szűrőket kombinálj, kompromisszumokat hasonlíts össze, kereséseket ments és listát exportálj, mielőtt hétvégéket kötnél le megtekintésekre.',
'Az ingyenes irányítószám-eszközök hasznosak, ha már tudod, mit kell ellenőrizni. A Perfect Postcode arra való, hogy Anglia minden irányítószámát végignézd az igényeid alapján, szűrőket kombinálj, lehetőségeket hasonlíts össze, kereséseket ments és listát exportálj, mielőtt hétvégéket kötnél le megtekintésekre.',
faqPricing2Q: 'Mit jelent az élethosszig tartó hozzáférés?',
faqPricing2A:
'Az élethosszig tartó hozzáférés azt jelenti, hogy egy fizetéssel a fiókod folyamatos hozzáférést kap a fizetős Perfect Postcode térképhez a szolgáltatás élettartamára. Ez nem havi vagy éves előfizetés, és a szokásos adatfrissítések benne vannak. Használhatod a mostani kereséshez, később visszatérhetsz, és akkor is hozzáférsz, ha újra költözöl.',
@ -643,15 +646,15 @@ const hu: Translations = {
'Az ingyenes felhasználók a demó területen (Belső-London, megközelítőleg az 1-2. zóna) fedezhetik fel az összes funkciót. Anglia többi részének adataihoz élethosszig tartó hozzáférés szükséges.',
// FAQ items — Tips and Tricks
faqTips1Q: 'Hogyan írjak le egy keresést hétköznapi nyelven?',
faqTips1Q: 'Hogyan nézhetek meg egy szűrőt a térképen?',
faqTips1A:
'Írj be valami ilyesmit: "freehold 3-bed under £550k, 45 minutes to work, quiet, good broadband", és az AI-szűrő beállítja az általa értett megfelelő szűrőket. Azt is jelzi, ha egy kérés, például kertméret, nem elérhető strukturált szűrőként.',
faqTips2Q: 'Elmenthetem a keresést, és később visszatérhetek hozzá?',
'Kattints a szem ikonra egy szűrő vagy jellemző mellett, és a térkép az adott elem alapján színeződik. Az aktív szűrők megmaradnak, így gyorsan összehasonlíthatsz egy dolgot, például árat, ingázási időt, iskolákat, bűnözést vagy zajt a lista módosítása nélkül.',
faqTips2Q: 'Honnan tudom, mit jelent egy szűrő?',
faqTips2A:
'Nyomd meg a mentés gombot, és mindent rögzítünk: szűrőid, a nagyítási szint, és melyik adatréteg szerint színezel. Folytasd pontosan ott, ahol abbahagytad, vagy oszd meg a linket a pároddal.',
faqTips3Q: 'Exportálhatom az adatokat, amiket látok?',
'Kattints az i információ gombra egy szűrő vagy jellemző mellett, hogy rövid magyarázatot kapj arról, mit jelent és hogyan olvasd. A térkép egyes részeinek, például az utazási idő kártyáknak, saját információ gombjuk is van.',
faqTips3Q: 'Hogyan frissíthetem a térkép színeit?',
faqTips3A:
'Az exportálás gombbal letöltheted a jelenlegi szűrőknek megfelelő ingatlanokat táblázatként. Az export figyelembe veszi az aktív szűrőket, így tiszta listát vihetsz portálokba, megtekintésekre, táblázatokba vagy beszélgetésekbe azzal, akivel együtt vásárolsz.',
'Amikor egy szem előnézet színezi a térképet, a jelmagyarázat Színskála visszaállítása gombjával frissítheted az aktuálisan látott eredmények színeit. Ez hasznos térképmozgatás, nagyítás vagy szűrőmódosítás után.',
},
// ── Account Page ───────────────────────────────────
@ -832,12 +835,11 @@ const hu: Translations = {
'Education, Skills and Training Score': 'Oktatás, készségek és képzés pontszám',
// ─ Feature names (Deprivation) ─
'Income Score (rate)': 'Jövedelmi pontszám (arány)',
'Employment Score (rate)': 'Foglalkoztatottsági pontszám (arány)',
'Income Score': 'Jövedelmi pontszám',
'Employment Score': 'Foglalkoztatottsági pontszám',
'Health Deprivation and Disability Score': 'Egészségügyi depriváció és fogyatékosság pontszám',
'Living Environment Score': 'Lakókörnyezet pontszám',
'Indoors Sub-domain Score': 'Beltéri alterulet pontszám',
'Outdoors Sub-domain Score': 'Kültéri alterulet pontszám',
'Housing Conditions Score': 'Lakáskörülmények pontszám',
'Air Quality and Road Safety Score': 'Levegőminőség és közlekedésbiztonság pontszám',
// ─ Feature names (Crime) ─
'Serious crime per 1k residents (avg/yr)': 'Súlyos bűncselekmény 1000 lakosra (éves átlag)',

View file

@ -383,7 +383,7 @@ const zh: Translations = {
showcaseStep4ColScore: '匹配',
showcaseStep4ColCommute: '通勤',
showcaseStep4ColPrice: '成交中位价',
showcaseStep4Conclusion: '您可以从这里开始。现在不再是盲目找房。',
showcaseStep4Conclusion: '您可以从这里开始。',
statProperties: '历史成交记录',
statFilters: '可组合筛选条件',
statEvery: '覆盖',
@ -531,13 +531,13 @@ const zh: Translations = {
'2024年7月英国大选的候选人级别结果。聚合到选区级别投票率%)和各政党得票率(%。通过NSPL邮编查询中的议会选区代码pcon关联到房产。',
// FAQ section titles
faqFindingTitle: '搜索策略',
faqCommuteTitle: '出行时间算法',
faqCommuteTitle: '出行时间',
faqBudgetTitle: '估计价格',
faqSafetyTitle: '安全与社区环境',
faqFamiliesTitle: '家庭与学校',
faqEnvironmentTitle: '环境与生活质量',
faqDueDiligenceTitle: '范围与尽职调查',
faqPrivacyTitle: '隐私与数据保护',
faqDueDiligenceTitle: '还需核实',
faqPrivacyTitle: '隐私',
faqWhyTitle: '为什么选择 Perfect Postcode',
faqPricingTitle: '访问权限',
faqTipsTitle: '使用技巧',
@ -550,76 +550,79 @@ const zh: Translations = {
'先用硬性条件筛选整张地图,再查看剩下的聚集区域。您可以按通勤、成交价、学校、犯罪率、宽带、噪音和配套来比较陌生邮编,而不是只依赖口碑。',
faqFinding3Q: '搜索结果太多或太少时该怎么办?',
faqFinding3A:
'先设置硬性限制,再按一个取舍指标为地图着色,例如每平方米价格、道路噪音、学校评分或通勤时间。如果结果太少,放宽一个滑块,就能看到哪个妥协会打开更多选择。',
'先保留硬性条件,再按一个要比较的因素为地图着色,例如每平方米价格、道路噪音、学校评分或通勤时间。如果结果太少,放宽一个滑块,就能看到哪个变化会打开更多选择。',
// FAQ items — Commute and Travel
faqCommute1Q: '出行时间是如何计算的?',
faqCommute1A:
'出行时间使用 Conveyal R5 预先计算,这是一个用于交通分析的路径引擎。对于每个支持的目的地,我们会沿街道和公共交通网络计算可到达邮编的路线,并为开车、骑车、步行和公共交通存储稀疏的邮编出行时间文件。这样地图可以快速筛选大量邮编,而不是逐个调用路线 API。',
'出行时间会针对每个已保存目的地提前计算。我们会判断哪些邮编可以通过开车、骑车、步行或公共交通到达该目的地,然后保存结果,让您筛选时地图能快速响应。',
faqCommute2Q: '这些出行时间数字有什么限制?',
faqCommute2A:
'公共交通时间使用早高峰 07:30 到 08:30 的出发窗口。默认值是中位数代表该窗口内的典型行程best-case 开关使用第 5 百分位,表示出发时间配合较好时的情况。这些是用于比较的模型时间,不是实时延误、交通状况或换乘站台预测。',
'公共交通时间基于工作日早晨通勤,出发时间在 07:30 到 08:30 之间。普通设置显示该时段内的典型行程。这些是用于规划的估算,不包含实时延误、交通状况或临时站台变化。',
faqCommute3Q: '什么时候使用“最佳情况”按钮?',
faqCommute3A:
'在公共交通中,如果想查看出发时间配合较好、换乘顺利时的通勤情况,可以使用“最佳情况”按钮。日常比较时保持关闭即可。',
// FAQ items — Budget and Value
faqBudget1Q: '估计当前价格算法是如何工作的?',
faqBudget1Q: '你们如何估算当前房价',
faqBudget1A:
'这个专有估算从 HM Land Registry 的最近成交价开始。它使用从多次成交房产中学习出的重复销售指数,将价格调整到当前市场,并按邮编分区和房产类型分层。数据较少的区域会向 district、area、全国和享乐模型回退并进行空间平滑。最后结果会与附近近期成交、同类型房产的最近邻估算混合使用调整后的每平方米价格和 EPC 室内面积。',
'估算从 HM Land Registry 记录的最近成交价开始。我们会观察类似房屋的价值如何随时间变化,尤其是附近同类型房屋,从而把这次成交价调整到更接近今天的市场。当地成交较少时,会更多参考更大区域的趋势。最后还会结合附近近期成交和房屋面积进行校验。',
faqBudget2Q: '为什么要用估计当前价格,而不是最近成交价?',
faqBudget2A:
'最近成交价可能是几年甚至几十年前的价格,而实时挂牌价只覆盖今天正在出售的房源。估计当前价格把旧成交放到更接近当前市场的尺度上,方便比较更多房产、计算估计每平方米价格,并在房源出现前发现可能有价值的区域。它是筛选估算,不是正式估值。',
'最近成交价可能是几年甚至几十年前的价格,而挂牌价只覆盖今天正在出售的房源。估计当前价格把旧成交放到更接近今天市场的水平,方便比较更多房屋,并发现可能更有价值的区域。请把它当作筛选参考,而不是银行估值。',
// FAQ items — Safety and Neighbourhood
faqSafety1Q: '这个邮编周边常见哪些犯罪类型?',
faqSafety1A:
'警方记录的犯罪会按类型拆分,包括暴力、入室盗窃、抢劫、车辆犯罪、反社会行为、商店行窃、毒品和公共秩序等。您可以按自己关心的具体风险筛选,而不是依赖一个模糊的安全分。',
faqSafety2Q: '看一条陌生街道前应该先查什么?',
faqSafety2A:
'预约前先查犯罪率、道路噪音、贫困指数、宽带、公园、食品店、学校和通勤。房源照片仍然有用,但不应该是您第一次了解这条街的方式。',
'预约前先查犯罪率、道路噪音、宽带、公园、食品店、学校和通勤。房源照片仍然有用,但不应该是您第一次了解这条街的方式。',
// FAQ items — Families and Schools
faqFamilies1Q: '哪些区域在学校、空间、安全和通勤之间取得了合适平衡?',
faqFamilies1A:
'把 Ofsted 评级、犯罪率、公园、通勤、建筑面积、房产类型和预算叠加到一张地图上。结果是实用的家庭候选名单,而不是一堆分散的学校、犯罪、房源和交通查询。',
'把学校评分、犯罪率、公园、通勤、空间、房屋类型和预算放到一张地图上。结果是实用的家庭候选名单,而不是一堆分散查询。',
faqFamilies2Q: '这能证明我在某所学校的招生范围内吗?',
faqFamilies2A:
'不能。我们显示附近学校质量和区域级教育数据,但招生边界和优先规则可能变化。请把 Perfect Postcode 当作候选工具,再向学校或地方政府核实招生范围和录取规则。',
'不能。我们显示附近学校质量和本地教育信息,但招生边界和优先规则可能变化。请用 Perfect Postcode 先筛选地点,再向学校或地方政府核实招生范围和录取规则。',
// FAQ items — Environment and Quality of Life
faqEnv1Q: '如何避开嘈杂道路,同时不牺牲通勤或宽带质量?',
faqEnv1A:
'按道路噪音筛选,同时保留通勤时间、宽带速度、价格和房产筛选条件。您可以按某一项指标给地图着色,而其他条件会保持候选名单可靠。',
'按道路噪音筛选,同时保留通勤、宽带、价格和房屋筛选条件。您可以按某一项给地图着色,而其他条件会保持候选名单可靠。',
faqEnv2Q: '是否显示洪水、地基沉降或验房风险?',
faqEnv2A:
'目前不作为实时筛选项提供。我们会显示道路噪音、EPC、建造年代和本地环境指标等数据但洪水查询、产权问题、结构问题和贷款适配性仍需要通过律师、贷款机构和专业验房流程确认。',
'目前不提供。我们会显示道路噪音、能源评级、建造年代和邮编周边环境。洪水风险、法律问题、结构问题、贷款问题和验房结果仍需要在购房前单独核实。',
faqEnv3Q: '看房前能做哪些运行成本检查?',
faqEnv3A:
'看房前可以先筛查 EPC 评级、总建筑面积、建造年代、市政税辖区、宽带和噪音。这无法预测您的精确账单,但能帮助您尽早避开明显不合适的房子。',
'看房前可以先查看能源评级、建筑面积、建造年代、市政税辖区、宽带和噪音。这无法预测您的精确账单,但能帮助您尽早避开明显不合适的房子。',
// FAQ items — Listing Portals and Due Diligence
faqDueDiligence1Q: '应该在查看 Rightmove 前还是之后使用?',
faqDueDiligence1A:
'Perfect Postcode 适合在房源平台之前和同时使用。Rightmove、Zoopla 和 OnTheMarket 仍然用于查看实时房源、照片、中介联系方式、预约看房和提醒。Perfect Postcode 帮助您先判断哪些邮编值得搜索。',
'Perfect Postcode 适合在房源网站之前和同时使用。Rightmove、Zoopla 和 OnTheMarket 仍然用于查看当前在售房源、照片、中介、预约看房和提醒。Perfect Postcode 帮助您先判断哪些邮编值得搜索。',
faqDueDiligence2Q: '可以按花园、车库、户型或房源描述筛选吗?',
faqDueDiligence2A:
'只有当这些信息存在于结构化官方数据中时才可以。Perfect Postcode 可以按面积、房产类型、产权类型、EPC、成交价和本地数据筛选。花园、车库、朝向、户型和中介描述仍需要在房源页面和看房时核实。',
'这些细节并不是每套房都可靠可得。Perfect Postcode 可以按面积、房屋类型、产权类型、能源评级、成交价和本地信息筛选。花园、车库、朝向、户型和中介描述仍需要在房源页面和看房时核实。',
faqDueDiligence3Q: '可以看到降价历史或房源上线多久了吗?',
faqDueDiligence3A:
'目前不支持。Perfect Postcode 基于官方成交价、EPC、邮编、通勤时间和社区数据而不是实时房源信息流。您仍可以用最近成交日期、成交历史、估计当前价值和每平方米价格来判断挂牌价是否偏高。',
'目前不支持。Perfect Postcode 基于成交价、能源评级、邮编、通勤时间和社区信息,而不是实时房源变化。您仍可以用成交历史、估计当前价值和每平方米价格来判断挂牌价是否偏高。',
faqDueDiligence4Q: '出价前还需要核实什么?',
faqDueDiligence4A:
'可以先用 Perfect Postcode 检查区域和价值,再通过常规专业流程核实实时房源细节、产权类型、租赁年限、服务费、规划历史、洪水风险、产权问题、贷款要求和验房结果。',
'可以先用 Perfect Postcode 检查区域和大致价值,然后在出价前确认房源细节。还应核实产权类型、租赁细节、服务费、规划历史、洪水风险、法律问题、贷款要求和验房结果。',
// FAQ items — Privacy and Data Protection
faqPrivacy1Q: '你们会存储关于我的个人数据吗?',
faqPrivacy1A:
'我们不会在房产和社区数据集中存储用户个人数据。这些数据集来自官方和公开来源,用于邮编和房产研究。如果您创建账户,我们只会存储运行服务所需的信息,例如邮箱地址、许可状态、新闻邮件偏好、已保存的搜索、已保存的房产,以及通过 Stripe 处理的付款标识符。我们会根据 UK GDPR 和 Data Protection Act 2018 处理这些账户数据。',
'房产和社区信息不包含您的个人资料。如果您创建账户,我们只会存储运行服务所需的信息,例如邮箱地址、访问状态、新闻邮件选择、已保存的搜索、已保存的房产,以及由 Stripe 处理的付款。账户数据会按英国隐私法律处理。',
// FAQ items — Why Perfect Postcode
faqWhy1Q: '它显示了房源门户通常不显示的什么信息?',
faqWhy1A:
'房源门户从当前在售的房子开始。Perfect Postcode 从适合您生活和预算的地方开始,在打开房源前就结合成交价、建筑面积、通勤、学校、犯罪率、噪音、宽带、EPC、产权和配套。',
'房源网站从当前在售的房子开始。Perfect Postcode 从适合您生活和预算的地方开始,在打开房源前就结合成交价、空间、通勤、学校、犯罪率、噪音、宽带、能源评级、产权类型和配套。',
faqWhy2Q: '这能节省多少手动研究?',
faqWhy2A:
'您可以自己做,但这意味着逐个邮编拼接 Land Registry、EPC、警方、Ofsted、Ofcom、ONS、Defra、出行时间和地图数据。Perfect Postcode 把这些来源放到一个地方,支持在整个英格兰筛选。',
faqWhy3Q: '底层数据来源有多可靠?',
'您可以自己做,但这意味着逐个邮编检查成交价、能源评级、犯罪率、学校、宽带、本地信息、环境、出行时间和地图。Perfect Postcode 把这些来源放到一张可搜索的英格兰地图中。',
faqWhy3Q: '数据有多可靠?',
faqWhy3A:
'核心数据集来自官方或权威来源,例如 HM Land Registry、EPC 记录、ONS、Ofsted、Ofcom、data.police.uk、Defra、Ordnance Survey 和 OpenStreetMap。它们非常适合筛选和比较但任何购房决定仍需要最新核查和专业建议。',
'主要来源是官方或广泛使用的公开数据,包括成交价、能源评级、本地信息、学校评分、宽带、犯罪率、环境、地图和街道数据。它们适合筛选和比较,但购房决定仍需要最新核查,必要时还要咨询专业人士。',
// FAQ items — Pricing and Access
faqPricing1Q: '既然邮编报告是免费的,为什么还要付费?',
faqPricing1A:
'免费的邮编工具在您已经知道要查什么时很有用。Perfect Postcode 用来按您的条件扫描英格兰每个邮编、组合筛选、比较取舍、保存搜索,并在投入周末看房前导出候选名单。',
'免费的邮编工具在您已经知道要查什么时很有用。Perfect Postcode 用来按您的需求扫描英格兰每个邮编、组合筛选、比较选项、保存搜索,并在投入周末看房前导出候选名单。',
faqPricing2Q: '终身访问是什么意思?',
faqPricing2A:
'终身访问指一次付款后,您的账户可在 Perfect Postcode 服务存续期间持续访问付费地图。它不是月度或年度订阅,并包含正常的数据更新。您可以在本次找房期间使用,之后再回来查看;如果将来再次搬家,也仍然保留访问权限。',
@ -628,15 +631,15 @@ const zh: Translations = {
'免费用户可以在演示区域(伦敦市中心,大约 1 至 2 区)内探索所有功能。要访问英格兰其他地区的数据,需要获取终身访问权限。',
// FAQ items — Tips and Tricks
faqTips1Q: '如何用自然语言描述一次搜索',
faqTips1Q: '如何在地图上预览筛选条件',
faqTips1A:
'输入类似 "freehold 3-bed under £550k, 45 minutes to work, quiet, good broadband" 的内容AI 筛选会设置它能理解的匹配条件。若某项需求(例如花园大小)不是结构化筛选条件,它也会告诉您。',
faqTips2Q: '我能保存搜索条件以后再用吗',
'点击筛选条件或数据项旁边的眼睛图标,即可按该项为地图着色。当前启用的筛选条件会保持不变,因此您可以快速比较价格、通勤时间、学校、犯罪率或噪音等单项,而不会改变候选范围。',
faqTips2Q: '如何了解筛选条件的含义',
faqTips2A:
'点击保存按钮,所有内容都会被记录:您的筛选条件、缩放级别以及当前着色的数据图层。下次从上次离开的地方继续,或将链接分享给您的伴侣。',
faqTips3Q: '我能导出正在查看的数据吗',
'点击筛选条件或数据项旁边的 i 信息按钮,可查看简短说明,了解它的含义以及如何阅读。地图中的一些部分,例如出行时间卡片,也有自己的信息按钮。',
faqTips3Q: '如何刷新地图颜色',
faqTips3A:
'使用导出按钮将当前筛选后的房产下载为电子表格。导出结果会遵循您的活动筛选条件,方便您把干净的候选名单带到房源门户、看房、表格或与共同购房者的讨论中。',
'当眼睛预览正在为地图着色时,在地图图例中使用“重置颜色比例”即可刷新当前结果的颜色。在移动地图、缩放或更改筛选条件后,这很有用。',
},
// ── Account Page ───────────────────────────────────
@ -806,12 +809,11 @@ const zh: Translations = {
'Education, Skills and Training Score': '教育、技能和培训得分',
// ─ Feature names (Deprivation) ─
'Income Score (rate)': '收入得分(比率)',
'Employment Score (rate)': '就业得分(比率)',
'Income Score': '收入得分',
'Employment Score': '就业得分',
'Health Deprivation and Disability Score': '健康与残障得分',
'Living Environment Score': '居住环境得分',
'Indoors Sub-domain Score': '室内子领域得分',
'Outdoors Sub-domain Score': '室外子领域得分',
'Housing Conditions Score': '住房状况得分',
'Air Quality and Road Safety Score': '空气质量与道路安全得分',
// ─ Feature names (Crime) ─
'Serious crime per 1k residents (avg/yr)': '每千人严重犯罪(年均)',

View file

@ -45,6 +45,26 @@ h3 {
color 0.2s ease;
}
header a,
header a *,
header button:not(:disabled),
header button:not(:disabled) *,
.mobile-menu-panel a,
.mobile-menu-panel a *,
.mobile-menu-panel button:not(:disabled),
.mobile-menu-panel button:not(:disabled) *,
.home-hero-showcase button:not(:disabled),
.home-hero-showcase button:not(:disabled) * {
cursor: pointer;
}
header button:disabled,
header button:disabled *,
.mobile-menu-panel button:disabled,
.mobile-menu-panel button:disabled * {
cursor: wait;
}
/* Hexagon background animations */
@keyframes hex-drift {
from {
@ -151,6 +171,46 @@ h3 {
height: 5rem;
}
@media (min-width: 1200px) {
.home-hero-container {
padding-top: 3rem;
padding-bottom: 3rem;
}
.home-hero-layout {
grid-template-columns: minmax(0, 0.82fr) minmax(38rem, 1.18fr);
column-gap: 3rem;
}
.home-hero-copy {
max-width: 42rem;
}
.home-hero-showcase {
max-width: none;
justify-self: stretch;
}
.home-hero-showcase-frame {
height: 40rem;
}
}
@media (min-width: 1440px) {
.home-hero-layout {
grid-template-columns: minmax(0, 0.78fr) minmax(44rem, 1.22fr);
column-gap: 4rem;
}
.home-hero-copy {
max-width: 45rem;
}
.home-hero-showcase-frame {
height: 40rem;
}
}
@media (min-width: 1024px) and (min-height: 900px) {
.hero-roomy-lift {
transform: translateY(-2.5rem);
@ -220,7 +280,7 @@ h3 {
}
.scout-export-action {
animation: scout-export-click 3.2s ease-in-out infinite;
animation: none;
}
.scout-export-ripple {
@ -231,11 +291,27 @@ h3 {
height: 7rem;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.55);
animation: scout-export-ripple 3.2s ease-out infinite;
opacity: 0;
transform: translate(-50%, -50%) scale(0.25);
animation: none;
}
.scout-export-check {
animation: scout-export-check 3.2s ease-in-out infinite;
opacity: 0;
transform: scale(0.65);
animation: none;
}
.scout-screen-active .scout-export-action {
animation: scout-export-click 2.4s ease-in-out 1 both;
}
.scout-screen-active .scout-export-ripple {
animation: scout-export-ripple 2.4s ease-out 1 both;
}
.scout-screen-active .scout-export-check {
animation: scout-export-check 2.4s ease-in-out 1 both;
}
@media (prefers-reduced-motion: reduce) {

View file

@ -24,5 +24,20 @@
</head>
<body>
<div id="root"></div>
<script>
(function() {
var root = document.getElementById('root');
if (!root) return;
var prerenderPath = root.getAttribute('data-prerender-path');
if (!prerenderPath) return;
var normalize = function(path) {
return path.length > 1 ? path.replace(/\/+$/, '') : '/';
};
if (normalize(prerenderPath) !== normalize(window.location.pathname || '/')) {
root.textContent = '';
root.removeAttribute('data-prerender-path');
}
})();
</script>
</body>
</html>

View file

@ -1,6 +1,6 @@
import { createRoot, hydrateRoot } from 'react-dom/client';
import { createRoot } from 'react-dom/client';
import App from './App';
import { INITIAL_LANGUAGE, i18nReady } from './i18n';
import { i18nReady } from './i18n';
import './index.css';
import './hooks/usePlausible';
@ -13,14 +13,10 @@ const root = container;
function renderApp() {
const hasPrerenderedMarkup = root.children.length > 0;
if (hasPrerenderedMarkup && INITIAL_LANGUAGE === 'en') {
hydrateRoot(root, <App />);
return;
}
if (hasPrerenderedMarkup) {
root.textContent = '';
}
root.removeAttribute('data-prerender-path');
createRoot(root).render(<App />);
}

View file

@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import type { FeatureMeta } from '../types';
import { apiUrl, assertOk, buildFilterString, isAbortError } from './api';
import { createSchoolFilterKey } from './school-filter';
import { createSpecificCrimeFilterKey } from './crime-filter';
describe('api utilities', () => {
it('builds API URLs from endpoint names, paths, and params', () => {
@ -81,4 +82,21 @@ describe('api utilities', () => {
)
).toBe('Good+ primary schools within 2km:2:8');
});
it('serializes specific crime filters using their selected backend crime feature', () => {
const features: FeatureMeta[] = [
{ name: 'Burglary (avg/yr)', type: 'numeric', min: 0, max: 20 },
{ name: 'Vehicle crime (avg/yr)', type: 'numeric', min: 0, max: 30 },
];
expect(
buildFilterString(
{
[createSpecificCrimeFilterKey('Burglary (avg/yr)', 1)]: [0, 5],
[createSpecificCrimeFilterKey('Vehicle crime (avg/yr)', 2)]: [1, 10],
},
features
)
).toBe('Burglary (avg/yr):0:5;;Vehicle crime (avg/yr):1:10');
});
});

View file

@ -2,6 +2,7 @@ import type { FeatureMeta, FeatureFilters } from '../types';
import { INITIAL_RETRY_MS, MAX_RETRY_MS } from './consts';
import pb from './pocketbase';
import { getSchoolBackendFeatureName } from './school-filter';
import { getSpecificCrimeFeatureName } from './crime-filter';
export function logNonAbortError(label: string, error: unknown): void {
if (error instanceof Error && error.name === 'AbortError') {
@ -87,7 +88,8 @@ export function buildFilterString(
const merged = new Map<string, [number, number] | string[]>();
for (const [name, value] of entries) {
if (name === exclude) continue;
const backendName = getSchoolBackendFeatureName(name) ?? name;
const backendName =
getSchoolBackendFeatureName(name) ?? getSpecificCrimeFeatureName(name) ?? name;
const prev = merged.get(backendName);
if (
prev &&

View file

@ -35,6 +35,10 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
{ maxZoom: 13, resolution: 9 },
] as const;
export const SMALLEST_VISIBLE_HEXAGON_RESOLUTION = Math.max(
...ZOOM_TO_RESOLUTION_THRESHOLDS.map(({ resolution }) => resolution)
);
export const POSTCODE_ZOOM_THRESHOLD = 15;
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
@ -132,6 +136,9 @@ export const POI_CATEGORY_LOGOS: Record<string, string> = {
Aldi: 'https://geolytix.github.io/MapIcons/brands/aldi_24px.svg',
Amazon: 'https://geolytix.github.io/MapIcons/brands/amazon_fresh_alt_24px.svg',
Asda: 'https://geolytix.github.io/MapIcons/asda/asda_primary.svg',
'Asda Express': 'https://geolytix.github.io/MapIcons/asda/asda_express_24px.svg',
'Asda Living': 'https://geolytix.github.io/MapIcons/asda/asda_living_24px.svg',
'Asda PFS': 'https://geolytix.github.io/MapIcons/asda/asda_pfs_24px.svg',
Bakery: '/assets/twemoji/1f950.png',
Booths: 'https://geolytix.github.io/MapIcons/brands/booths_24px.svg',
Budgens: 'https://geolytix.github.io/MapIcons/brands/budgens_24px.svg',
@ -153,17 +160,28 @@ export const POI_CATEGORY_LOGOS: Record<string, string> = {
Lidl: 'https://geolytix.github.io/MapIcons/brands/lidl_24px.svg',
Makro: 'https://geolytix.github.io/MapIcons/brands/makro_24px.svg',
'M&S': 'https://geolytix.github.io/MapIcons/brands/mns_24px.svg',
'M&S Clothing': 'https://geolytix.github.io/MapIcons/brands/mns_high_street_24px.svg',
'M&S Food': 'https://geolytix.github.io/MapIcons/brands/mns_food_24px.svg',
'M&S Hospital': 'https://geolytix.github.io/MapIcons/brands/mns_hospital_24px.svg',
'M&S MSA': 'https://geolytix.github.io/MapIcons/brands/mns_moto_24px.svg',
'M&S Outlet': 'https://geolytix.github.io/MapIcons/brands/mns_outlet_24px.svg',
Morrisons: 'https://geolytix.github.io/MapIcons/brands/morrisons_24px.svg',
'Morrisons Daily': 'https://geolytix.github.io/MapIcons/brands/morrisons_daily_24px.svg',
'Off-Licence': '/assets/twemoji/1f377.png',
'Planet Organic': 'https://geolytix.github.io/MapIcons/logos/planet_organic_24px.svg',
'Rail station': '/assets/twemoji/1f686.png',
"Sainsbury's": 'https://geolytix.github.io/MapIcons/brands/sainsburys_24px.svg',
"Sainsbury's Local": 'https://geolytix.github.io/MapIcons/brands/sainsburys_local_24px.svg',
Spar: 'https://geolytix.github.io/MapIcons/brands/spar_24px.svg',
Supermarket: '/assets/twemoji/1f6d2.png',
Tesco: 'https://geolytix.github.io/MapIcons/brands/tesco_24px.svg',
'Tesco Express': 'https://geolytix.github.io/MapIcons/brands/tesco_express_24px.svg',
'Tesco Extra': 'https://geolytix.github.io/MapIcons/brands/tesco_extra_24px.svg',
'Taxi rank': '/assets/twemoji/1f695.png',
'The Food Warehouse': 'https://geolytix.github.io/MapIcons/brands/iceland_food_warehouse_24px.svg',
'Tube station': 'https://geolytix.github.io/MapIcons/public_transport/london_tube.svg',
Waitrose: 'https://geolytix.github.io/MapIcons/brands/waitrose_24px.svg',
'Little Waitrose': 'https://geolytix.github.io/MapIcons/brands/little_waitrose_24px.svg',
'Whole Foods Market': 'https://geolytix.github.io/MapIcons/brands/wholefoods_24px.svg',
};

View file

@ -0,0 +1,116 @@
import type { FeatureFilters, FeatureMeta } from '../types';
export const SPECIFIC_CRIMES_FILTER_NAME = 'Specific crimes';
export const SPECIFIC_CRIMES_FILTER_KEY_PREFIX = `${SPECIFIC_CRIMES_FILTER_NAME}:`;
export const SPECIFIC_CRIME_FEATURE_NAMES = [
'Violence and sexual offences (avg/yr)',
'Burglary (avg/yr)',
'Robbery (avg/yr)',
'Vehicle crime (avg/yr)',
'Anti-social behaviour (avg/yr)',
'Criminal damage and arson (avg/yr)',
'Other theft (avg/yr)',
'Theft from the person (avg/yr)',
'Shoplifting (avg/yr)',
'Bicycle theft (avg/yr)',
'Drugs (avg/yr)',
'Possession of weapons (avg/yr)',
'Public order (avg/yr)',
'Other crime (avg/yr)',
] as const;
const SPECIFIC_CRIME_FEATURE_NAME_SET = new Set<string>(SPECIFIC_CRIME_FEATURE_NAMES);
export function isSpecificCrimeFeatureName(name: string): boolean {
return SPECIFIC_CRIME_FEATURE_NAME_SET.has(name);
}
export function isSpecificCrimeFilterName(name: string): boolean {
return isSpecificCrimeFeatureName(name) || name.startsWith(SPECIFIC_CRIMES_FILTER_KEY_PREFIX);
}
export function createSpecificCrimeFilterKey(featureName: string, id: number | string): string {
return `${SPECIFIC_CRIMES_FILTER_KEY_PREFIX}${encodeURIComponent(featureName)}:${id}`;
}
export function getSpecificCrimeFilterKeyId(name: string): string | null {
if (!name.startsWith(SPECIFIC_CRIMES_FILTER_KEY_PREFIX)) return null;
const rest = name.substring(SPECIFIC_CRIMES_FILTER_KEY_PREFIX.length);
const lastColon = rest.lastIndexOf(':');
return lastColon === -1 ? null : rest.substring(lastColon + 1);
}
export function parseSpecificCrimeFilterKey(name: string): string | null {
if (!name.startsWith(SPECIFIC_CRIMES_FILTER_KEY_PREFIX)) return null;
const rest = name.substring(SPECIFIC_CRIMES_FILTER_KEY_PREFIX.length);
const lastColon = rest.lastIndexOf(':');
if (lastColon === -1) return null;
const decoded = decodeURIComponent(rest.substring(0, lastColon));
return isSpecificCrimeFeatureName(decoded) ? decoded : null;
}
export function getSpecificCrimeFeatureName(name: string): string | null {
if (isSpecificCrimeFeatureName(name)) return name;
return parseSpecificCrimeFilterKey(name);
}
export function replaceSpecificCrimeFilterKeySelection(key: string, featureName: string): string {
const id = getSpecificCrimeFilterKeyId(key) ?? '0';
return createSpecificCrimeFilterKey(featureName, id);
}
export function getDefaultSpecificCrimeFeatureName(features: FeatureMeta[]): string | null {
return (
SPECIFIC_CRIME_FEATURE_NAMES.find((name) =>
features.some((feature) => feature.name === name)
) ?? null
);
}
export function normalizeSpecificCrimeFilters(filters: FeatureFilters): FeatureFilters {
let changed = false;
const next: FeatureFilters = {};
for (const [name, value] of Object.entries(filters)) {
if (isSpecificCrimeFeatureName(name)) {
next[createSpecificCrimeFilterKey(name, Object.keys(next).length)] = value;
changed = true;
continue;
}
next[name] = value;
}
return changed ? next : filters;
}
export function getSpecificCrimeFilterMeta(features: FeatureMeta[]): FeatureMeta {
const sourceFeatureName = getDefaultSpecificCrimeFeatureName(features);
const sourceFeature = sourceFeatureName
? features.find((feature) => feature.name === sourceFeatureName)
: undefined;
return {
name: SPECIFIC_CRIMES_FILTER_NAME,
type: 'numeric',
group: 'Crime',
min: sourceFeature?.min ?? 0,
max: sourceFeature?.max ?? 100,
step: 1,
description:
'Violence, burglary, robbery, drugs, shoplifting, vehicle crime, anti-social behaviour, public order, theft, and other crime types',
detail: 'Filter by one street-level crime category at a time using yearly averages per LSOA.',
source: 'crime',
suffix: '/yr',
};
}
export function clampSpecificCrimeRange(
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

@ -188,14 +188,14 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
),
// ── Deprivation ──────────────────────────────
'Income Score (rate)': (
'Income Score': (
<>
<rect x="2" y="6" width="20" height="14" rx="2" />
<path d="M2 10h20" />
<path d="M6 14h4m4 0h4" />
</>
),
'Employment Score (rate)': (
'Employment Score': (
<>
<rect x="2" y="7" width="20" height="14" rx="2" />
<path d="M16 3h-8a2 2 0 00-2 2v2h12V5a2 2 0 00-2-2z" />
@ -207,19 +207,13 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<path d="M20.42 4.58a5.4 5.4 0 00-7.65 0L12 5.34l-.77-.76a5.4 5.4 0 00-7.65 7.65L12 20.65l8.42-8.42a5.4 5.4 0 000-7.65z" />
</>
),
'Living Environment Score': (
<>
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
<path d="M9 16l2 2 4-4" />
</>
),
'Indoors Sub-domain Score': (
'Housing Conditions Score': (
<>
<path d="M20 9V6a2 2 0 00-2-2H6a2 2 0 00-2 2v3" />
<path d="M2 11v5a2 2 0 002 2h1v3h2v-3h10v3h2v-3h1a2 2 0 002-2v-5a3 3 0 00-3-3H5a3 3 0 00-3 3z" />
</>
),
'Outdoors Sub-domain Score': (
'Air Quality and Road Safety Score': (
<>
<path d="M11 20A7 7 0 019.8 6.9C15.5 4.9 20 9 20 9s-3.4 5.4-3.4 9c0 .6 0 1.2-.1 1.8" />
<path d="M12 10a3.5 3.5 0 00-5 5" />

View file

@ -0,0 +1,46 @@
import { cellToChildren, cellToLatLng, latLngToCell } from 'h3-js';
import { describe, expect, it } from 'vitest';
import type { HexagonData } from '../types';
import { findOverlappingMatchingHexagon, hasMatchingHexagonAtResolution } from './h3-selection';
function hexagonData(h3: string, count: number): HexagonData {
const [lat, lon] = cellToLatLng(h3);
return { h3, count, lat, lon };
}
describe('h3 selection helpers', () => {
it('finds 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(
parent,
[hexagonData(children[0], 0), hexagonData(children[1], 4)],
9
);
expect(selected?.h3).toBe(children[1]);
});
it('rejects candidates that do not overlap or have no matches', () => {
const parent = latLngToCell(51.5, -0.1, 8);
const nearbyChild = cellToChildren(parent, 9)[0];
const distant = latLngToCell(52.2, -0.1, 9);
expect(
findOverlappingMatchingHexagon(
parent,
[hexagonData(nearbyChild, 0), hexagonData(distant, 12)],
9
)
).toBeNull();
});
it('detects when target-resolution matching data is loaded', () => {
const parent = latLngToCell(51.5, -0.1, 8);
const child = cellToChildren(parent, 9)[0];
expect(hasMatchingHexagonAtResolution([hexagonData(child, 1)], 9)).toBe(true);
expect(hasMatchingHexagonAtResolution([hexagonData(child, 0)], 9)).toBe(false);
expect(hasMatchingHexagonAtResolution([hexagonData(parent, 1)], 9)).toBe(false);
});
});

View file

@ -0,0 +1,128 @@
import { cellToBoundary, cellToLatLng, getResolution } from 'h3-js';
import type { HexagonData } from '../types';
type Point = [number, number];
const EPSILON = 1e-12;
function samePoint(a: Point, b: Point): boolean {
return Math.abs(a[0] - b[0]) <= EPSILON && Math.abs(a[1] - b[1]) <= EPSILON;
}
function cellBoundary(h3: string): Point[] {
const boundary = cellToBoundary(h3, true) as Point[];
if (boundary.length > 1 && samePoint(boundary[0], boundary[boundary.length - 1])) {
return boundary.slice(0, -1);
}
return boundary;
}
function orientation(a: Point, b: Point, c: Point): number {
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
}
function pointOnSegment(point: Point, start: Point, end: Point): boolean {
if (Math.abs(orientation(start, end, point)) > EPSILON) return false;
return (
point[0] >= Math.min(start[0], end[0]) - EPSILON &&
point[0] <= Math.max(start[0], end[0]) + EPSILON &&
point[1] >= Math.min(start[1], end[1]) - EPSILON &&
point[1] <= Math.max(start[1], end[1]) + EPSILON
);
}
function segmentsIntersect(a: Point, b: Point, c: Point, d: Point): boolean {
const abC = orientation(a, b, c);
const abD = orientation(a, b, d);
const cdA = orientation(c, d, a);
const cdB = orientation(c, d, b);
if (
((abC > EPSILON && abD < -EPSILON) || (abC < -EPSILON && abD > EPSILON)) &&
((cdA > EPSILON && cdB < -EPSILON) || (cdA < -EPSILON && cdB > EPSILON))
) {
return true;
}
return (
pointOnSegment(c, a, b) ||
pointOnSegment(d, a, b) ||
pointOnSegment(a, c, d) ||
pointOnSegment(b, c, d)
);
}
function pointInPolygon(point: Point, polygon: Point[]): boolean {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const current = polygon[i];
const previous = polygon[j];
if (pointOnSegment(point, previous, current)) return true;
if (current[1] > point[1] !== previous[1] > point[1]) {
const x =
((previous[0] - current[0]) * (point[1] - current[1])) /
(previous[1] - current[1]) +
current[0];
if (point[0] < x) inside = !inside;
}
}
return inside;
}
export function polygonsOverlap(a: Point[], b: Point[]): boolean {
if (a.length < 3 || b.length < 3) return false;
if (a.some((point) => pointInPolygon(point, b))) return true;
if (b.some((point) => pointInPolygon(point, a))) return true;
for (let i = 0; i < a.length; i++) {
const aNext = (i + 1) % a.length;
for (let j = 0; j < b.length; j++) {
const bNext = (j + 1) % b.length;
if (segmentsIntersect(a[i], a[aNext], b[j], b[bNext])) return true;
}
}
return false;
}
export function hasMatchingHexagonAtResolution(
hexagons: HexagonData[],
resolution: number
): boolean {
return hexagons.some(
(hexagon) => hexagon.count > 0 && getResolution(hexagon.h3) === resolution
);
}
export function findOverlappingMatchingHexagon(
previousH3: string,
hexagons: HexagonData[],
resolution: number
): HexagonData | null {
const previousBoundary = cellBoundary(previousH3);
const [previousLat, previousLng] = cellToLatLng(previousH3);
let best: HexagonData | null = null;
let bestDistance = Infinity;
for (const hexagon of hexagons) {
if (hexagon.count <= 0 || getResolution(hexagon.h3) !== resolution) continue;
if (!polygonsOverlap(previousBoundary, cellBoundary(hexagon.h3))) continue;
const distance = (hexagon.lat - previousLat) ** 2 + (hexagon.lon - previousLng) ** 2;
if (
!best ||
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;
}
}
return best;
}

View file

@ -1,11 +1,17 @@
import { describe, expect, it } from 'vitest';
import { DENSITY_GRADIENT, ENUM_PALETTE, FEATURE_GRADIENT } from './consts';
import {
DENSITY_GRADIENT,
ENUM_PALETTE,
FEATURE_GRADIENT,
SMALLEST_VISIBLE_HEXAGON_RESOLUTION,
} from './consts';
import {
emojiToTwemojiUrl,
enumIndexToColor,
getBoundsFromViewState,
getFeatureFillColor,
getMapCenterForTargetScreenPoint,
getPoiIconUrl,
zoomToResolution,
} from './map-utils';
@ -16,6 +22,7 @@ describe('map utilities', () => {
expect(zoomToResolution(7)).toBe(6);
expect(zoomToResolution(10.6)).toBe(8);
expect(zoomToResolution(14)).toBe(9);
expect(SMALLEST_VISIBLE_HEXAGON_RESOLUTION).toBe(9);
});
it('computes buffered bounds around a view state', () => {
@ -31,6 +38,13 @@ describe('map utilities', () => {
expect(bounds.east).toBeGreaterThan(-0.1);
});
it('moves the map center so a target lands in the requested screen position', () => {
const centered = getMapCenterForTargetScreenPoint(51.5, -0.1, 17, 390, 844, 195, 42.2);
expect(centered.longitude).toBeCloseTo(-0.1, 6);
expect(centered.latitude).toBeLessThan(51.5);
});
it('builds twemoji URLs and wraps enum colors', () => {
expect(emojiToTwemojiUrl('🛒')).toBe('/assets/twemoji/1f6d2.png');
expect(emojiToTwemojiUrl('')).toBe('/assets/twemoji/1f4cd.png');

View file

@ -13,6 +13,59 @@ import {
type GradientStop,
} from './consts';
const ROAD_OPACITY = 0.4;
const TILE_SIZE = 512;
const MAX_MERCATOR_LATITUDE = 85;
function clampLatitude(latitude: number): number {
return Math.max(-MAX_MERCATOR_LATITUDE, Math.min(MAX_MERCATOR_LATITUDE, latitude));
}
function longitudeToWorldX(longitude: number, worldSize: number): number {
return ((longitude + 180) / 360) * worldSize;
}
function latitudeToWorldY(latitude: number, worldSize: number): number {
const clampedLat = clampLatitude(latitude);
const latRad = (clampedLat * Math.PI) / 180;
const mercatorY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;
return mercatorY * worldSize;
}
function worldXToLongitude(pixelX: number, worldSize: number): number {
const longitude = (pixelX / worldSize) * 360 - 180;
return ((((longitude + 180) % 360) + 360) % 360) - 180;
}
function worldYToLatitude(pixelY: number, worldSize: number): number {
const mercY = Math.max(0.001, Math.min(0.999, pixelY / worldSize));
const latRadians = Math.atan(Math.sinh(Math.PI * (1 - 2 * mercY)));
return (latRadians * 180) / Math.PI;
}
export function getMapCenterForTargetScreenPoint(
targetLatitude: number,
targetLongitude: number,
zoom: number,
width: number,
height: number,
targetScreenX: number,
targetScreenY: number
): Pick<ViewState, 'latitude' | 'longitude'> {
if (width <= 0 || height <= 0) {
return { latitude: targetLatitude, longitude: targetLongitude };
}
const worldSize = TILE_SIZE * Math.pow(2, zoom);
const targetWorldX = longitudeToWorldX(targetLongitude, worldSize);
const targetWorldY = latitudeToWorldY(targetLatitude, worldSize);
const centerWorldX = targetWorldX + width / 2 - targetScreenX;
const centerWorldY = targetWorldY + height / 2 - targetScreenY;
return {
latitude: worldYToLatitude(centerWorldY, worldSize),
longitude: worldXToLongitude(centerWorldX, worldSize),
};
}
export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
const flavor = namedFlavor(theme);
@ -158,8 +211,7 @@ export function getBoundsFromViewState(
height: number
): Bounds {
const { longitude, latitude, zoom } = viewState;
const clampedLat = Math.max(-85, Math.min(85, latitude));
const TILE_SIZE = 512;
const clampedLat = clampLatitude(latitude);
const scale = Math.pow(2, zoom);
const worldSize = TILE_SIZE * scale;
@ -169,21 +221,13 @@ export function getBoundsFromViewState(
const degreesPerPixelLng = 360 / worldSize;
const halfWidthDeg = (bufferedWidth / 2) * degreesPerPixelLng;
const latRad = (clampedLat * Math.PI) / 180;
const mercatorY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;
const centerPixelY = mercatorY * worldSize;
const centerPixelY = latitudeToWorldY(clampedLat, worldSize);
const topPixelY = centerPixelY - bufferedHeight / 2;
const bottomPixelY = centerPixelY + bufferedHeight / 2;
const pixelYToLat = (pixelY: number): number => {
const mercY = Math.max(0.001, Math.min(0.999, pixelY / worldSize));
const latRadians = Math.atan(Math.sinh(Math.PI * (1 - 2 * mercY)));
return (latRadians * 180) / Math.PI;
};
const north = Math.min(85, pixelYToLat(topPixelY));
const south = Math.max(-85, pixelYToLat(bottomPixelY));
const north = Math.min(MAX_MERCATOR_LATITUDE, worldYToLatitude(topPixelY, worldSize));
const south = Math.max(-MAX_MERCATOR_LATITUDE, worldYToLatitude(bottomPixelY, worldSize));
const west = Math.max(-180, longitude - halfWidthDeg);
const east = Math.min(180, longitude + halfWidthDeg);

View file

@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it } from 'vitest';
import type { FeatureMeta } from '../types';
import { parseUrlState, stateToParams } from './url-state';
import { createSchoolFilterKey } from './school-filter';
import { createSpecificCrimeFilterKey } from './crime-filter';
describe('url-state', () => {
beforeEach(() => {
@ -110,6 +111,36 @@ describe('url-state', () => {
});
});
it('round-trips repeated specific crime filters with dedicated URL params', () => {
const burglary = createSpecificCrimeFilterKey('Burglary (avg/yr)', 1);
const vehicleCrime = createSpecificCrimeFilterKey('Vehicle crime (avg/yr)', 2);
const params = stateToParams(
null,
{
[burglary]: [0, 5],
[vehicleCrime]: [1, 10],
},
[],
new Set(),
'area'
);
expect(params.getAll('crime')).toEqual([
'Burglary (avg/yr):0:5',
'Vehicle crime (avg/yr):1:10',
]);
expect(params.getAll('filter')).toEqual([]);
window.history.replaceState({}, '', `/?${params.toString()}`);
const state = parseUrlState();
expect(state.filters).toEqual({
[createSpecificCrimeFilterKey('Burglary (avg/yr)', 0)]: [0, 5],
[createSpecificCrimeFilterKey('Vehicle crime (avg/yr)', 1)]: [1, 10],
});
});
it('omits the default area tab', () => {
const params = stateToParams(null, {}, [], new Set(), 'area');

View file

@ -8,17 +8,28 @@ import {
import {
SCHOOL_FILTER_NAME,
createSchoolFilterKey,
getSchoolBackendFeatureName,
getSchoolFilterConfig,
isSchoolFilterName,
type SchoolDistance,
type SchoolPhase,
type SchoolRating,
} from './school-filter';
import {
SPECIFIC_CRIMES_FILTER_NAME,
createSpecificCrimeFilterKey,
getSpecificCrimeFeatureName,
isSpecificCrimeFeatureName,
isSpecificCrimeFilterName,
} from './crime-filter';
function parseFilters(params: URLSearchParams): FeatureFilters | undefined {
const filterParams = params.getAll('filter');
const schoolParams = params.getAll('school');
if (filterParams.length === 0 && schoolParams.length === 0) return undefined;
const crimeParams = params.getAll('crime');
if (filterParams.length === 0 && schoolParams.length === 0 && crimeParams.length === 0) {
return undefined;
}
const filters: FeatureFilters = {};
for (const entry of filterParams) {
@ -60,6 +71,18 @@ function parseFilters(params: URLSearchParams): FeatureFilters | undefined {
filters[createSchoolFilterKey(phase, rating, distance, index)] = [min, max];
});
crimeParams.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 (!isSpecificCrimeFeatureName(featureName) || isNaN(min) || isNaN(max)) {
return;
}
filters[createSpecificCrimeFilterKey(featureName, index)] = [min, max];
});
return Object.keys(filters).length > 0 ? filters : undefined;
}
@ -180,6 +203,13 @@ export function stateToParams(
continue;
}
const specificCrimeFeatureName = getSpecificCrimeFeatureName(name);
if (specificCrimeFeatureName && isSpecificCrimeFilterName(name)) {
const [min, max] = value as [number, number];
params.append('crime', `${specificCrimeFeatureName}:${min}:${max}`);
continue;
}
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') {
params.append('filter', `${name}:${(value as string[]).join('|')}`);
@ -225,14 +255,19 @@ export function summarizeParams(queryString: string): string {
const filterParams = params.getAll('filter');
const schoolParams = params.getAll('school');
if (filterParams.length > 0 || schoolParams.length > 0) {
const crimeParams = params.getAll('crime');
if (filterParams.length > 0 || schoolParams.length > 0 || crimeParams.length > 0) {
const filterNames = filterParams
.map((entry) => {
const colonIdx = entry.indexOf(':');
return colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
const name = colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
return isSpecificCrimeFeatureName(name) ? SPECIFIC_CRIMES_FILTER_NAME : name;
})
.filter((n) => n);
for (let i = 0; i < schoolParams.length; i++) filterNames.push(SCHOOL_FILTER_NAME);
for (let i = 0; i < crimeParams.length; i++) {
filterNames.push(SPECIFIC_CRIMES_FILTER_NAME);
}
if (filterNames.length > 0) {
parts.push(
filterNames.length <= 2

View file

@ -66,6 +66,22 @@ export interface ViewState {
bearing?: number;
}
export interface MapVisibleAreaInsets {
top?: number;
right?: number;
bottom?: number;
left?: number;
topRatio?: number;
rightRatio?: number;
bottomRatio?: number;
leftRatio?: number;
}
export interface MapFlyToOptions {
visibleArea?: MapVisibleAreaInsets;
visibleViewportArea?: MapVisibleAreaInsets;
}
export interface ViewChangeParams {
resolution: number;
bounds: Bounds;
@ -82,6 +98,7 @@ export interface POI {
id: string;
name: string;
category: string;
icon_category?: string;
group: string;
lat: number;
lng: number;

View file

@ -5,6 +5,7 @@ const CopyWebpackPlugin = require('copy-webpack-plugin');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const FaviconsWebpackPlugin = require('favicons-webpack-plugin');
const sharp = require('sharp');
const webpack = require('webpack');
const HOUSE_IMAGE_WIDTH = 260;
@ -52,6 +53,9 @@ module.exports = (env, argv) => {
],
},
plugins: [
new webpack.DefinePlugin({
__DEV__: JSON.stringify(!isProduction),
}),
new HtmlWebpackPlugin({
template: './src/index.html',
}),

View file

@ -2,12 +2,17 @@
import argparse
import io
import math
import re
import urllib.request
from dataclasses import dataclass
from pathlib import Path
import polars as pl
NAPTAN_CSV_URL = "https://naptan.api.dft.gov.uk/v1/access-nodes?dataFormat=csv"
TUBE_STATION_CATEGORY = "Tube station"
TUBE_STATION_MERGE_RADIUS_DEGREES = 0.01
STOP_TYPES = {
@ -25,6 +30,41 @@ STOP_TYPES = {
OUTPUT_COLUMNS = ["id", "name", "category", "lat", "lng"]
def canonical_station_name(name: str | None) -> str:
"""Normalize station names so entrances/transport-mode variants collapse."""
if not name:
return ""
normalized = name.lower()
normalized = re.sub(r"\([^)]*\)", " ", normalized)
normalized = re.sub(r"['`]", "", normalized)
normalized = normalized.replace("&", " and ")
normalized = re.sub(r"[^a-z0-9]+", " ", normalized)
words = normalized.split()
suffixes = (
("underground", "station"),
("tube", "station"),
("dlr", "station"),
("metro", "station"),
("tram", "stop"),
("rail", "station"),
("railway", "station"),
("station",),
("stop",),
)
while True:
suffix = next(
(suffix for suffix in suffixes if words[-len(suffix) :] == list(suffix)),
None,
)
if suffix is None:
break
del words[-len(suffix) :]
return " ".join(words)
def canonical_station_name_expr(name_col: str = "name") -> pl.Expr:
"""Normalize station names so entrances/transport-mode variants collapse."""
expr = pl.col(name_col).str.to_lowercase()
@ -45,10 +85,7 @@ def _has_locality() -> pl.Expr:
return pl.col("locality").is_not_null() & (pl.col("locality") != "")
def _deduplicate_tube_partition(
df: pl.DataFrame, group_cols: list[str]
) -> pl.DataFrame:
if len(df) == 0:
def _empty_output_frame() -> pl.DataFrame:
return pl.DataFrame(
{
"id": pl.Series([], dtype=pl.String),
@ -59,53 +96,147 @@ def _deduplicate_tube_partition(
}
)
name_len = pl.col("name").str.len_chars()
return (
df.group_by(group_cols)
.agg(
pl.col("id").sort_by(name_len).first(),
pl.col("name").sort_by(name_len).first(),
pl.col("category").first(),
pl.col("lat").mean(),
pl.col("lng").mean(),
def station_name_score(name: str) -> tuple[int, int]:
lower = name.lower()
suffix_penalty = int(
lower.endswith(
(
" underground station",
" tube station",
" dlr station",
" metro station",
" tram stop",
" station",
" stop",
)
.select(OUTPUT_COLUMNS)
)
)
return (suffix_penalty, len(name))
@dataclass
class StationAccumulator:
id: str
name: str
category: str
lat_sum: float
lng_sum: float
count: int = 1
@property
def lat(self) -> float:
return self.lat_sum / self.count
@property
def lng(self) -> float:
return self.lng_sum / self.count
def same_area(self, lat: float, lng: float) -> bool:
dlat = self.lat - lat
dlng = (self.lng - lng) * math.cos(math.radians(self.lat))
return (dlat * dlat + dlng * dlng) <= TUBE_STATION_MERGE_RADIUS_DEGREES**2
def merge(self, row: dict[str, object]) -> None:
self.lat_sum += float(row["lat"])
self.lng_sum += float(row["lng"])
self.count += 1
name = str(row["name"] or "")
if station_name_score(name) < station_name_score(self.name):
self.id = str(row["id"] or "")
self.name = name
def _station_from_row(row: dict[str, object]) -> StationAccumulator:
return StationAccumulator(
id=str(row["id"] or ""),
name=str(row["name"] or ""),
category=str(row["category"] or ""),
lat_sum=float(row["lat"]),
lng_sum=float(row["lng"]),
)
def deduplicate_naptan(df: pl.DataFrame) -> pl.DataFrame:
"""Deduplicate NaPTAN stops, with stricter station-level merging for Tube POIs."""
def _deduplicate_tube_stations(df: pl.DataFrame) -> pl.DataFrame:
if len(df) == 0:
return _empty_output_frame()
selected: list[StationAccumulator] = []
groups: dict[str, list[int]] = {}
for row in df.iter_rows(named=True):
station_key = canonical_station_name(str(row["name"] or ""))
if not station_key:
selected.append(_station_from_row(row))
continue
existing = next(
(
index
for index in groups.get(station_key, [])
if selected[index].same_area(float(row["lat"]), float(row["lng"]))
),
None,
)
if existing is not None:
selected[existing].merge(row)
continue
index = len(selected)
selected.append(_station_from_row(row))
groups.setdefault(station_key, []).append(index)
return pl.DataFrame(
{
"id": [station.id for station in selected],
"name": [station.name for station in selected],
"category": [station.category for station in selected],
"lat": [station.lat for station in selected],
"lng": [station.lng for station in selected],
}
).select(OUTPUT_COLUMNS)
def _deduplicate_non_tube_stops(df: pl.DataFrame) -> pl.DataFrame:
if len(df) == 0:
return _empty_output_frame()
has_loc = df.filter(_has_locality())
no_loc = df.filter(~_has_locality())
cols_with_locality = [*OUTPUT_COLUMNS, "locality"]
# First pass: one record per exact stop name/category/locality.
deduped_has_loc = (
frames = []
if len(has_loc) > 0:
frames.append(
has_loc.group_by("name", "category", "locality")
.agg(
pl.col("id").first(),
pl.col("lat").mean(),
pl.col("lng").mean(),
)
.select(cols_with_locality)
.select(OUTPUT_COLUMNS)
)
df = pl.concat([deduped_has_loc, no_loc.select(cols_with_locality)])
if len(no_loc) > 0:
frames.append(no_loc.select(OUTPUT_COLUMNS))
tube = df.filter(pl.col("category") == "Tube station").with_columns(
canonical_station_name_expr().alias("_station_key")
)
other = df.filter(pl.col("category") != "Tube station")
if not frames:
return _empty_output_frame()
tube_with_loc = tube.filter(_has_locality())
tube_no_loc = tube.filter(~_has_locality())
deduped_tube = pl.concat(
return pl.concat(frames).select(OUTPUT_COLUMNS)
def deduplicate_naptan(df: pl.DataFrame) -> pl.DataFrame:
"""Deduplicate NaPTAN stops, with station-level merging for Tube POIs."""
tube = df.filter(pl.col("category") == TUBE_STATION_CATEGORY)
other = df.filter(pl.col("category") != TUBE_STATION_CATEGORY)
return pl.concat(
[
_deduplicate_tube_partition(tube_with_loc, ["_station_key", "locality"]),
_deduplicate_tube_partition(tube_no_loc, ["_station_key"]),
_deduplicate_non_tube_stops(other),
_deduplicate_tube_stations(tube),
]
)
return pl.concat([other.select(OUTPUT_COLUMNS), deduped_tube])
).select(OUTPUT_COLUMNS)
def download_naptan(output: Path) -> None:
@ -140,7 +271,7 @@ def download_naptan(output: Path) -> None:
print(
f"Deduplicated {before:,}{len(df):,} stops "
"(by name+category+locality; tube stations by normalized station name)"
"(by name+category+locality; tube stations by normalized name+area)"
)
df.write_parquet(output)

View file

@ -1,19 +1,24 @@
import polars as pl
import pytest
from pipeline.download.naptan import canonical_station_name_expr, deduplicate_naptan
from pipeline.download.naptan import (
canonical_station_name,
canonical_station_name_expr,
deduplicate_naptan,
)
def test_canonical_station_name_expr_normalizes_transport_suffixes():
df = pl.DataFrame(
{
"name": [
names = [
"Bank",
"Bank Underground Station",
"Bank DLR Station",
"Pleasure Beach (Blackpool Tramway)",
"Earl's Court Tube Station",
]
df = pl.DataFrame(
{
"name": names,
}
)
@ -26,30 +31,45 @@ def test_canonical_station_name_expr_normalizes_transport_suffixes():
"pleasure beach",
"earls court",
]
assert [canonical_station_name(name) for name in names] == result
def test_deduplicate_naptan_merges_tube_station_variants_by_locality():
def test_deduplicate_naptan_merges_tube_station_variants_by_area():
df = pl.DataFrame(
{
"id": ["bank", "bank-lu", "bank-dlr", "other-bank"],
"id": [
"bank",
"bank-lu",
"bank-dlr",
"other-bank",
"central-a",
"central-b",
],
"name": [
"Bank",
"Bank Underground Station",
"Bank DLR Station",
"Bank Underground Station",
"Central Tube Station",
"Central Tube Station",
],
"category": ["Tube station"] * 4,
"lat": [51.5129, 51.5134, 51.5132, 55.0140],
"lng": [-0.0889, -0.0890, -0.0885, -1.6781],
"locality": ["LOC1", "LOC1", "LOC1", "LOC2"],
"category": ["Tube station"] * 6,
"lat": [51.5129, 51.5134, 51.5132, 55.0140, 51.5, 53.0],
"lng": [-0.0889, -0.0890, -0.0885, -1.6781, -0.1, -2.0],
"locality": ["LOC1", "LOC1", "LOC2", "LOC1", None, None],
}
)
result = deduplicate_naptan(df).sort("lat")
assert len(result) == 2
assert result["name"].to_list() == ["Bank", "Bank Underground Station"]
assert result["lat"].to_list()[0] == pytest.approx(
assert len(result) == 4
assert result["name"].to_list() == [
"Central Tube Station",
"Bank",
"Central Tube Station",
"Bank Underground Station",
]
assert result.filter(pl.col("name") == "Bank")["lat"][0] == pytest.approx(
(51.5129 + 51.5134 + 51.5132) / 3
)

View file

@ -13,13 +13,12 @@ _AREA_COLUMNS = [
"lat",
"lon",
# Deprivation
"Income Score (rate)",
"Employment Score (rate)",
"Income Score",
"Employment Score",
"Education, Skills and Training Score",
"Health Deprivation and Disability Score",
"Living Environment Score",
"Indoors Sub-domain Score",
"Outdoors Sub-domain Score",
"Housing Conditions Score",
"Air Quality and Road Safety Score",
# Ethnicity
"% South Asian",
"% East Asian",
@ -144,7 +143,6 @@ def _build(
"Income Score (rate)",
"Employment Score (rate)",
"Health Deprivation and Disability Score",
"Living Environment Score",
"Indoors Sub-domain Score",
"Outdoors Sub-domain Score",
]
@ -319,6 +317,7 @@ def _build(
"Adult Skills Sub-domain Score",
"Children and Young People Sub-domain Score",
"Crime Score",
"Living Environment Score",
"Index of Multiple Deprivation (IMD) Score",
"Income Deprivation Affecting Older People (IDAOPI) Score (rate)",
"Income Deprivation Affecting Children Index (IDACI) Score (rate)",
@ -335,6 +334,10 @@ def _build(
"date_of_transfer": "Date of last transaction",
"construction_age_band": "Construction year",
"is_construction_date_approximate": "Is construction date approximate",
"Income Score (rate)": "Income Score",
"Employment Score (rate)": "Employment Score",
"Indoors Sub-domain Score": "Housing Conditions Score",
"Outdoors Sub-domain Score": "Air Quality and Road Safety Score",
"pp_address": "Address per Property Register",
"epc_address": "Address per EPC",
"postcode": "Postcode",

View file

@ -17,11 +17,14 @@ def test_transform_grocery_retail_points_outputs_chain_categories():
pois = transform_grocery_retail_points(raw)
assert pois.select("id", "name", "category", "group", "emoji").to_dicts() == [
assert pois.select(
"id", "name", "category", "icon_category", "group", "emoji"
).to_dicts() == [
{
"id": "glx-101",
"name": "Waitrose Test",
"category": "Waitrose",
"icon_category": "Waitrose",
"group": "Groceries",
"emoji": "🛒",
},
@ -29,6 +32,7 @@ def test_transform_grocery_retail_points_outputs_chain_categories():
"id": "glx-102",
"name": "Sainsbury's Test",
"category": "Sainsbury's",
"icon_category": "Sainsbury's Local",
"group": "Groceries",
"emoji": "🛒",
},
@ -36,12 +40,45 @@ def test_transform_grocery_retail_points_outputs_chain_categories():
"id": "glx-103",
"name": "Co-op Test",
"category": "Co-op",
"icon_category": "Co-op",
"group": "Groceries",
"emoji": "🛒",
},
]
def test_transform_grocery_retail_points_keeps_fascia_icon_category():
raw = pl.DataFrame(
{
"id": [101, 102, 103, 104],
"retailer": ["Tesco", "Iceland", "Waitrose", "Morrisons"],
"fascia": [
"Tesco Express Esso",
"The Food Warehouse",
"Little Waitrose Shell",
"Morrisons Daily",
],
"store_name": [
"Tesco Test Express",
"Iceland Test Food Warehouse",
"Little Waitrose Test",
"Morrisons Daily Test",
],
"long_wgs": [-0.141, -0.142, -0.143, -0.144],
"lat_wgs": [51.515, 51.516, 51.517, 51.518],
}
)
pois = transform_grocery_retail_points(raw)
assert pois.select("category", "icon_category").to_dicts() == [
{"category": "Tesco", "icon_category": "Tesco Express"},
{"category": "Iceland", "icon_category": "The Food Warehouse"},
{"category": "Waitrose", "icon_category": "Little Waitrose"},
{"category": "Morrisons", "icon_category": "Morrisons Daily"},
]
def test_transform_grocery_retail_points_drops_invalid_rows():
raw = pl.DataFrame(
{

View file

@ -1086,12 +1086,56 @@ GROCERY_RETAILER_DISPLAY_NAMES: dict[str, str] = {
}
GROCERY_FASCIA_ICON_NAMES: dict[str, str] = {
"Aldi Local": "Aldi",
"Asda Express": "Asda Express",
"Asda Living": "Asda Living",
"Asda PFS": "Asda PFS",
"Cooltrader": "Heron Foods",
"Cook": "COOK",
"Eurospar": "Spar",
"Eurospar PFS": "Spar",
"Heron": "Heron Foods",
"Little Waitrose": "Little Waitrose",
"Little Waitrose Shell": "Little Waitrose",
"Marks and Spencer": "M&S",
"Marks and Spencer BP": "M&S Food",
"Marks and Spencer Clothing": "M&S Clothing",
"Marks and Spencer Food To Go": "M&S Food",
"Marks and Spencer Food Outlet": "M&S Outlet",
"Marks and Spencer Foodhall": "M&S Food",
"Marks and Spencer Hospital": "M&S Hospital",
"Marks and Spencer MSA": "M&S MSA",
"Marks and Spencer Outlet": "M&S Outlet",
"Marks and Spencer Simply Food": "M&S Food",
"Marks and Spencer Travel SF": "M&S Food",
"Morrisons Daily": "Morrisons Daily",
"Morrisons Select": "Morrisons",
"Sainsburys": "Sainsbury's",
"Sainsburys Local": "Sainsbury's Local",
"Spar PFS": "Spar",
"Tesco Express": "Tesco Express",
"Tesco Express Esso": "Tesco Express",
"Tesco Extra": "Tesco Extra",
"The Co-operative Food": "Co-op",
"The Co-operative Food PFS": "Co-op",
"The Food Warehouse": "The Food Warehouse",
"Waitrose MSA": "Waitrose",
}
def normalize_grocery_retailer(retailer: str | None) -> str:
if retailer is None:
return ""
return GROCERY_RETAILER_DISPLAY_NAMES.get(retailer, retailer)
def normalize_grocery_icon_category(fascia: str | None, retailer: str | None) -> str:
if fascia:
return GROCERY_FASCIA_ICON_NAMES.get(fascia, normalize_grocery_retailer(fascia))
return normalize_grocery_retailer(retailer)
def transform_grocery_retail_points(
grocery_df: pl.DataFrame,
boundary_path: Path | None = None,
@ -1133,9 +1177,15 @@ def transform_grocery_retail_points(
pl.col("retailer")
.map_elements(normalize_grocery_retailer, return_dtype=pl.String)
.alias("category"),
pl.struct(["fascia", "retailer"])
.map_elements(
lambda row: normalize_grocery_icon_category(row["fascia"], row["retailer"]),
return_dtype=pl.String,
)
.alias("icon_category"),
pl.lit("Groceries").alias("group"),
pl.lit("🛒").alias("emoji"),
).select("id", "name", "category", "group", "lat", "lng", "emoji")
).select("id", "name", "category", "icon_category", "group", "lat", "lng", "emoji")
def transform(
@ -1189,6 +1239,7 @@ def transform(
lf = lf.with_columns(
pl.col("category").replace_strict(group_mapping).alias("group"),
pl.col("category").replace_strict(name_mapping).alias("category"),
pl.col("category").replace_strict(name_mapping).alias("icon_category"),
pl.col("category").replace_strict(emoji_mapping).alias("emoji"),
)
@ -1203,6 +1254,7 @@ def transform(
naptan = naptan_df.lazy().with_columns(
pl.col("category").replace_strict(NAPTAN_EMOJIS).alias("emoji"),
pl.lit("Public Transport").alias("group"),
pl.col("category").alias("icon_category"),
)
frames = [lf, naptan]

View file

@ -19,7 +19,7 @@ export class ScreenshotCache {
* Build a cache key by quantizing view params and hashing.
* - lat/lon quantized to 2 decimal places
* - zoom quantized to integer
* - filters and POI categories sorted alphabetically
* - filters, configurable filters, and POI categories sorted alphabetically
*/
buildKey(params: URLSearchParams): string {
const normalized: Record<string, string> = {};
@ -40,6 +40,16 @@ export class ScreenshotCache {
normalized.filters = filters.join(',');
}
const schools = params.getAll('school').sort();
if (schools.length > 0) {
normalized.school = schools.join(',');
}
const crimes = params.getAll('crime').sort();
if (crimes.length > 0) {
normalized.crime = crimes.join(',');
}
// Sort POI categories
const pois = params.getAll('poi').sort();
if (pois.length > 0) {

View file

@ -12,6 +12,8 @@ test('buildScreenshotRequest accepts supported screenshot parameters', () => {
og: '1',
path: '/invite/abc123',
filter: ['Last known price:100000:500000', 'Total floor area (sqm):50:150'],
school: 'primary:good:2:1:10',
crime: ['Burglary (avg/yr):0:5', 'Vehicle crime (avg/yr):0:10'],
poi: 'supermarket',
tt: 'transit:kings-cross:Kings Cross:b:0:30',
});
@ -25,6 +27,11 @@ test('buildScreenshotRequest accepts supported screenshot parameters', () => {
'Last known price:100000:500000',
'Total floor area (sqm):50:150',
]);
assert.deepEqual(result.qs.getAll('school'), ['primary:good:2:1:10']);
assert.deepEqual(result.qs.getAll('crime'), [
'Burglary (avg/yr):0:5',
'Vehicle crime (avg/yr):0:10',
]);
});
test('buildScreenshotRequest rejects invalid numeric values', () => {

View file

@ -12,7 +12,7 @@ const MAX_VALUE_LENGTH = 500;
const NUMERIC_RE = /^-?(?:\d+|\d*\.\d+)$/;
const PATH_RE = /^\/(?:invite\/[A-Za-z0-9]{1,20})?$/;
const SAFE_VALUE_RE = /^[^\u0000-\u001f\u007f]+$/;
const REPEATED_KEYS = ['filter', 'poi', 'tt'] as const;
const REPEATED_KEYS = ['filter', 'school', 'crime', 'poi', 'tt'] as const;
type Query = Record<string, unknown>;

View file

@ -26,6 +26,7 @@ pub struct POIData {
id_lengths: Vec<u16>,
pub group: InternedColumn,
pub category: InternedColumn,
pub icon_category: InternedColumn,
pub name: Vec<String>,
pub lat: Vec<f32>,
pub lng: Vec<f32>,
@ -93,6 +94,15 @@ impl POIData {
let lat = extract_f32_col(&df, "lat", 0.0)?;
let lng = extract_f32_col(&df, "lng", 0.0)?;
let emoji_raw = extract_str_col(&df, "emoji")?;
let icon_category_raw = if df
.get_column_names()
.iter()
.any(|name| name.as_str() == "icon_category")
{
extract_str_col(&df, "icon_category")?
} else {
category_raw.clone()
};
// Pack POI IDs into a contiguous buffer
let total_id_bytes: usize = id_raw.iter().map(|s| s.len()).sum();
@ -108,11 +118,13 @@ impl POIData {
}
let category = InternedColumn::build(&category_raw);
let icon_category = InternedColumn::build(&icon_category_raw);
let group = InternedColumn::build(&group_raw);
let emoji = InternedColumn::build(&emoji_raw);
info!(
category_unique = category.values.len(),
icon_category_unique = icon_category.values.len(),
group_unique = group.values.len(),
emoji_unique = emoji.values.len(),
"POI string columns interned"
@ -131,6 +143,7 @@ impl POIData {
id_lengths,
name,
category,
icon_category,
group,
lat,
lng,

View file

@ -513,6 +513,8 @@ pub struct PropertyData {
/// Per-feature: max - min (for encoding filter bounds).
quant_range: Vec<f32>,
pub feature_stats: Vec<FeatureStats>,
/// Unquantized last sale price used by the price-history chart.
last_known_price_raw: Vec<f32>,
/// Contiguous buffer holding all address strings end-to-end.
address_buffer: String,
/// Byte offset into `address_buffer` where each row's address starts.
@ -754,6 +756,12 @@ impl PropertyData {
self.price_qualifier.get(&(row as u32)).map(String::as_str)
}
/// Get the unquantized last sale price for charting.
#[inline]
pub fn last_known_price_raw(&self, row: usize) -> f32 {
self.last_known_price_raw[row]
}
/// Decode a single feature value from quantized u16 storage.
#[inline]
pub fn get_feature(&self, row: usize, feat_idx: usize) -> f32 {
@ -1476,6 +1484,15 @@ impl PropertyData {
.iter()
.map(|&perm_index| lon[perm_index as usize])
.collect();
let last_known_price_raw: Vec<f32> = numeric_names
.iter()
.position(|&name| name == "Last known price")
.map(|price_idx| {
perm.iter()
.map(|&perm_index| numeric_col_major[price_idx][perm_index as usize])
.collect()
})
.unwrap_or_else(|| vec![f32::NAN; row_count]);
// Build contiguous address buffer and address search index (permuted)
tracing::info!("Building interned strings");
@ -1679,6 +1696,7 @@ impl PropertyData {
quant_min,
quant_range,
feature_stats,
last_known_price_raw,
address_buffer,
address_offsets,
address_lengths,
@ -1907,6 +1925,16 @@ mod tests {
assert!(stats.slider_max < 1000.0);
}
#[test]
fn fixed_price_bounds_keep_slider_cap() {
let data = vec![400_000.0_f32, 2_500_000.0, 3_750_000.0];
let bounds = make_fixed_bounds(0.0, 2_500_000.0);
let stats = compute_feature_stats(&data, &bounds, false);
assert_eq!(stats.slider_min, 0.0);
assert_eq!(stats.slider_max, 2_500_000.0);
}
#[test]
fn histogram_bin_for_value() {
let hist = Histogram {

View file

@ -412,7 +412,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
name: "Deprivation",
features: &[
Feature::Numeric(FeatureConfig {
name: "Income Score (rate)",
name: "Income Score",
bounds: Bounds::Fixed { min: 0.0, max: 1.0 },
step: 0.01,
description: "Income deprivation rate, inverted (higher = less deprived)",
@ -424,7 +424,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Employment Score (rate)",
name: "Employment Score",
bounds: Bounds::Fixed { min: 0.0, max: 1.0 },
step: 0.01,
description: "Employment deprivation rate, inverted (higher = less deprived)",
@ -451,22 +451,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Living Environment Score",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 0.1,
description: "Quality of the local indoor and outdoor environment (higher = better)",
detail: "From the English Indices of Deprivation (inverted so higher = better). Combines housing quality (condition, central heating) and outdoor environment (air quality, road safety). Higher scores indicate better living environments.",
source: "iod",
prefix: "",
suffix: "",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Indoors Sub-domain Score",
name: "Housing Conditions Score",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
@ -481,7 +466,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Outdoors Sub-domain Score",
name: "Air Quality and Road Safety Score",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,

View file

@ -19,6 +19,7 @@ use std::sync::Arc;
use std::time::Duration;
use anyhow::{bail, Context};
use axum::http::{header, HeaderValue};
use axum::middleware;
use axum::routing::{any, get, patch, post};
use axum::Router;
@ -37,6 +38,67 @@ use tracing_subscriber::EnvFilter;
use state::{AppState, SharedState};
fn is_api_path(path: &str) -> bool {
path.starts_with("/api/")
|| path.starts_with("/pb/")
|| path.starts_with("/s/")
|| matches!(path, "/health" | "/metrics")
}
fn is_fingerprinted_asset(path: &str) -> bool {
let Some(filename) = path.rsplit('/').next() else {
return false;
};
let Some((stem, extension)) = filename.rsplit_once('.') else {
return false;
};
if !matches!(extension, "css" | "js") {
return false;
}
let Some((_, hash)) = stem.rsplit_once('.') else {
return false;
};
hash.len() >= 8 && hash.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn is_static_asset_path(path: &str) -> bool {
path.rsplit('/')
.next()
.is_some_and(|segment| segment.contains('.'))
}
async fn static_cache_headers(
request: axum::extract::Request,
next: middleware::Next,
) -> axum::response::Response {
let path = request.uri().path().to_string();
let mut response = next.run(request).await;
if is_api_path(&path) || response.headers().contains_key(header::CACHE_CONTROL) {
return response;
}
let cache_control = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.filter(|content_type| content_type.contains("text/html"))
.map(|_| HeaderValue::from_static("no-cache, must-revalidate"))
.or_else(|| {
is_fingerprinted_asset(&path)
.then(|| HeaderValue::from_static("public, max-age=31536000, immutable"))
})
.or_else(|| {
is_static_asset_path(&path).then(|| HeaderValue::from_static("public, max-age=3600"))
});
if let Some(value) = cache_control {
response.headers_mut().insert(header::CACHE_CONTROL, value);
}
response
}
#[cfg(target_os = "linux")]
fn resident_memory_kib() -> Option<u64> {
let status = std::fs::read_to_string("/proc/self/status").ok()?;
@ -558,6 +620,7 @@ async fn main() -> anyhow::Result<()> {
}
},
))
.layer(middleware::from_fn(static_cache_headers))
.layer(cors)
.layer(CompressionLayer::new().zstd(true).gzip(true))
.layer(TraceLayer::new_for_http());

View file

@ -19,6 +19,7 @@ use crate::parsing::{
use crate::state::SharedState;
use super::stats;
use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters};
#[derive(Serialize)]
pub struct HistogramStats {
@ -76,6 +77,9 @@ pub struct HexagonStatsParams {
/// shortest travel time for this mode+slug (so it has journey data).
pub journey_mode: Option<String>,
pub journey_slug: Option<String>,
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`.
/// Optional min:max applies as a filter (exclude properties outside range).
pub travel: Option<String>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
pub share: Option<String>,
}
@ -118,6 +122,9 @@ pub async fn get_hexagon_stats(
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
let travel_entries = parse_optional_travel(params.travel.as_deref())
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
// Load travel time data for central_postcode selection (if requested)
let journey_travel_data = match (&params.journey_mode, &params.journey_slug) {
(Some(mode), Some(slug)) if state.travel_time_store.has_destination(mode, slug) => {
@ -134,6 +141,8 @@ pub async fn get_hexagon_stats(
let need_parent = needs_parent(resolution);
let num_features = state.data.num_features;
let feature_data = &state.data.feature_data;
let travel_data = load_travel_data(&state.travel_time_store, &travel_entries)?;
let has_travel = !travel_entries.is_empty();
let (min_lat, min_lon, max_lat, max_lon) = h3_cell_bounds(cell, 0.001);
@ -153,6 +162,12 @@ pub async fn get_hexagon_stats(
num_features,
)
{
if has_travel {
let postcode = state.data.postcode(row);
if !row_passes_travel_filters(postcode, &travel_entries, &travel_data) {
return;
}
}
matching_rows.push(row);
}
});
@ -235,6 +250,7 @@ pub async fn get_hexagon_stats(
total_count,
filters = num_filters,
filters_raw = filters_str.as_deref().unwrap_or("-"),
travel_entries = travel_entries.len(),
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
"GET /api/hexagon-stats"
);

View file

@ -3,24 +3,21 @@ use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::Json;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::consts::MAX_POIS_PER_REQUEST;
use crate::data::{POICategoryGroup, POIData};
use crate::data::POICategoryGroup;
use crate::parsing::require_bounds;
use crate::state::SharedState;
const TUBE_STATION_CATEGORY: &str = "Tube station";
const TUBE_STATION_MERGE_RADIUS_DEGREES: f32 = 0.01;
#[derive(Serialize)]
#[allow(clippy::upper_case_acronyms)]
pub struct POI {
id: String,
name: String,
category: String,
icon_category: String,
group: String,
lat: f32,
lng: f32,
@ -39,167 +36,6 @@ pub struct POIParams {
categories: Option<String>,
}
struct SelectedPOIRow {
row: usize,
id_override: Option<String>,
name_override: Option<String>,
lat: f32,
lng: f32,
lat_sum: f32,
lng_sum: f32,
count: u32,
priority: u32,
}
impl SelectedPOIRow {
fn new(data: &POIData, row: usize, override_identity: bool) -> Self {
Self {
row,
id_override: override_identity.then(|| data.id(row).to_string()),
name_override: override_identity.then(|| data.name[row].clone()),
lat: data.lat[row],
lng: data.lng[row],
lat_sum: data.lat[row],
lng_sum: data.lng[row],
count: 1,
priority: data.priority[row],
}
}
fn merge_tube_station(&mut self, data: &POIData, row: usize) {
self.lat_sum += data.lat[row];
self.lng_sum += data.lng[row];
self.count += 1;
self.lat = self.lat_sum / self.count as f32;
self.lng = self.lng_sum / self.count as f32;
self.priority = self.priority.min(data.priority[row]);
let current_name = self
.name_override
.as_deref()
.unwrap_or(&data.name[self.row]);
let candidate_name = &data.name[row];
if tube_station_name_score(candidate_name) < tube_station_name_score(current_name) {
self.id_override = Some(data.id(row).to_string());
self.name_override = Some(candidate_name.clone());
}
}
fn id(&self, data: &POIData) -> String {
self.id_override
.clone()
.unwrap_or_else(|| data.id(self.row).to_string())
}
fn name(&self, data: &POIData) -> String {
self.name_override
.clone()
.unwrap_or_else(|| data.name[self.row].clone())
}
}
fn dedupe_tube_stations(data: &POIData, rows: Vec<usize>) -> Vec<SelectedPOIRow> {
let mut selected = Vec::with_capacity(rows.len());
let mut tube_groups: FxHashMap<String, Vec<usize>> = FxHashMap::default();
for row in rows {
if data.category.get(row) != TUBE_STATION_CATEGORY {
selected.push(SelectedPOIRow::new(data, row, false));
continue;
}
let station_key = canonical_tube_station_name(&data.name[row]);
if station_key.is_empty() {
selected.push(SelectedPOIRow::new(data, row, false));
continue;
}
let existing = tube_groups.get(&station_key).and_then(|indices| {
indices.iter().copied().find(|&index| {
same_tube_station_area(&selected[index], data.lat[row], data.lng[row])
})
});
if let Some(index) = existing {
selected[index].merge_tube_station(data, row);
} else {
let index = selected.len();
selected.push(SelectedPOIRow::new(data, row, true));
tube_groups.entry(station_key).or_default().push(index);
}
}
selected
}
fn canonical_tube_station_name(name: &str) -> String {
let mut normalized = String::with_capacity(name.len());
let mut paren_depth = 0u32;
for ch in name.chars() {
match ch {
'(' => {
paren_depth += 1;
normalized.push(' ');
}
')' => {
paren_depth = paren_depth.saturating_sub(1);
normalized.push(' ');
}
_ if paren_depth > 0 => {}
'\'' | '' | '`' => {}
'&' => normalized.push_str(" and "),
_ if ch.is_ascii_alphanumeric() => normalized.push(ch.to_ascii_lowercase()),
_ => normalized.push(' '),
}
}
let mut words: Vec<&str> = normalized.split_whitespace().collect();
const SUFFIXES: &[&[&str]] = &[
&["underground", "station"],
&["tube", "station"],
&["dlr", "station"],
&["metro", "station"],
&["tram", "stop"],
&["rail", "station"],
&["railway", "station"],
&["station"],
&["stop"],
];
loop {
let Some(suffix) = SUFFIXES.iter().find(|suffix| words.ends_with(suffix)) else {
break;
};
words.truncate(words.len() - suffix.len());
}
words.join(" ")
}
fn same_tube_station_area(station: &SelectedPOIRow, lat: f32, lng: f32) -> bool {
let dlat = station.lat - lat;
let dlng = (station.lng - lng) * station.lat.to_radians().cos();
(dlat * dlat + dlng * dlng) <= TUBE_STATION_MERGE_RADIUS_DEGREES.powi(2)
}
fn tube_station_name_score(name: &str) -> (u8, usize) {
let lower = name.to_ascii_lowercase();
let suffix_penalty = if lower.ends_with(" underground station")
|| lower.ends_with(" tube station")
|| lower.ends_with(" dlr station")
|| lower.ends_with(" metro station")
|| lower.ends_with(" tram stop")
|| lower.ends_with(" station")
|| lower.ends_with(" stop")
{
1
} else {
0
};
(suffix_penalty, name.len())
}
pub async fn get_pois(
State(shared): State<Arc<SharedState>>,
Query(params): Query<POIParams>,
@ -246,32 +82,30 @@ pub async fn get_pois(
})
.collect();
let mut matching_pois = dedupe_tube_stations(&state.poi_data, matching_rows);
let mut matching_pois = matching_rows;
if matching_pois.len() > MAX_POIS_PER_REQUEST {
let ratio = (matching_pois.len() / MAX_POIS_PER_REQUEST) as u32;
let step = ratio.next_power_of_two();
let mask = step - 1;
matching_pois.retain(|poi| poi.priority & mask == 0);
matching_pois.retain(|&row| state.poi_data.priority[row] & mask == 0);
if matching_pois.len() > MAX_POIS_PER_REQUEST {
matching_pois.sort_unstable_by_key(|poi| poi.priority);
matching_pois.sort_unstable_by_key(|&row| state.poi_data.priority[row]);
matching_pois.truncate(MAX_POIS_PER_REQUEST);
}
}
let pois: Vec<POI> = matching_pois
.iter()
.map(|poi| {
let row = poi.row;
POI {
id: poi.id(&state.poi_data),
name: poi.name(&state.poi_data),
.map(|&row| POI {
id: state.poi_data.id(row).to_string(),
name: state.poi_data.name[row].clone(),
category: state.poi_data.category.get(row).to_string(),
icon_category: state.poi_data.icon_category.get(row).to_string(),
group: state.poi_data.group.get(row).to_string(),
lat: poi.lat,
lng: poi.lng,
lat: state.poi_data.lat[row],
lng: state.poi_data.lng[row],
emoji: state.poi_data.emoji.get(row).to_string(),
}
})
.collect();
@ -313,53 +147,3 @@ pub async fn get_poi_categories(
Json(POICategoriesResponse { groups })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_tube_station_name_strips_transport_suffixes() {
assert_eq!(canonical_tube_station_name("Bank"), "bank");
assert_eq!(
canonical_tube_station_name("Bank Underground Station"),
"bank"
);
assert_eq!(canonical_tube_station_name("Bank DLR Station"), "bank");
assert_eq!(
canonical_tube_station_name("Pleasure Beach (Blackpool Tramway)"),
"pleasure beach"
);
assert_eq!(
canonical_tube_station_name("Earl's Court Tube Station"),
"earls court"
);
}
#[test]
fn same_tube_station_area_keeps_distant_names_separate() {
let station = SelectedPOIRow {
row: 0,
id_override: None,
name_override: None,
lat: 51.5130,
lng: -0.0889,
lat_sum: 51.5130,
lng_sum: -0.0889,
count: 1,
priority: 0,
};
assert!(same_tube_station_area(&station, 51.5132, -0.0885));
assert!(!same_tube_station_area(&station, 55.0140, -1.6781));
}
#[test]
fn tube_station_name_score_prefers_plain_station_names() {
assert!(tube_station_name_score("Bank") < tube_station_name_score("Bank DLR Station"));
assert!(
tube_station_name_score("Acton Town")
< tube_station_name_score("Acton Town Underground Station")
);
}
}

View file

@ -15,11 +15,15 @@ use crate::state::SharedState;
use crate::utils::normalize_postcode;
use super::properties::{HexagonPropertiesResponse, Property};
use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters};
#[derive(Deserialize)]
pub struct PostcodePropertiesParams {
pub postcode: String,
pub filters: Option<String>,
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`.
/// Optional min:max applies as a filter (exclude properties outside range).
pub travel: Option<String>,
pub limit: Option<usize>,
pub offset: Option<usize>,
/// Exact address to rank first when opening properties from address search.
@ -67,6 +71,8 @@ pub async fn get_postcode_properties(
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let filters_str = params.filters;
let travel_entries = parse_optional_travel(params.travel.as_deref())
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let postcode_str = normalized;
let focus_address = params
@ -83,6 +89,8 @@ pub async fn get_postcode_properties(
let feature_names = &state.data.feature_names;
let feature_name_to_index = &state.feature_name_to_index;
let enum_values = &state.data.enum_values;
let travel_data = load_travel_data(&state.travel_time_store, &travel_entries)?;
let has_travel = !travel_entries.is_empty();
let offset_deg: f64 = POSTCODE_SEARCH_OFFSET;
let min_lat = centroid_lat as f64 - offset_deg;
@ -104,6 +112,15 @@ pub async fn get_postcode_properties(
num_features,
)
{
if has_travel
&& !row_passes_travel_filters(
state.data.postcode(row),
&travel_entries,
&travel_data,
)
{
return;
}
matching_rows.push(row);
}
});
@ -154,6 +171,7 @@ pub async fn get_postcode_properties(
offset = page_offset,
filters = num_filters,
filters_raw = filters_str.as_deref().unwrap_or("-"),
travel_entries = travel_entries.len(),
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
"GET /api/postcode-properties"
);

View file

@ -16,6 +16,7 @@ use crate::utils::normalize_postcode;
use super::hexagon_stats::HexagonStatsResponse;
use super::stats;
use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters};
#[derive(Deserialize)]
pub struct PostcodeStatsParams {
@ -24,6 +25,9 @@ pub struct PostcodeStatsParams {
/// Comma-separated feature names to include in stats response.
/// Only listed features are computed; if absent or empty, no features are returned.
pub fields: Option<String>,
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`.
/// Optional min:max applies as a filter (exclude properties outside range).
pub travel: Option<String>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
pub share: Option<String>,
}
@ -71,6 +75,8 @@ pub async fn get_postcode_stats(
let filters_str = params.filters;
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
let travel_entries = parse_optional_travel(params.travel.as_deref())
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let postcode_str = normalized;
@ -78,6 +84,8 @@ pub async fn get_postcode_stats(
let start_time = std::time::Instant::now();
let num_features = state.data.num_features;
let feature_data = &state.data.feature_data;
let travel_data = load_travel_data(&state.travel_time_store, &travel_entries)?;
let has_travel = !travel_entries.is_empty();
// Search around centroid (generous for a postcode)
let offset: f64 = POSTCODE_SEARCH_OFFSET;
@ -101,6 +109,11 @@ pub async fn get_postcode_stats(
num_features,
)
{
if has_travel
&& !row_passes_travel_filters(row_postcode, &travel_entries, &travel_data)
{
return;
}
matching_rows.push(row);
}
});
@ -126,6 +139,7 @@ pub async fn get_postcode_stats(
total_count,
filters = num_filters,
filters_raw = filters_str.as_deref().unwrap_or("-"),
travel_entries = travel_entries.len(),
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
"GET /api/postcode-stats"
);

View file

@ -19,11 +19,16 @@ use crate::parsing::{
};
use crate::state::{AppState, SharedState};
use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters};
#[derive(Deserialize)]
pub struct HexagonPropertiesParams {
pub h3: String,
pub resolution: u8,
pub filters: Option<String>,
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`.
/// Optional min:max applies as a filter (exclude properties outside range).
pub travel: Option<String>,
pub limit: Option<usize>,
pub offset: Option<usize>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
@ -203,6 +208,8 @@ pub async fn get_hexagon_properties(
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let filters_str = params.filters;
let travel_entries = parse_optional_travel(params.travel.as_deref())
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let result = tokio::task::spawn_blocking(move || {
let t0 = std::time::Instant::now();
@ -215,6 +222,8 @@ pub async fn get_hexagon_properties(
let feature_names = &state.data.feature_names;
let feature_name_to_index = &state.feature_name_to_index;
let enum_values = &state.data.enum_values;
let travel_data = load_travel_data(&state.travel_time_store, &travel_entries)?;
let has_travel = !travel_entries.is_empty();
let (min_lat, min_lon, max_lat, max_lon) = h3_cell_bounds(cell, 0.001);
@ -234,6 +243,12 @@ pub async fn get_hexagon_properties(
num_features,
)
{
if has_travel {
let postcode = state.data.postcode(row);
if !row_passes_travel_filters(postcode, &travel_entries, &travel_data) {
return;
}
}
matching_rows.push(row);
}
});
@ -273,6 +288,7 @@ pub async fn get_hexagon_properties(
offset,
filters = num_filters,
filters_raw = filters_str.as_deref().unwrap_or("-"),
travel_entries = travel_entries.len(),
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
"GET /api/hexagon-properties"
);

View file

@ -17,14 +17,13 @@ pub fn extract_price_history(
let year_idx = feature_name_to_index
.get("Date of last transaction")
.copied();
let price_idx = feature_name_to_index.get("Last known price").copied();
match (year_idx, price_idx) {
(Some(yi), Some(pi)) => {
match year_idx {
Some(yi) => {
let mut points: Vec<PricePoint> = matching_rows
.iter()
.filter_map(|&row| {
let year = data.get_feature(row, yi);
let price = data.get_feature(row, pi);
let price = data.last_known_price_raw(row);
if year.is_finite() && price.is_finite() {
Some(PricePoint { year, price })
} else {
@ -46,7 +45,7 @@ pub fn extract_price_history(
}
points
}
_ => Vec::new(),
None => Vec::new(),
}
}

View file

@ -1,3 +1,5 @@
use crate::data::travel_time::{TravelData, TravelTimeStore};
/// Parse the optional `travel` query param, returning an empty Vec when absent or empty.
pub fn parse_optional_travel(travel: Option<&str>) -> Result<Vec<TravelEntry>, String> {
match travel.filter(|val| !val.is_empty()) {
@ -15,6 +17,46 @@ pub struct TravelEntry {
pub filter_max: Option<f32>,
}
pub fn load_travel_data(
store: &TravelTimeStore,
entries: &[TravelEntry],
) -> Result<Vec<TravelData>, String> {
entries
.iter()
.map(|entry| {
store
.get(&entry.mode, &entry.slug)
.map_err(|err| format!("Failed to load travel data: {}", err))
})
.collect()
}
#[inline]
pub fn row_passes_travel_filters(
postcode: &str,
entries: &[TravelEntry],
travel_data: &[TravelData],
) -> bool {
for (index, entry) in entries.iter().enumerate() {
let (Some(fmin), Some(fmax)) = (entry.filter_min, entry.filter_max) else {
continue;
};
let Some(row_data) = travel_data.get(index).and_then(|data| data.get(postcode)) else {
return false;
};
let minutes = if entry.use_best {
row_data.best_minutes.unwrap_or(row_data.minutes)
} else {
row_data.minutes
};
if (minutes as f32) < fmin || (minutes as f32) > fmax {
return false;
}
}
true
}
/// Parse `travel` param into a list of travel entries.
/// Format: `mode:slug` or `mode:slug:best` or `mode:slug:min:max` or `mode:slug:best:min:max`
fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, String> {

View file

@ -28,7 +28,7 @@ AUTH_TTL_HOURS="${AUTH_TTL_HOURS:-24}" # re-auth if auth.json older than this
# the built bundle, so updating this path is what makes the new clip appear
# on the homepage. Override if the dashboard ever moves.
PUBLISH_DIR="${PUBLISH_DIR:-../frontend/public/video}"
# When in the *output* timeline (post-speedup) to grab the poster frame.
# When in the output timeline to grab the poster frame.
# Right-pane inspection (~16s output) is the clearest paused-state preview:
# Manchester map, filters applied, right pane populated, larger narration
# caption visible.

View file

@ -1,4 +1,4 @@
import { chromium, type Browser, type BrowserContext } from 'playwright';
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
import { AUTH_STATE_PATH, CAPTURE_SCALE, OUTPUT_DIR, VIDEO_SIZE, VIEWPORT } from './config.js';
export interface RecordingBrowser {
@ -11,10 +11,15 @@ export async function launchRecordingBrowser(): Promise<RecordingBrowser> {
headless: true,
args: [
'--disable-blink-features=AutomationControlled',
'--enable-gpu',
'--use-gl=angle',
'--use-angle=swiftshader',
'--enable-unsafe-swiftshader',
'--use-angle=gl-egl',
'--ignore-gpu-blocklist',
'--enable-webgl',
'--enable-webgl2',
'--enable-gpu-rasterization',
'--enable-zero-copy',
'--disable-software-rasterizer',
'--disable-frame-rate-limit',
'--disable-gpu-vsync',
'--disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling',
@ -34,6 +39,33 @@ export async function launchRecordingBrowser(): Promise<RecordingBrowser> {
return { browser, context };
}
export async function assertHardwareWebGL(page: Page): Promise<void> {
const info = await page.evaluate(() => {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl');
if (!gl) return { webgl: false, vendor: '', renderer: '' };
const ext = gl.getExtension('WEBGL_debug_renderer_info');
const vendor = String(
ext ? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR)
);
const renderer = String(
ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER)
);
return { webgl: true, vendor, renderer };
});
console.log(`[gpu] WebGL renderer: ${info.webgl ? `${info.vendor} / ${info.renderer}` : 'none'}`);
if (
process.env.ALLOW_SOFTWARE_GL !== '1' &&
(!info.webgl || /SwiftShader|llvmpipe|software/i.test(`${info.vendor} ${info.renderer}`))
) {
throw new Error(
'Recording browser did not get hardware WebGL. Set ALLOW_SOFTWARE_GL=1 to bypass this guard.'
);
}
}
async function suppressDevServerNoise(context: BrowserContext) {
await context.addInitScript(() => {
const RealWS = window.WebSocket;

View file

@ -7,16 +7,15 @@ export const OUTPUT_DIR = 'output';
const aspect = process.env.ASPECT ?? '16x9';
export const VIEWPORT =
aspect === '9x16' ? { width: 1080, height: 1920 } : { width: 1920, height: 1080 };
export const CAPTURE_SCALE = Math.max(1, Number(process.env.CAPTURE_SCALE ?? 1.5));
export const CAPTURE_SCALE = Math.max(1, Number(process.env.CAPTURE_SCALE ?? 1));
export const VIDEO_SIZE = {
width: VIEWPORT.width,
height: VIEWPORT.height,
};
export const WEBM_BITRATE = process.env.WEBM_BITRATE ?? (CAPTURE_SCALE > 1 ? '18M' : '8M');
// Cold-open prompt. Punchy version of the user's intent — short enough that
// the typing animation fits in the AI scene without throttling pushing past
// the trim window. Each char costs ~80ms wall under boot CPU load.
// Cold-open prompt. Punchy version of the user's intent, short enough to type
// on camera without making the opening scene drag.
export const PROMPT_TEXT =
process.env.PROMPT_TEXT ?? 'Flats or terraces <£450k, 35 min to Manchester, low crime';
@ -66,16 +65,11 @@ export const INITIAL_MAP_VIEW = {
zoom: 11.5,
};
// Verification guard only. The renderer no longer uses this as an editing cap:
// Verification guard only. The renderer does not use this as an editing cap:
// if the storyboard needs more than 15 seconds to avoid jumps, keep the frames.
export const MAX_DURATION_S = Number(process.env.MAX_DURATION_S ?? 45);
export const MIN_DURATION_S = Number(process.env.MIN_DURATION_S ?? 10);
// Slow down all interactions while recording, then speed the output back up in
// ffmpeg. A higher scale makes rendering take longer, but gives the 25fps raw
// recorder enough unique frames for a smooth 50fps final without shortcut cuts.
export const RECORD_SCALE = Math.max(1, Number(process.env.RECORD_SCALE ?? 3.5));
// Target fps of the FINAL output.
export const OUTPUT_FPS = Number(process.env.OUTPUT_FPS ?? 50);

View file

@ -21,6 +21,22 @@ interface HexagonSnapshot {
bounds: Bounds;
}
interface PostcodeFeature {
type?: string;
properties: {
postcode: string;
count: number;
centroid: [number, number];
[key: string]: unknown;
};
[key: string]: unknown;
}
interface PostcodeSnapshot {
features: PostcodeFeature[];
bounds: Bounds;
}
export interface HexagonClickTarget {
h3: string;
x: number;
@ -46,6 +62,7 @@ export class DashboardRecorder {
private mapDataVersion = 0;
private selectionStatsVersion = 0;
private lastHexagons: HexagonSnapshot | null = null;
private lastPostcodes: PostcodeSnapshot | null = null;
constructor(private readonly page: Page) {
page.on('request', (request) => {
@ -78,6 +95,9 @@ export class DashboardRecorder {
}
async visibleHexagonTargets(limit = 8): Promise<HexagonClickTarget[]> {
const postcodeTargets = await this.visiblePostcodeTargets(limit);
if (postcodeTargets.length > 0) return postcodeTargets;
const snapshot = this.lastHexagons;
if (!snapshot || snapshot.features.length === 0) {
throw new Error('No recorded hexagon response is available for map clicking');
@ -146,6 +166,11 @@ export class DashboardRecorder {
}
if (kind === 'postcodes') {
const body = await response.json().catch(() => null);
const snapshot = parsePostcodeSnapshot(response.url(), body);
if (snapshot) {
this.lastPostcodes = snapshot;
}
this.mapDataVersion += 1;
return;
}
@ -217,6 +242,56 @@ export class DashboardRecorder {
.catch(() => false);
return !loading && !connecting;
}
private async visiblePostcodeTargets(limit: number): Promise<HexagonClickTarget[]> {
const snapshot = this.lastPostcodes;
if (!snapshot || snapshot.features.length === 0) return [];
const mapBox = await this.page.locator('[data-tutorial="map"]').boundingBox();
if (!mapBox) throw new Error('Map container has no bounding box');
const projected = snapshot.features
.filter((feature) => feature.properties.count > 0)
.map((feature) => {
const [lon, lat] = feature.properties.centroid;
const point = projectFromBounds({ lat, lon }, snapshot.bounds, mapBox);
if (!point) return null;
const centerX = mapBox.x + mapBox.width / 2;
const centerY = mapBox.y + mapBox.height / 2;
const distanceFromCenter = Math.hypot(
(point.x - centerX) / (mapBox.width / 2),
(point.y - centerY) / (mapBox.height / 2)
);
return {
h3: feature.properties.postcode,
x: point.x,
y: point.y,
count: feature.properties.count,
score: feature.properties.count / (1 + distanceFromCenter * 0.25),
};
})
.filter((target): target is HexagonClickTarget & { score: number } => target != null);
const onScreen = projected.filter(
(target) =>
target.x >= mapBox.x &&
target.x <= mapBox.x + mapBox.width &&
target.y >= mapBox.y &&
target.y <= mapBox.y + mapBox.height
);
const clearOfChrome = onScreen.filter(
(target) =>
target.x >= mapBox.x + 80 &&
target.x <= mapBox.x + mapBox.width - 130 &&
target.y >= mapBox.y + 105 &&
target.y <= mapBox.y + mapBox.height - 115
);
const candidates = (clearOfChrome.length > 0 ? clearOfChrome : onScreen).sort(
(a, b) => b.score - a.score
);
return candidates.slice(0, limit).map(({ score: _score, ...target }) => target);
}
}
function classifyApiRequest(rawUrl: string): ApiKind | null {
@ -250,6 +325,18 @@ function parseHexagonSnapshot(rawUrl: string, body: unknown): HexagonSnapshot |
return { features, bounds };
}
function parsePostcodeSnapshot(rawUrl: string, body: unknown): PostcodeSnapshot | null {
if (!isRecord(body) || !Array.isArray(body.features)) return null;
const features = body.features.filter(isPostcodeFeature);
if (features.length === 0) return null;
const url = new URL(rawUrl);
const bounds = parseBounds(url.searchParams.get('bounds'));
if (!bounds) return null;
return { features, bounds };
}
function parseBounds(value: string | null): Bounds | null {
if (!value) return null;
const [south, west, north, east] = value.split(',').map(Number);
@ -271,8 +358,21 @@ function isHexagonFeature(value: unknown): value is HexagonFeature {
);
}
function isPostcodeFeature(value: unknown): value is PostcodeFeature {
if (!isRecord(value) || !isRecord(value.properties)) return false;
const { postcode, count, centroid } = value.properties;
return (
typeof postcode === 'string' &&
typeof count === 'number' &&
Array.isArray(centroid) &&
centroid.length === 2 &&
typeof centroid[0] === 'number' &&
typeof centroid[1] === 'number'
);
}
function projectFromBounds(
feature: HexagonFeature,
feature: { lat: number; lon: number },
bounds: Bounds,
mapBox: { x: number; y: number; width: number; height: number }
): { x: number; y: number } | null {

View file

@ -1,5 +1,4 @@
import type { Page } from 'playwright';
import { RECORD_SCALE } from './config.js';
/**
* Inject a visible cursor that mirrors the real mouse position. The browser's
@ -373,7 +372,7 @@ export async function zoomTo(
opts: { scale: number; focusX: number; focusY: number; durationMs?: number }
): Promise<void> {
const { scale, focusX, focusY, durationMs = 1100 } = opts;
const transitionMs = Math.round(durationMs * RECORD_SCALE);
const transitionMs = Math.round(durationMs);
const viewport = page.viewportSize() ?? { width: 1920, height: 1080 };
const dx = viewport.width / 2 - scale * focusX;
const dy = viewport.height / 2 - scale * focusY;
@ -390,7 +389,7 @@ export async function zoomTo(
/** Animate the wrapper back to identity transform. */
export async function zoomReset(page: Page, durationMs = 1100): Promise<void> {
const transitionMs = Math.round(durationMs * RECORD_SCALE);
const transitionMs = Math.round(durationMs);
await page.evaluate((transitionMs) => {
const wrap = document.getElementById('__demo-zoom-wrap');
if (!wrap) return;

View file

@ -1,12 +1,7 @@
import type { Page } from 'playwright';
import { RECORD_SCALE } from './config.js';
// All timing primitives multiply by RECORD_SCALE. Scenes call them with
// "human-time" durations; the actual wall-clock pause is N× longer so the
// renderer has more time per visual frame. ffmpeg later speeds the output
// back up, so the *visible* animation speed in the final video is unchanged.
export const sleep = (ms: number) =>
new Promise<void>((r) => setTimeout(r, ms * RECORD_SCALE));
new Promise<void>((r) => setTimeout(r, ms));
// Cubic ease-in-out: slow start and end, fast middle. Reads as "natural" motion.
export const easeInOut = (t: number): number =>
@ -16,28 +11,18 @@ interface MoveOptions {
durationMs?: number;
ease?: (t: number) => number;
realMouse?: boolean;
/**
* Override the per-step CDP cost used to size the loop. During a drag, every
* mouse.move fires a pointermove -> React re-render -> thumb position update
* on the same thread, pushing effective per-step cost higher. Pass that for drags
* so the loop's wall duration matches `durationMs * RECORD_SCALE`.
*/
stepBudgetMs?: number;
}
// Empirical Playwright-to-Chromium CDP roundtrip cost for a mouse.move command
// while recording the software-GL dashboard.
const CDP_MOVE_MS = 70;
const RAW_RECORDING_FRAME_MS = 40;
/**
* Move the real mouse from its current position to (x, y) along an eased path.
* The injected cursor follows via its mousemove listener.
*
* Why no explicit sleep between steps: each `await page.mouse.move(...)` is a
* synchronous WebSocket round-trip to Chromium. Adding a setTimeout on top
* means the loop runs at `cdp_latency + sleepMs`, overshooting wallDuration
* by ~3×. We instead size `steps = wallDuration / CDP_MOVE_MS` so the loop's
* natural pace lands on the target wall duration.
* Real mouse moves are paced by wall-clock deadlines instead of CDP latency.
* The old version relied on slow software WebGL making each mouse.move call
* expensive; with hardware GPU those calls return quickly and animations
* collapse into a burst unless we explicitly pace them.
*/
export async function smoothMove(
page: Page,
@ -47,10 +32,9 @@ export async function smoothMove(
durationMs = 600,
ease = easeInOut,
realMouse = false,
stepBudgetMs = CDP_MOVE_MS,
}: MoveOptions = {}
): Promise<void> {
const wallDuration = durationMs * RECORD_SCALE;
const wallDuration = durationMs;
if (!realMouse) {
const animated = await page.evaluate(
({ x, y, wallDuration }) => {
@ -72,20 +56,24 @@ export async function smoothMove(
}
}
const steps = Math.max(2, Math.min(96, Math.round(wallDuration / stepBudgetMs)));
const steps = Math.max(2, Math.min(160, Math.round(wallDuration / RAW_RECORDING_FRAME_MS)));
const start = Date.now();
for (let i = 1; i <= steps; i++) {
const t = ease(i / steps);
const x = from.x + (to.x - from.x) * t;
const y = from.y + (to.y - from.y) * t;
await page.mouse.move(x, y);
const targetElapsed = (wallDuration * i) / steps;
const waitMs = start + targetElapsed - Date.now();
if (waitMs > 0) await new Promise((resolve) => setTimeout(resolve, waitMs));
}
}
/**
* "Fake" type: progressively set the textarea value, dispatching
* React-compatible input events. This is Node-driven instead of browser
* setInterval-driven because 4K software WebGL can starve page timers and
* stretch a two-second typing beat into a minute.
* React-compatible input events. This stays Node-driven so typing cadence is
* stable even when the map is busy rendering.
*/
export async function fakeType(
page: Page,
@ -93,7 +81,6 @@ export async function fakeType(
text: string,
delayMs: number
): Promise<void> {
const delay = delayMs * RECORD_SCALE;
const steps = text.length;
for (let i = 1; i <= steps; i++) {
const end = Math.ceil((text.length * i) / steps);
@ -112,8 +99,26 @@ export async function fakeType(
},
{ selector, value: text.slice(0, end) }
);
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
if (delayMs > 0 && i < steps) {
await new Promise((resolve) =>
setTimeout(resolve, humanTypingDelay(text[i - 1], text[i], i, delayMs))
);
}
}
}
function humanTypingDelay(
char: string,
nextChar: string | undefined,
index: number,
baseDelayMs: number
): number {
const cadence = [0.82, 1.08, 0.94, 1.22, 0.88, 1.14, 0.98, 1.28];
let delay = baseDelayMs * cadence[index % cadence.length];
if (char === ' ') delay += baseDelayMs * 0.9;
if (/[,.!?;:]/.test(char)) delay += baseDelayMs * 1.8;
if (nextChar === ' ' && index % 4 === 0) delay += baseDelayMs * 0.55;
return Math.round(delay);
}
/**
@ -136,7 +141,6 @@ export async function smoothDragSliderThumb(
const thumbCy = thumbBox.y + thumbBox.height / 2;
const targetX = trackBox.x + trackBox.width * toFraction;
// smoothMove already applies RECORD_SCALE internally; pass human-time durations.
await smoothMove(page, fromCursor, { x: thumbCx, y: thumbCy }, { durationMs: 220 });
await page.mouse.down();
// The user explicitly prefers a longer render over stepped motion, so use
@ -145,7 +149,7 @@ export async function smoothDragSliderThumb(
page,
{ x: thumbCx, y: thumbCy },
{ x: targetX, y: thumbCy },
{ durationMs, realMouse: true, stepBudgetMs: 135 }
{ durationMs, realMouse: true }
);
await page.mouse.up();
return { x: targetX, y: thumbCy };

View file

@ -1,7 +1,7 @@
import { existsSync, mkdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { AUTH_STATE_PATH, OUTPUT_DIR } from './config.js';
import { launchRecordingBrowser } from './browser.js';
import { assertHardwareWebGL, launchRecordingBrowser } from './browser.js';
import { installDemoRoutes } from './routes.js';
import { prepareTimeline, runTimeline } from './timeline.js';
import { trimRecording } from './video.js';
@ -15,6 +15,7 @@ async function main() {
const { browser, context } = await launchRecordingBrowser();
const page = await context.newPage();
await assertHardwareWebGL(page);
const recordedVideo = page.video();
const recordStartMs = Date.now();

View file

@ -28,6 +28,14 @@ export interface SceneCtx {
cursor: { x: number; y: number };
}
const AI_CLOSEUP_ZOOM_MS = 1400;
const RESULTS_ZOOM_OUT_MS = 1500;
const EXPORT_ZOOM_OUT_MS = 1100;
const PROMPT_TYPING_DELAY_MS = 64;
const MAP_ZOOM_WHEEL_STEPS = 18;
const MAP_ZOOM_WHEEL_DELTA = -120;
const MAP_ZOOM_WHEEL_PAUSE_MS = 70;
/**
* Scene 1: start wide, then zoom into the AI prompt. The AI response is
* stubbed, while the map filters and right pane are loaded from the real app.
@ -39,10 +47,15 @@ export async function sceneAiCloseUp(ctx: SceneCtx): Promise<void> {
await showCaption(page, 'Brief: flats or terraces under £450k near central Manchester.');
await sleep(180);
await zoomToAiBox(page, 720);
await sleep(760);
await zoomToAiBox(page, AI_CLOSEUP_ZOOM_MS);
await sleep(AI_CLOSEUP_ZOOM_MS + 160);
await fakeType(page, '[data-tutorial="ai-filters"] textarea', PROMPT_TEXT, 18);
await fakeType(
page,
'[data-tutorial="ai-filters"] textarea',
PROMPT_TEXT,
PROMPT_TYPING_DELAY_MS
);
await sleep(160);
const aiResponse = page
.waitForResponse(
@ -71,17 +84,16 @@ export async function sceneAiCloseUp(ctx: SceneCtx): Promise<void> {
export async function sceneZoomOutResults(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await showCaption(page, 'Now the view lands on central Manchester with the filters already set.');
await zoomReset(page, 860);
await sleep(980);
await zoomReset(page, RESULTS_ZOOM_OUT_MS);
await sleep(RESULTS_ZOOM_OUT_MS + 160);
await hideCaption(page);
await sleep(180);
}
/**
* Scene 3: drag the right thumb of the AI-applied travel-time slider from
* 35 to 20 minutes. The slider has step=1 over 0120, so the 15-minute
* range crosses 15 step boundaries at our pace each one gets ~20+ recorded
* frames, so the thumb reads as a continuous slide rather than incremental.
* 35 to 20 minutes. The slider has step=1 over 0120, so the drag is paced
* with real pointer updates instead of jumping the value directly.
*
* The card we drag (`tt_0`) only exists because the AI filter step inserted
* exactly one travel-time entry; if you change the AI stub's count, update
@ -135,17 +147,18 @@ export async function sceneClusterClick(ctx: SceneCtx): Promise<void> {
await showCaption(page, 'Zoom into the result clusters, then click a promising hexagon.');
const cluster = {
const defaultCluster = {
x: 360 + (viewport.width - 360) * 0.35,
y: viewport.height * 0.52,
};
const cluster = await pickMapZoomTarget(ctx, defaultCluster);
await smoothMove(page, ctx.cursor, cluster, { durationMs: 520 });
ctx.cursor = cluster;
await sleep(220);
await zoomMapWithWheel(ctx, cluster);
ctx.cursor = await clickVisibleHexagon(ctx);
ctx.cursor = await clickVisibleHexagon(ctx, cluster);
await sleep(360);
await showCaption(
page,
@ -155,24 +168,43 @@ export async function sceneClusterClick(ctx: SceneCtx): Promise<void> {
await hideCaption(page);
}
async function pickMapZoomTarget(
ctx: SceneCtx,
fallback: { x: number; y: number }
): Promise<{ x: number; y: number }> {
const [target] = await ctx.dashboard.visibleHexagonTargets(1).catch(() => []);
return target ? { x: target.x, y: target.y } : fallback;
}
async function zoomMapWithWheel(ctx: SceneCtx, target: { x: number; y: number }): Promise<void> {
const { page, dashboard } = ctx;
const mapVersion = dashboard.getMapDataVersion();
await page.mouse.move(target.x, target.y);
for (let i = 0; i < 5; i++) {
await page.mouse.wheel(0, -120);
await sleep(95);
for (let i = 0; i < MAP_ZOOM_WHEEL_STEPS; i++) {
await page.mouse.wheel(0, MAP_ZOOM_WHEEL_DELTA);
await sleep(MAP_ZOOM_WHEEL_PAUSE_MS);
}
await dashboard.waitForMapSettled(mapVersion, 16000);
await sleep(260);
}
async function clickVisibleHexagon(ctx: SceneCtx): Promise<{ x: number; y: number }> {
const candidates = await ctx.dashboard.visibleHexagonTargets(8);
async function clickVisibleHexagon(
ctx: SceneCtx,
fallbackTarget: { x: number; y: number }
): Promise<{ x: number; y: number }> {
const candidates = await ctx.dashboard.visibleHexagonTargets(8).catch((error) => {
console.log(
`[scene] Falling back to direct map click targets: ${
error instanceof Error ? error.message : String(error)
}`
);
return [];
});
const clickTargets = await addFallbackClickTargets(ctx, candidates, fallbackTarget);
const startedAt = ctx.dashboard.getSelectionStatsVersion();
let lastError: Error | null = null;
for (const target of candidates) {
for (const target of clickTargets) {
await moveAndClickHexagon(ctx, target);
try {
await ctx.dashboard.waitForSelectionReady(startedAt, 7000);
@ -192,6 +224,39 @@ async function clickVisibleHexagon(ctx: SceneCtx): Promise<{ x: number; y: numbe
);
}
async function addFallbackClickTargets(
ctx: SceneCtx,
candidates: HexagonClickTarget[],
fallbackTarget: { x: number; y: number }
): Promise<HexagonClickTarget[]> {
const mapBox = await ctx.page.locator('[data-tutorial="map"]').boundingBox();
const fallbacks: HexagonClickTarget[] = [
{
h3: 'direct-target',
x: fallbackTarget.x,
y: fallbackTarget.y,
count: 1,
},
];
if (mapBox) {
fallbacks.push({
h3: 'map-center',
x: mapBox.x + mapBox.width / 2,
y: mapBox.y + mapBox.height / 2,
count: 1,
});
}
const seen = new Set<string>();
return [...candidates, ...fallbacks].filter((target) => {
const key = `${Math.round(target.x / 12)},${Math.round(target.y / 12)}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
async function moveAndClickHexagon(ctx: SceneCtx, target: HexagonClickTarget): Promise<void> {
await smoothMove(ctx.page, ctx.cursor, { x: target.x, y: target.y }, { durationMs: 420 });
ctx.cursor = { x: target.x, y: target.y };
@ -204,8 +269,8 @@ export async function sceneExportAndOutro(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await showCaption(page, 'When the shortlist feels right, export the exact filtered view.');
await zoomReset(page, 680);
await sleep(520);
await zoomReset(page, EXPORT_ZOOM_OUT_MS);
await sleep(EXPORT_ZOOM_OUT_MS + 120);
const exportButton = page.locator('button[title="Export to Excel"]').first();
await exportButton.waitFor({ state: 'visible', timeout: 4000 });

View file

@ -1,12 +1,6 @@
import { execSync } from 'node:child_process';
import { renameSync, statSync } from 'node:fs';
import {
MAX_DURATION_S,
OUTPUT_FPS,
RECORD_SCALE,
VIDEO_SIZE,
WEBM_BITRATE,
} from './config.js';
import { MAX_DURATION_S, OUTPUT_FPS, VIDEO_SIZE, WEBM_BITRATE } from './config.js';
const LEAD_IN_S = 0.12;
@ -18,11 +12,11 @@ export function trimRecording(
const sceneSpan = (times.sceneEndMs - times.sceneStartMs) / 1000;
const trimStart = Math.max(
0,
(times.sceneStartMs - times.recordStartMs) / 1000 - LEAD_IN_S * RECORD_SCALE
(times.sceneStartMs - times.recordStartMs) / 1000 - LEAD_IN_S
);
const trimEnd = (times.sceneEndMs - times.recordStartMs) / 1000;
const wallDuration = trimEnd - trimStart;
const finalDuration = wallDuration / RECORD_SCALE;
const finalDuration = wallDuration;
if (finalDuration > MAX_DURATION_S) {
console.log(
@ -32,12 +26,11 @@ export function trimRecording(
const filter =
`trim=start=${trimStart.toFixed(3)}:duration=${wallDuration.toFixed(3)},` +
`setpts=(PTS-STARTPTS)/${RECORD_SCALE},fps=${OUTPUT_FPS},` +
`setpts=PTS-STARTPTS,fps=${OUTPUT_FPS},` +
`trim=duration=${finalDuration.toFixed(3)},setpts=PTS-STARTPTS`;
// Keep trimming inside the filter graph: it is frame-accurate for WebM
// without the keyframe leakage of input seeking or the post-speedup timing
// ambiguity of output seeking.
// without the keyframe leakage of input seeking.
execSync(
`ffmpeg -y -i "${rawPath}" -vf "${filter}" ` +
`-fps_mode cfr -r ${OUTPUT_FPS} -c:v libvpx -b:v ${WEBM_BITRATE} -deadline good -cpu-used 5 ` +
@ -53,6 +46,6 @@ export function trimRecording(
}
console.log(
`Wrote ${trimmedPath} (${finalDuration.toFixed(2)}s, scene=${(sceneSpan / RECORD_SCALE).toFixed(2)}s, scale=${RECORD_SCALE}, capture=${VIDEO_SIZE.width}x${VIDEO_SIZE.height})`
`Wrote ${trimmedPath} (${finalDuration.toFixed(2)}s, scene=${sceneSpan.toFixed(2)}s, capture=${VIDEO_SIZE.width}x${VIDEO_SIZE.height})`
);
}