Lots of improvements
Some checks failed
CI / Python (lint + test) (push) Failing after 1m39s
CI / Frontend (lint + typecheck) (push) Failing after 1m49s
CI / Rust (lint + test) (push) Failing after 1m50s
Build and publish Docker image / build-and-push (push) Failing after 3m9s

This commit is contained in:
Andras Schmelczer 2026-04-04 10:45:48 +01:00
parent 3853b5dce7
commit b94cf17d75
33 changed files with 2587 additions and 1866 deletions

View file

@ -139,8 +139,9 @@ export default function App() {
window.history.replaceState({}, '', newUrl); window.history.replaceState({}, '', newUrl);
trackEvent('Purchase'); trackEvent('Purchase');
setShowLicenseSuccess(true); setShowLicenseSuccess(true);
refreshAuth();
} }
// Always refresh auth on startup to pick up server-side subscription changes
refreshAuth().catch(() => {});
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
const savedSearches = useSavedSearches(user?.id ?? null); const savedSearches = useSavedSearches(user?.id ?? null);
@ -419,6 +420,8 @@ export default function App() {
isPropertySaved={user ? savedProperties.isPropertySaved : undefined} isPropertySaved={user ? savedProperties.isPropertySaved : undefined}
getSavedPropertyId={user ? savedProperties.getSavedPropertyId : undefined} getSavedPropertyId={user ? savedProperties.getSavedPropertyId : undefined}
deferTutorial={showLicenseSuccess} deferTutorial={showLicenseSuccess}
onSaveSearch={user ? savedSearches.saveSearch : undefined}
savingSearch={savedSearches.saving}
/> />
)} )}
{showAuthModal && ( {showAuthModal && (

View file

@ -58,7 +58,7 @@ export default function HomePage({
}, []); }, []);
return ( return (
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative"> <div className="flex-1 overflow-y-auto overflow-x-hidden bg-warm-50 dark:bg-navy-950 relative">
<div className="relative" style={{ zIndex: 1 }}> <div className="relative" style={{ zIndex: 1 }}>
{/* Hero */} {/* 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"> <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">
@ -78,7 +78,7 @@ export default function HomePage({
<p className="text-lg text-warm-400 mb-8 max-w-xl"> <p className="text-lg text-warm-400 mb-8 max-w-xl">
{t('home.heroDescription')} {t('home.heroDescription')}
</p> </p>
<div className="flex items-center gap-4 mb-10"> <div className="flex flex-wrap items-center gap-4 mb-10">
<button <button
onClick={() => { onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' }); trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
@ -118,7 +118,7 @@ export default function HomePage({
{t('home.seeTheDifference')} {t('home.seeTheDifference')}
</button> </button>
</div> </div>
<div className="flex gap-12 pt-3 border-t border-white/10"> <div className="flex flex-wrap gap-x-12 gap-y-4 pt-3 border-t border-white/10">
<div> <div>
<div className="text-2xl md:text-3xl font-bold text-white"> <div className="text-2xl md:text-3xl font-bold text-white">
<TickerValue text="13M" active={statsActive} /> <TickerValue text="13M" active={statsActive} />

View file

@ -2,7 +2,7 @@ import { Fragment, memo, useState, useMemo, useRef, useCallback, useEffect } fro
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ts } from '../../i18n/server'; import { ts } from '../../i18n/server';
import { Slider } from '../ui/Slider'; import { Slider } from '../ui/Slider';
import { ChevronIcon, LightbulbIcon } from '../ui/icons'; import { ChevronIcon, CloseIcon, LightbulbIcon, SpinnerIcon } from '../ui/icons';
import { PillToggle } from '../ui/PillToggle'; import { PillToggle } from '../ui/PillToggle';
import { PillGroup } from '../ui/PillGroup'; import { PillGroup } from '../ui/PillGroup';
@ -203,6 +203,9 @@ interface FiltersProps {
onUpgradeClick?: () => void; onUpgradeClick?: () => void;
onResetTutorial?: () => void; onResetTutorial?: () => void;
filterImpacts?: Record<string, number>; filterImpacts?: Record<string, number>;
onClearAll: () => void;
onSaveSearch?: (name: string) => Promise<void>;
savingSearch?: boolean;
} }
export default memo(function Filters({ export default memo(function Filters({
@ -242,6 +245,9 @@ export default memo(function Filters({
onUpgradeClick, onUpgradeClick,
onResetTutorial, onResetTutorial,
filterImpacts, filterImpacts,
onClearAll,
onSaveSearch,
savingSearch,
}: FiltersProps) { }: FiltersProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const modeRestrictions = useMemo(() => { const modeRestrictions = useMemo(() => {
@ -416,6 +422,50 @@ export default memo(function Filters({
const badgeCount = enabledFeatureList.length + activeEntryCount; const badgeCount = enabledFeatureList.length + activeEntryCount;
const [showClearPopup, setShowClearPopup] = useState(false);
const [clearSaveName, setClearSaveName] = useState('');
const [clearSaveError, setClearSaveError] = useState<string | null>(null);
const handleClearAllClick = useCallback(() => {
if (badgeCount === 0) return;
if (onSaveSearch) {
setShowClearPopup(true);
setClearSaveName('');
setClearSaveError(null);
} else {
onClearAll();
}
}, [badgeCount, onSaveSearch, onClearAll]);
const handleSaveAndClear = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!clearSaveName.trim() || savingSearch) return;
try {
await onSaveSearch!(clearSaveName.trim());
setShowClearPopup(false);
onClearAll();
} catch {
setClearSaveError(t('saveSearch.saving'));
}
},
[clearSaveName, savingSearch, onSaveSearch, onClearAll, t]
);
const handleClearWithoutSaving = useCallback(() => {
setShowClearPopup(false);
onClearAll();
}, [onClearAll]);
useEffect(() => {
if (!showClearPopup) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setShowClearPopup(false);
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [showClearPopup]);
return ( return (
<div <div
ref={containerRef} ref={containerRef}
@ -424,8 +474,7 @@ export default memo(function Filters({
<div <div
className="flex flex-col min-h-0" className="flex flex-col min-h-0"
style={{ style={{
flexGrow: activeFilterCollapsed ? 0 : addFilterCollapsed ? 1 : 3, flex: activeFilterCollapsed ? '0 0 auto' : addFilterCollapsed ? '1 1 0' : '3 1 0',
flexShrink: activeFilterCollapsed ? 0 : 1,
}} }}
> >
<button <button
@ -442,10 +491,31 @@ export default memo(function Filters({
</span> </span>
)} )}
</div> </div>
<ChevronIcon <div className="flex items-center gap-2">
direction={activeFilterCollapsed ? 'down' : 'up'} {badgeCount > 0 && (
className="w-4 h-4 text-warm-400 dark:text-warm-500" <span
/> role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
handleClearAllClick();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
handleClearAllClick();
}
}}
className="text-xs text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-200 underline"
>
{t('filters.clearAll')}
</span>
)}
<ChevronIcon
direction={activeFilterCollapsed ? 'down' : 'up'}
className="w-4 h-4 text-warm-400 dark:text-warm-500"
/>
</div>
</button> </button>
{!activeFilterCollapsed && <div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden"> {!activeFilterCollapsed && <div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
@ -521,6 +591,7 @@ export default memo(function Filters({
onDragEnd={() => onTravelTimeDragEnd(index)} onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)} onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)} onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
/> />
</div> </div>
))} ))}
@ -618,6 +689,7 @@ export default memo(function Filters({
onDragEnd={() => onTravelTimeDragEnd(index)} onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)} onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)} onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
/> />
</div> </div>
))} ))}
@ -625,7 +697,7 @@ export default memo(function Filters({
data-filter-name={feature.name} data-filter-name={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${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' : ''}`} className={`space-y-0.5 px-2 py-1.5 rounded ${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="flex items-center justify-between gap-1"> <div className="relative z-10 flex items-center justify-between gap-1">
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" hideIconOnMobile /> <FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" hideIconOnMobile />
<FeatureActions <FeatureActions
feature={feature} feature={feature}
@ -705,6 +777,7 @@ export default memo(function Filters({
onDragEnd={() => onTravelTimeDragEnd(index)} onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)} onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)} onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
/> />
</div> </div>
))} ))}
@ -715,8 +788,7 @@ export default memo(function Filters({
<div <div
className="flex flex-col min-h-0 border-t border-warm-200 dark:border-warm-700" className="flex flex-col min-h-0 border-t border-warm-200 dark:border-warm-700"
style={{ style={{
flexGrow: addFilterCollapsed ? 0 : activeFilterCollapsed ? 1 : 2, flex: addFilterCollapsed ? '0 0 auto' : activeFilterCollapsed ? '1 1 0' : '2 1 0',
flexShrink: addFilterCollapsed ? 0 : 1,
}} }}
> >
<button <button
@ -730,7 +802,7 @@ export default memo(function Filters({
/> />
</button> </button>
{!addFilterCollapsed && ( {!addFilterCollapsed && (
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col"> <div className="flex-1 min-h-0 overflow-y-auto">
<FeatureBrowser <FeatureBrowser
availableFeatures={availableFeatures} availableFeatures={availableFeatures}
allFeatures={features} allFeatures={features}
@ -826,6 +898,63 @@ export default memo(function Filters({
onNavigateToSource={onNavigateToSource} onNavigateToSource={onNavigateToSource}
/> />
)} )}
{showClearPopup && (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={() => setShowClearPopup(false)}>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">
{t('filters.clearAllTitle')}
</h2>
<button
onClick={() => setShowClearPopup(false)}
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
>
<CloseIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSaveAndClear} className="p-5 pt-2 space-y-4">
<p className="text-sm text-warm-600 dark:text-warm-400">
{t('filters.clearAllSavePrompt')}
</p>
<div>
<input
type="text"
value={clearSaveName}
onChange={(e) => setClearSaveName(e.target.value)}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder={t('saveSearch.namePlaceholder')}
autoFocus
/>
</div>
{clearSaveError && (
<p className="text-sm text-red-600 dark:text-red-300">{clearSaveError}</p>
)}
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={handleClearWithoutSaving}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
{t('filters.clearWithoutSaving')}
</button>
<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"
>
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{savingSearch ? t('saveSearch.saving') : t('filters.saveAndClear')}
</button>
</div>
</form>
</div>
</div>
)}
</div> </div>
); );
}); });

View file

@ -1,9 +1,11 @@
import { useState, useCallback, useRef, useEffect } from 'react'; import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { PostcodeGeometry } from '../../types'; import type { PostcodeGeometry } from '../../types';
import { authHeaders } from '../../lib/api'; import { authHeaders } from '../../lib/api';
import { useIsMobile } from '../../hooks/useIsMobile'; import { useIsMobile } from '../../hooks/useIsMobile';
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch'; import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
import { PlaceSearchInput } from '../ui/PlaceSearchInput'; import { PlaceSearchInput } from '../ui/PlaceSearchInput';
import { LocateIcon } from '../ui/icons/LocateIcon';
import { SearchIcon } from '../ui/icons/SearchIcon'; import { SearchIcon } from '../ui/icons/SearchIcon';
export interface SearchedLocation { export interface SearchedLocation {
@ -14,6 +16,7 @@ export interface SearchedLocation {
const ZOOM_FOR_TYPE: Record<string, number> = { const ZOOM_FOR_TYPE: Record<string, number> = {
city: 10, city: 10,
borough: 12, borough: 12,
outcode: 14,
town: 13, town: 13,
suburb: 14, suburb: 14,
quarter: 14, quarter: 14,
@ -35,6 +38,7 @@ export default function LocationSearch({
onLocationSearched?: (postcode: SearchedLocation | null) => void; onLocationSearched?: (postcode: SearchedLocation | null) => void;
onMouseEnter?: () => void; onMouseEnter?: () => void;
}) { }) {
const { t } = useTranslation();
const search = useLocationSearch(); const search = useLocationSearch();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -80,7 +84,7 @@ export default function LocationSearch({
try { try {
const res = await fetch(`/api/postcode/${encodeURIComponent(result.label)}`, authHeaders()); const res = await fetch(`/api/postcode/${encodeURIComponent(result.label)}`, authHeaders());
if (!res.ok) { if (!res.ok) {
setError('Postcode not found'); setError(t('locationSearch.postcodeNotFound'));
return; return;
} }
const json: { const json: {
@ -94,7 +98,7 @@ export default function LocationSearch({
search.clear(); search.clear();
if (isMobile) setExpanded(false); if (isMobile) setExpanded(false);
} catch { } catch {
setError('Lookup failed'); setError(t('locationSearch.lookupFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -102,17 +106,71 @@ export default function LocationSearch({
[onFlyTo, onLocationSearched, isMobile, search] [onFlyTo, onLocationSearched, isMobile, search]
); );
// Mobile collapsed state: just a search icon button const [locating, setLocating] = useState(false);
const locateUser = useCallback(async () => {
if (!navigator.geolocation) {
setError(t('locationSearch.geolocationUnsupported'));
return;
}
setError(null);
setLocating(true);
search.close();
try {
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
timeout: 10000,
});
});
const { latitude, longitude } = position.coords;
const res = await fetch(
`/api/nearest-postcode?lat=${latitude}&lng=${longitude}`,
authHeaders()
);
if (!res.ok) {
setError(t('locationSearch.lookupFailed'));
return;
}
const json: {
postcode: string;
latitude: number;
longitude: number;
geometry: PostcodeGeometry;
} = await res.json();
onFlyTo(json.latitude, json.longitude, 16);
onLocationSearched?.({ postcode: json.postcode, geometry: json.geometry });
search.clear();
if (isMobile) setExpanded(false);
} catch {
setError(t('locationSearch.geolocationFailed'));
} finally {
setLocating(false);
}
}, [onFlyTo, onLocationSearched, isMobile, search, t]);
// Mobile collapsed state: search icon + locate button
if (isMobile && !expanded) { if (isMobile && !expanded) {
return ( return (
<button <div className="flex gap-2 pointer-events-auto">
type="button" <button
onClick={() => setExpanded(true)} type="button"
className="p-2 bg-white dark:bg-warm-800 rounded shadow-lg pointer-events-auto" onClick={() => setExpanded(true)}
aria-label="Search places or postcodes" className="p-2 bg-white dark:bg-warm-800 rounded shadow-lg"
> aria-label={t('locationSearch.searchLabel')}
<SearchIcon className="w-5 h-5 text-warm-600 dark:text-warm-300" /> >
</button> <SearchIcon className="w-5 h-5 text-warm-600 dark:text-warm-300" />
</button>
<button
type="button"
onClick={locateUser}
disabled={locating}
className="p-2 bg-white dark:bg-warm-800 rounded shadow-lg text-warm-600 dark:text-warm-300 hover:text-teal-600 dark:hover:text-teal-400 disabled:opacity-50"
aria-label={t('locationSearch.locateMe')}
>
<LocateIcon className={`w-5 h-5 ${locating ? 'animate-pulse' : ''}`} />
</button>
</div>
); );
} }
@ -129,12 +187,22 @@ export default function LocationSearch({
search={search} search={search}
onSelect={selectResult} onSelect={selectResult}
loading={loading} loading={loading}
placeholder="Search places or postcodes..." placeholder={t('locationSearch.placeholder')}
size="sm" size="sm"
inputClassName="px-2 py-2 text-sm w-56 border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500" inputClassName="px-2 py-2 text-sm w-56 border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500"
inputRef={inputRef} inputRef={inputRef}
onInputChange={() => setError(null)} onInputChange={() => setError(null)}
/> />
<button
type="button"
onClick={locateUser}
disabled={locating}
className="p-2 mr-0.5 rounded hover:bg-warm-100 dark:hover:bg-warm-700 text-warm-400 dark:text-warm-500 hover:text-teal-600 dark:hover:text-teal-400 disabled:opacity-50"
aria-label={t('locationSearch.locateMe')}
title={t('locationSearch.locateMe')}
>
<LocateIcon className={`w-4 h-4 ${locating ? 'animate-pulse' : ''}`} />
</button>
</div> </div>
{error && ( {error && (

View file

@ -30,6 +30,7 @@ import { CloseIcon } from '../ui/icons/CloseIcon';
import type { FeatureFilters } from '../../types'; import type { FeatureFilters } from '../../types';
import { useDeckLayers } from '../../hooks/useDeckLayers'; import { useDeckLayers } from '../../hooks/useDeckLayers';
import { useTranslatedModes, type TravelTimeEntry } from '../../hooks/useTravelTime'; import { useTranslatedModes, type TravelTimeEntry } from '../../hooks/useTravelTime';
import { ts } from '../../i18n/server';
interface MapProps { interface MapProps {
data: HexagonData[]; data: HexagonData[];
@ -59,6 +60,7 @@ interface MapProps {
hideLegend?: boolean; hideLegend?: boolean;
travelTimeEntries?: TravelTimeEntry[]; travelTimeEntries?: TravelTimeEntry[];
densityLabel?: string; densityLabel?: string;
totalCount?: number;
} }
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = []; const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
@ -115,11 +117,13 @@ export default memo(function Map({
bounds: viewportBounds, bounds: viewportBounds,
hideLegend = false, hideLegend = false,
travelTimeEntries = EMPTY_TRAVEL_ENTRIES, travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
densityLabel = 'Number of properties', densityLabel: densityLabelProp,
totalCount: totalCountProp,
}: MapProps) { }: MapProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation(); const { t } = useTranslation();
const modes = useTranslatedModes(); const modes = useTranslatedModes();
const densityLabel = densityLabelProp ?? t('mapLegend.numberOfProperties');
const [internalViewState, setInternalViewState] = useState<ViewState>( const [internalViewState, setInternalViewState] = useState<ViewState>(
initialViewState || INITIAL_VIEW_STATE initialViewState || INITIAL_VIEW_STATE
); );
@ -288,8 +292,8 @@ export default memo(function Map({
<MapLegend <MapLegend
featureLabel={ featureLabel={
viewSource === 'eye' viewSource === 'eye'
? `Previewing \u201c${colorFeatureMeta.name}\u201d` ? t('mapLegend.previewing', { name: ts(colorFeatureMeta.name) })
: colorFeatureMeta.name : ts(colorFeatureMeta.name)
} }
range={colorRange} range={colorRange}
showCancel={viewSource === 'eye'} showCancel={viewSource === 'eye'}
@ -311,7 +315,7 @@ export default memo(function Map({
: [countRange.min, countRange.max] : [countRange.min, countRange.max]
} }
totalCount={ totalCount={
usePostcodeView ? postcodeCountRange.total : countRange.total totalCountProp ?? (usePostcodeView ? postcodeCountRange.total : countRange.total)
} }
showCancel={false} showCancel={false}
onCancel={onCancelPin} onCancel={onCancelPin}

View file

@ -1,4 +1,5 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Slider } from '../ui/Slider'; import { Slider } from '../ui/Slider';
import { IconButton } from '../ui/IconButton'; import { IconButton } from '../ui/IconButton';
import { PillToggle } from '../ui/PillToggle'; import { PillToggle } from '../ui/PillToggle';
@ -8,9 +9,9 @@ import { TravelTimeInfoPopup } from '../ui/TravelTimeInfoPopup';
import { CloseIcon } from '../ui/icons/CloseIcon'; import { CloseIcon } from '../ui/icons/CloseIcon';
import { EyeIcon } from '../ui/icons/EyeIcon'; import { EyeIcon } from '../ui/icons/EyeIcon';
import { InfoIcon } from '../ui/icons/InfoIcon'; import { InfoIcon } from '../ui/icons/InfoIcon';
import { formatFilterValue } from '../../lib/format'; import { formatFilterValue, formatNumber } from '../../lib/format';
import { useTravelDestinations } from '../../hooks/useTravelDestinations'; import { useTravelDestinations } from '../../hooks/useTravelDestinations';
import { MODE_LABELS, MODE_ICONS, type TransportMode } from '../../hooks/useTravelTime'; import { MODE_ICONS, useTranslatedModes, type TransportMode } from '../../hooks/useTravelTime';
interface TravelTimeCardProps { interface TravelTimeCardProps {
mode: TransportMode; mode: TransportMode;
@ -29,6 +30,7 @@ interface TravelTimeCardProps {
onDragEnd: () => void; onDragEnd: () => void;
onToggleBest: () => void; onToggleBest: () => void;
onRemove: () => void; onRemove: () => void;
filterImpact?: number;
} }
export function TravelTimeCard({ export function TravelTimeCard({
@ -48,7 +50,10 @@ export function TravelTimeCard({
onDragEnd, onDragEnd,
onToggleBest, onToggleBest,
onRemove, onRemove,
filterImpact,
}: TravelTimeCardProps) { }: TravelTimeCardProps) {
const { t } = useTranslation();
const modes = useTranslatedModes();
const { destinations, loading: destinationsLoading } = useTravelDestinations(mode); const { destinations, loading: destinationsLoading } = useTravelDestinations(mode);
const [showInfo, setShowInfo] = useState(false); const [showInfo, setShowInfo] = useState(false);
const [showBestInfo, setShowBestInfo] = useState(false); const [showBestInfo, setShowBestInfo] = useState(false);
@ -75,23 +80,23 @@ export function TravelTimeCard({
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" /> <ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
<span className="text-sm font-medium text-navy-950 dark:text-warm-100"> <span className="text-sm font-medium text-navy-950 dark:text-warm-100">
Travel Time ({MODE_LABELS[mode]}) {t('travel.travelTime', { mode: modes.label(mode) })}
</span> </span>
</div> </div>
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
<IconButton onClick={() => setShowInfo(true)} title="Feature info"> <IconButton onClick={() => setShowInfo(true)} title={t('filters.featureInfo')}>
<InfoIcon className="w-3.5 h-3.5" /> <InfoIcon className="w-3.5 h-3.5" />
</IconButton> </IconButton>
{slug && ( {slug && (
<IconButton <IconButton
onClick={onTogglePin} onClick={onTogglePin}
active={isPinned} active={isPinned}
title={isPinned ? 'Stop previewing' : 'Preview on map'} title={isPinned ? t('travel.stopPreviewing') : t('travel.previewOnMap')}
> >
<EyeIcon className="w-3.5 h-3.5" filled={isPinned} /> <EyeIcon className="w-3.5 h-3.5" filled={isPinned} />
</IconButton> </IconButton>
)} )}
<IconButton onClick={() => onRemove()} title="Remove travel time"> <IconButton onClick={() => onRemove()} title={t('travel.removeTravelTime')}>
<CloseIcon className="w-3.5 h-3.5" /> <CloseIcon className="w-3.5 h-3.5" />
</IconButton> </IconButton>
</div> </div>
@ -104,14 +109,14 @@ export function TravelTimeCard({
onSelect={handleDestinationSelect} onSelect={handleDestinationSelect}
value={label || undefined} value={label || undefined}
onClear={() => onSetDestination('', '', 0, 0)} onClear={() => onSetDestination('', '', 0, 0)}
placeholder="Select destination..." placeholder={t('travel.selectDestination')}
/> />
{/* Best-case toggle — transit only, shown when destination is set */} {/* Best-case toggle — transit only, shown when destination is set */}
{slug && mode === 'transit' && ( {slug && mode === 'transit' && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<PillToggle label="Best case" active={useBest} onClick={onToggleBest} size="xs" /> <PillToggle label={t('travel.bestCase')} active={useBest} onClick={onToggleBest} size="xs" />
<IconButton onClick={() => setShowBestInfo(true)} title="What is best case?"> <IconButton onClick={() => setShowBestInfo(true)} title={t('travel.bestCaseTitle')}>
<InfoIcon className="w-3 h-3" /> <InfoIcon className="w-3 h-3" />
</IconButton> </IconButton>
</div> </div>
@ -120,12 +125,11 @@ export function TravelTimeCard({
{showInfo && <TravelTimeInfoPopup mode={mode} onClose={() => setShowInfo(false)} />} {showInfo && <TravelTimeInfoPopup mode={mode} onClose={() => setShowInfo(false)} />}
{showBestInfo && ( {showBestInfo && (
<InfoPopup title="Best case travel time" onClose={() => setShowBestInfo(false)}> <InfoPopup title={t('travel.bestCaseTitle')} onClose={() => setShowBestInfo(false)}>
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed"> <p
Uses the fastest realistic journey time (if you time your departure well and catch good className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed"
connections). The default uses the <strong>median</strong>, representing a typical journey dangerouslySetInnerHTML={{ __html: t('travel.bestCaseDesc') }}
regardless of when you leave. />
</p>
</InfoPopup> </InfoPopup>
)} )}
@ -133,7 +137,7 @@ export function TravelTimeCard({
{slug && ( {slug && (
<div> <div>
<span className="text-[10px] font-medium text-warm-500 dark:text-warm-400 uppercase tracking-wide"> <span className="text-[10px] font-medium text-warm-500 dark:text-warm-400 uppercase tracking-wide">
Max time {t('travel.maxTime')}
</span> </span>
<Slider <Slider
min={sliderMin} min={sliderMin}
@ -145,9 +149,14 @@ export function TravelTimeCard({
onPointerUp={() => onDragEnd()} onPointerUp={() => onDragEnd()}
/> />
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight"> <div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
<span className="absolute left-0">{formatFilterValue(displayRange[0])} min</span> <span className="absolute left-0">{formatFilterValue(displayRange[0])} {t('common.min')}</span>
<span className="absolute right-0">{formatFilterValue(displayRange[1])} min</span> <span className="absolute right-0">{formatFilterValue(displayRange[1])} {t('common.min')}</span>
</div> </div>
{filterImpact != null && filterImpact > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
+{formatNumber(filterImpact)} without this filter
</p>
)}
</div> </div>
)} )}
</div> </div>

View file

@ -0,0 +1,60 @@
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { SUPPORTED_LANGUAGES, type LanguageCode } from '../../i18n';
export default function LanguageDropdown() {
const { i18n } = useTranslation();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const current = SUPPORTED_LANGUAGES.find((l) => l.code === i18n.language) ?? SUPPORTED_LANGUAGES[0];
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const changeLanguage = (code: LanguageCode) => {
i18n.changeLanguage(code);
localStorage.setItem('language', code);
setOpen(false);
};
return (
<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"
aria-label="Language"
>
<span className="text-base leading-none">{current.flag}</span>
<svg className="w-3 h-3 text-warm-400" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 5l3 3 3-3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open && (
<div className="absolute right-0 top-9 w-40 bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg shadow-lg z-50 py-1">
{SUPPORTED_LANGUAGES.map((lang) => (
<button
key={lang.code}
onClick={() => changeLanguage(lang.code)}
className={`w-full flex 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'
}`}
>
<span className="text-base leading-none">{lang.flag}</span>
{lang.label}
</button>
))}
</div>
)}
</div>
);
}

View file

@ -16,7 +16,7 @@ export function Slider({ className, ...props }: SliderProps) {
{props.value?.map((_, i) => ( {props.value?.map((_, i) => (
<SliderPrimitive.Thumb <SliderPrimitive.Thumb
key={i} key={i}
className="block h-6 w-6 rounded-full border-2 border-teal-600 dark:border-teal-500 bg-white dark:bg-navy-800 ring-offset-white dark:ring-offset-navy-950 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 before:absolute before:left-1/2 before:top-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:h-11 before:w-11 before:rounded-full before:content-['']" className="block h-6 w-6 cursor-pointer rounded-full border-2 border-teal-600 dark:border-teal-500 bg-white dark:bg-navy-800 ring-offset-white dark:ring-offset-navy-950 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 before:absolute before:left-1/2 before:top-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:h-11 before:w-11 before:rounded-full before:content-['']"
/> />
))} ))}
</SliderPrimitive.Root> </SliderPrimitive.Root>

View file

@ -17,8 +17,6 @@ export interface AiFiltersResult {
notes: string; notes: string;
/** Human-readable summary of what was set */ /** Human-readable summary of what was set */
summary: string; summary: string;
/** The listing mode used (historical/buy/rent) */
listingType: string;
/** Number of properties matching the proposed filters (excludes travel time) */ /** Number of properties matching the proposed filters (excludes travel time) */
matchCount: number; matchCount: number;
} }
@ -34,8 +32,7 @@ export interface AiFiltersContext {
interface UseAiFiltersResult { interface UseAiFiltersResult {
fetchAiFilters: ( fetchAiFilters: (
query: string, query: string,
context?: AiFiltersContext, context?: AiFiltersContext
listingType?: string
) => Promise<AiFiltersResult | null>; ) => Promise<AiFiltersResult | null>;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
@ -88,8 +85,7 @@ export function useAiFilters(): UseAiFiltersResult {
const fetchAiFilters = useCallback( const fetchAiFilters = useCallback(
async ( async (
query: string, query: string,
context?: AiFiltersContext, context?: AiFiltersContext
listingType?: string
): Promise<AiFiltersResult | null> => { ): Promise<AiFiltersResult | null> => {
abortRef.current?.abort(); abortRef.current?.abort();
const controller = new AbortController(); const controller = new AbortController();
@ -104,7 +100,6 @@ export function useAiFilters(): UseAiFiltersResult {
try { try {
const url = apiUrl('ai-filters'); const url = apiUrl('ai-filters');
const bodyObj: Record<string, unknown> = { query }; const bodyObj: Record<string, unknown> = { query };
if (listingType) bodyObj.listing_type = listingType;
if (context) { if (context) {
bodyObj.context = { bodyObj.context = {
filters: context.filters, filters: context.filters,
@ -155,7 +150,6 @@ export function useAiFilters(): UseAiFiltersResult {
travelTimeFilters, travelTimeFilters,
notes: json.notes || '', notes: json.notes || '',
summary: summaryText, summary: summaryText,
listingType: json.listing_type || 'historical',
matchCount, matchCount,
}; };
setNotes(result.notes || null); setNotes(result.notes || null);

View file

@ -126,8 +126,10 @@ export function useAuth() {
const result = await pb.collection('users').authRefresh(); const result = await pb.collection('users').authRefresh();
setUser(recordToUser(result.record)); setUser(recordToUser(result.record));
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Auth refresh failed'; // Token is invalid/expired — clear auth state but don't set error,
setError(msg); // since this is a background refresh, not a user-initiated action
pb.authStore.clear();
setUser(null);
throw err; throw err;
} finally { } finally {
setLoading(false); setLoading(false);

View file

@ -0,0 +1,93 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import type { FeatureMeta, FeatureFilters, Bounds } from '../types';
import { apiUrl, buildFilterString, logNonAbortError, authHeaders, isAbortError } from '../lib/api';
import type { TravelTimeEntry } from './useTravelTime';
const DEBOUNCE_MS = 400;
interface FilterCountsResponse {
total: number;
impacts: Record<string, number>;
}
/**
* Fetches per-filter marginal impact counts: for each active filter,
* how many more properties would be visible if that filter were removed.
*/
export function useFilterCounts(
filters: FeatureFilters,
features: FeatureMeta[],
bounds: Bounds | null,
travelTimeEntries: TravelTimeEntry[]
) {
const [impacts, setImpacts] = useState<Record<string, number>>({});
const [total, setTotal] = useState<number>(0);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortRef = useRef<AbortController | null>(null);
// Build the travel param string (same format as useMapData)
const travelParam = useMemo(() => {
const segments: string[] = [];
for (const entry of travelTimeEntries) {
if (!entry.slug) continue;
let seg = `${entry.mode}:${entry.slug}`;
if (entry.useBest) seg += ':best';
if (entry.timeRange) seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
segments.push(seg);
}
return segments.join('|');
}, [travelTimeEntries]);
useEffect(() => {
if (!bounds) return;
const filterCount = Object.keys(filters).length;
const hasTravelFilters = travelTimeEntries.some((e) => e.slug && e.timeRange);
if (filterCount === 0 && !hasTravelFilters) {
setImpacts({});
setTotal(0);
return;
}
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
if (abortRef.current) abortRef.current.abort();
abortRef.current = new AbortController();
try {
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const filtersStr = buildFilterString(filters, features);
const params = new URLSearchParams({ bounds: boundsStr });
if (filtersStr) params.set('filters', filtersStr);
if (travelParam) params.set('travel', travelParam);
const res = await fetch(
apiUrl('filter-counts', params),
authHeaders({ signal: abortRef.current!.signal })
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json: FilterCountsResponse = await res.json();
setImpacts(json.impacts);
setTotal(json.total);
} catch (err) {
if (!isAbortError(err)) {
logNonAbortError('Failed to fetch filter counts', err);
}
}
}, DEBOUNCE_MS);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [filters, features, bounds, travelParam, travelTimeEntries]);
// Cancel in-flight on unmount
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
return { impacts, total };
}

View file

@ -266,6 +266,14 @@ export function useHexagonSelection({
setSelectedPostcodeGeometry(null); setSelectedPostcodeGeometry(null);
} else { } else {
setAreaStats(stats); setAreaStats(stats);
// Re-fetch properties if the properties tab is active
if (rightPaneTab === 'properties') {
if (selectedHexagon.type === 'postcode') {
fetchPostcodeProperties(selectedHexagon.id, 0);
} else {
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
}
}
} }
}) })
.catch((error) => { .catch((error) => {
@ -279,7 +287,7 @@ export function useHexagonSelection({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [filterStr, selectedHexagon, fetchHexagonStats, fetchPostcodeStats]); }, [filterStr, selectedHexagon, fetchHexagonStats, fetchPostcodeStats, rightPaneTab, fetchHexagonProperties, fetchPostcodeProperties]);
const handleLocationSearch = useCallback( const handleLocationSearch = useCallback(
(postcode: string, geometry: PostcodeGeometry) => { (postcode: string, geometry: PostcodeGeometry) => {

View file

@ -2,10 +2,12 @@ import { useState, useCallback, useRef, useEffect } from 'react';
import type { PlaceResult } from '../types'; import type { PlaceResult } from '../types';
import { authHeaders, logNonAbortError } from '../lib/api'; import { authHeaders, logNonAbortError } from '../lib/api';
const POSTCODE_RE = /^[A-Z]{1,2}\d[A-Z\d]?\s*\d?[A-Z]{0,2}$/i; /** Matches a full UK postcode with complete inward code (e.g. "E14 2DG", "SW1A1AA").
* Outcodes like "E14" or "SW1A" intentionally do NOT match they go through /api/places instead. */
const FULL_POSTCODE_RE = /^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$/i;
function looksLikePostcode(s: string) { function looksLikePostcode(s: string) {
return POSTCODE_RE.test(s.trim()); return FULL_POSTCODE_RE.test(s.trim());
} }
/** Normalize a UK postcode: uppercase, strip spaces, insert canonical space before inward code. */ /** Normalize a UK postcode: uppercase, strip spaces, insert canonical space before inward code. */
@ -83,7 +85,7 @@ export function useLocationSearch(mode?: string) {
place_type: p.place_type, place_type: p.place_type,
lat: p.lat, lat: p.lat,
lon: p.lon, lon: p.lon,
city: p.city, city: p.city === 'City of London' ? 'London' : p.city,
})); }));
setResults(placeResults); setResults(placeResults);
setOpen(placeResults.length > 0); setOpen(placeResults.length > 0);

View file

@ -7,43 +7,87 @@ interface PaneResizeHandlers {
} }
export function usePaneResize( export function usePaneResize(
initialWidth: number, initialSize: number,
minWidth: number, minSize: number,
maxWidth: number, maxSize: number,
side: 'left' | 'right' side: 'left' | 'right' | 'top' | 'bottom'
): [number, PaneResizeHandlers] { ): [number, PaneResizeHandlers, React.RefCallback<HTMLElement>] {
const [width, setWidth] = useState(initialWidth); const [size, setSize] = useState(initialSize);
const draggingRef = useRef(false); const draggingRef = useRef(false);
const liveSizeRef = useRef(initialSize);
const targetRef = useRef<HTMLElement | null>(null);
const containerOffsetRef = useRef(0);
const containerSizeRef = useRef(0);
const handlePointerDown = useCallback((e: React.PointerEvent) => { const isVertical = side === 'top' || side === 'bottom';
e.preventDefault(); const styleProp = isVertical ? 'height' : 'width';
(e.target as HTMLElement).setPointerCapture(e.pointerId);
draggingRef.current = true; const targetCallbackRef = useCallback((el: HTMLElement | null) => {
targetRef.current = el;
}, []); }, []);
const computeSize = useCallback(
(e: React.PointerEvent): number => {
if (isVertical) {
const total = containerSizeRef.current || window.innerHeight;
const resolvedMax = maxSize <= 1 ? total * maxSize : maxSize;
const pos = e.clientY - containerOffsetRef.current;
return side === 'top'
? Math.min(resolvedMax, Math.max(minSize, pos))
: Math.min(resolvedMax, Math.max(minSize, total - pos));
} else {
const resolvedMax = maxSize <= 1 ? window.innerWidth * maxSize : maxSize;
return side === 'left'
? Math.min(resolvedMax, Math.max(minSize, e.clientX))
: Math.min(resolvedMax, Math.max(minSize, window.innerWidth - e.clientX));
}
},
[side, isVertical, minSize, maxSize]
);
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
draggingRef.current = true;
if (isVertical) {
const container = (e.currentTarget as HTMLElement).parentElement;
if (container) {
const rect = container.getBoundingClientRect();
containerOffsetRef.current = rect.top;
containerSizeRef.current = rect.height;
}
}
},
[isVertical]
);
const handlePointerMove = useCallback( const handlePointerMove = useCallback(
(e: React.PointerEvent) => { (e: React.PointerEvent) => {
if (!draggingRef.current) return; if (!draggingRef.current) return;
const resolvedMax = maxWidth <= 1 ? window.innerWidth * maxWidth : maxWidth; const newSize = computeSize(e);
const newWidth = liveSizeRef.current = newSize;
side === 'left' if (targetRef.current) {
? Math.min(resolvedMax, Math.max(minWidth, e.clientX)) targetRef.current.style[styleProp] = `${newSize}px`;
: Math.min(resolvedMax, Math.max(minWidth, window.innerWidth - e.clientX)); } else {
setWidth(newWidth); setSize(newSize);
}
}, },
[side, minWidth, maxWidth] [computeSize, styleProp]
); );
const handlePointerUp = useCallback(() => { const handlePointerUp = useCallback(() => {
draggingRef.current = false; draggingRef.current = false;
setSize(liveSizeRef.current);
}, []); }, []);
return [ return [
width, size,
{ {
onPointerDown: handlePointerDown, onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove, onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp, onPointerUp: handlePointerUp,
}, },
targetCallbackRef,
]; ];
} }

View file

@ -13,275 +13,271 @@ import i18n from 'i18next';
const descriptions: Record<string, Record<string, string>> = { const descriptions: Record<string, Record<string, string>> = {
fr: { fr: {
'Listing status': 'Indique si le bien provient de ventes historiques, est en vente ou en location', 'Listing status': 'Indique si le bien provient de ventes historiques, est en vente ou en location',
'Property type': 'Type de bien : individuel, jumel\u00E9, mitoyen, appartement ou autre', 'Property type': 'Type de bien : individuel, jumelé, mitoyen, appartement ou autre',
'Leasehold/Freehold': 'Indique si le bien est en bail ou en pleine propri\u00E9t\u00E9', 'Leasehold/Freehold': 'Indique si le bien est en bail ou en pleine propriété',
'Last known price': 'Dernier prix de vente enregistr\u00E9 au Land Registry', 'Last known price': 'Dernier prix de vente enregistré au Land Registry',
'Estimated current price': 'Estimation du prix actuel ajust\u00E9 \u00E0 l\u2019inflation', 'Estimated current price': 'Estimation du prix actuel ajusté à linflation',
'Asking price': 'Prix demand\u00E9 pour les biens actuellement en vente', 'Asking price': 'Prix demandé pour les biens actuellement en vente',
'Price per sqm': 'Prix de vente divis\u00E9 par la surface totale', 'Price per sqm': 'Prix de vente divisé par la surface totale',
'Est. price per sqm': 'Prix actuel estim\u00E9 divis\u00E9 par la surface totale', 'Est. price per sqm': 'Prix actuel estimé divisé par la surface totale',
'Asking price per sqm': 'Prix demand\u00E9 divis\u00E9 par la surface totale', 'Asking price per sqm': 'Prix demandé divisé par la surface totale',
'Estimated monthly rent': 'Loyer mensuel priv\u00E9 m\u00E9dian pour le secteur', 'Estimated monthly rent': 'Loyer mensuel privé médian pour le secteur',
'Asking rent (monthly)': 'Loyer mensuel affich\u00E9 pour les biens en location', 'Asking rent (monthly)': 'Loyer mensuel affiché pour les biens en location',
'Total floor area (sqm)': 'Surface int\u00E9rieure issue du diagnostic EPC', 'Total floor area (sqm)': 'Surface intérieure issue du diagnostic EPC',
'Number of bedrooms & living rooms': 'Nombre de pi\u00E8ces habitables selon le diagnostic EPC', 'Number of bedrooms & living rooms': 'Nombre de pièces habitables selon le diagnostic EPC',
'Bedrooms': 'Nombre de chambres selon l\u2019annonce en ligne', 'Bedrooms': 'Nombre de chambres selon lannonce en ligne',
'Bathrooms': 'Nombre de salles de bain selon l\u2019annonce en ligne', 'Bathrooms': 'Nombre de salles de bain selon lannonce en ligne',
'Construction year': 'Ann\u00E9e de construction estim\u00E9e selon l\u2019EPC', 'Construction year': 'Année de construction estimée selon lEPC',
'Date of last transaction': 'Date de la derni\u00E8re vente enregistr\u00E9e au Land Registry', 'Date of last transaction': 'Date de la dernière vente enregistrée au Land Registry',
'Listing date': 'Date de premi\u00E8re mise en ligne du bien', 'Listing date': 'Date de première mise en ligne du bien',
'Former council house': 'Indique si le bien a \u00E9t\u00E9 r\u00E9pertori\u00E9 comme logement social', 'Former council house': 'Indique si le bien a été répertorié comme logement social',
'Current energy rating': 'Classement \u00E9nerg\u00E9tique EPC actuel (A = meilleur, G = pire)', 'Current energy rating': 'Classement énergétique EPC actuel (A = meilleur, G = pire)',
'Potential energy rating': 'Classement EPC potentiel si toutes les am\u00E9liorations recommand\u00E9es \u00E9taient r\u00E9alis\u00E9es', 'Potential energy rating': 'Classement EPC potentiel si toutes les améliorations recommandées étaient réalisées',
'Interior height (m)': 'Hauteur moyenne d\u2019\u00E9tage selon le diagnostic EPC', 'Interior height (m)': 'Hauteur moyenne détage selon le diagnostic EPC',
'Distance to nearest train or tube station (km)': 'Distance \u00E0 la gare ou station de m\u00E9tro la plus proche', 'Distance to nearest train or tube station (km)': 'Distance à la gare ou station de métro la plus proche',
'Train or tube stations within 1km': 'Nombre de gares ou stations de m\u00E9tro \u00E0 moins d\u20191 km', 'Good+ primary schools within 2km': 'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 2 km',
'Good+ primary schools within 2km': '\u00C9coles primaires not\u00E9es Bien ou Excellent par Ofsted dans un rayon de 2 km', 'Good+ secondary schools within 2km': 'Collèges/lycées notés Bien ou Excellent par Ofsted dans un rayon de 2 km',
'Good+ secondary schools within 2km': 'Coll\u00E8ges/lyc\u00E9es not\u00E9s Bien ou Excellent par Ofsted dans un rayon de 2 km', 'Good+ primary schools within 5km': 'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 5 km',
'Good+ primary schools within 5km': '\u00C9coles primaires not\u00E9es Bien ou Excellent par Ofsted dans un rayon de 5 km', 'Good+ secondary schools within 5km': 'Collèges/lycées notés Bien ou Excellent par Ofsted dans un rayon de 5 km',
'Good+ secondary schools within 5km': 'Coll\u00E8ges/lyc\u00E9es not\u00E9s Bien ou Excellent par Ofsted dans un rayon de 5 km', 'Education, Skills and Training Score': 'Score de qualité éducative du secteur (plus élevé = meilleur)',
'Education, Skills and Training Score': 'Score de qualit\u00E9 \u00E9ducative du secteur (plus \u00E9lev\u00E9 = meilleur)', 'Income Score (rate)': 'Taux de précarité de revenu, inversé (plus élevé = moins précaire)',
'Income Score (rate)': 'Taux de pr\u00E9carit\u00E9 de revenu, invers\u00E9 (plus \u00E9lev\u00E9 = moins pr\u00E9caire)', 'Employment Score (rate)': 'Taux de précarité demploi, inversé (plus élevé = moins précaire)',
'Employment Score (rate)': 'Taux de pr\u00E9carit\u00E9 d\u2019emploi, invers\u00E9 (plus \u00E9lev\u00E9 = moins pr\u00E9caire)', 'Health Deprivation and Disability Score': 'Score de santé et handicap (plus élevé = meilleurs résultats)',
'Health Deprivation and Disability Score': 'Score de sant\u00E9 et handicap (plus \u00E9lev\u00E9 = meilleurs r\u00E9sultats)', 'Living Environment Score': 'Qualité de lenvironnement intérieur et extérieur (plus élevé = meilleur)',
'Living Environment Score': 'Qualit\u00E9 de l\u2019environnement int\u00E9rieur et ext\u00E9rieur (plus \u00E9lev\u00E9 = meilleur)', 'Indoors Sub-domain Score': 'Qualité et état du logement (plus élevé = meilleur)',
'Indoors Sub-domain Score': 'Qualit\u00E9 et \u00E9tat du logement (plus \u00E9lev\u00E9 = meilleur)', 'Outdoors Sub-domain Score': 'Qualité de lair et sécurité routière (plus élevé = meilleur)',
'Outdoors Sub-domain Score': 'Qualit\u00E9 de l\u2019air et s\u00E9curit\u00E9 routi\u00E8re (plus \u00E9lev\u00E9 = meilleur)',
'Serious crime per 1k residents (avg/yr)': 'Taux de crimes graves pour 1 000 habitants par an', '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\u00E9lits mineurs 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\u00E9gat des cat\u00E9gories de crimes graves par an', 'Serious crime (avg/yr)': 'Agrégat des catégories de crimes graves par an',
'Minor crime (avg/yr)': 'Agr\u00E9gat des cat\u00E9gories de d\u00E9lits mineurs par an', 'Minor crime (avg/yr)': 'Agrégat des catégories de délits mineurs par an',
'Violence and sexual offences (avg/yr)': 'Moyenne annuelle des violences et infractions sexuelles dans le secteur', 'Violence and sexual offences (avg/yr)': 'Moyenne annuelle des violences et infractions sexuelles dans le secteur',
'Burglary (avg/yr)': 'Moyenne annuelle des cambriolages dans le secteur', 'Burglary (avg/yr)': 'Moyenne annuelle des cambriolages dans le secteur',
'Robbery (avg/yr)': 'Moyenne annuelle des vols avec violence dans le secteur', 'Robbery (avg/yr)': 'Moyenne annuelle des vols avec violence dans le secteur',
'Vehicle crime (avg/yr)': 'Moyenne annuelle des crimes li\u00E9s aux v\u00E9hicules dans le secteur', 'Vehicle crime (avg/yr)': 'Moyenne annuelle des crimes liés aux véhicules dans le secteur',
'Anti-social behaviour (avg/yr)': 'Moyenne annuelle des comportements antisociaux dans le secteur', 'Anti-social behaviour (avg/yr)': 'Moyenne annuelle des comportements antisociaux dans le secteur',
'Criminal damage and arson (avg/yr)': 'Moyenne annuelle des d\u00E9gradations et incendies criminels dans le secteur', 'Criminal damage and arson (avg/yr)': 'Moyenne annuelle des dégradations et incendies criminels dans le secteur',
'Other theft (avg/yr)': 'Moyenne annuelle des autres vols dans le secteur', 'Other theft (avg/yr)': 'Moyenne annuelle des autres vols dans le secteur',
'Theft from the person (avg/yr)': 'Moyenne annuelle des vols \u00E0 la personne dans le secteur', 'Theft from the person (avg/yr)': 'Moyenne annuelle des vols à la personne dans le secteur',
'Shoplifting (avg/yr)': 'Moyenne annuelle des vols \u00E0 l\u2019\u00E9talage dans le secteur', 'Shoplifting (avg/yr)': 'Moyenne annuelle des vols à létalage dans le secteur',
'Bicycle theft (avg/yr)': 'Moyenne annuelle des vols de v\u00E9los dans le secteur', 'Bicycle theft (avg/yr)': 'Moyenne annuelle des vols de vélos dans le secteur',
'Drugs (avg/yr)': 'Moyenne annuelle des infractions li\u00E9es aux stup\u00E9fiants dans le secteur', 'Drugs (avg/yr)': 'Moyenne annuelle des infractions liées aux stupéfiants dans le secteur',
'Possession of weapons (avg/yr)': 'Moyenne annuelle des infractions de possession d\u2019armes dans le secteur', 'Possession of weapons (avg/yr)': 'Moyenne annuelle des infractions de possession darmes dans le secteur',
'Public order (avg/yr)': 'Moyenne annuelle des troubles \u00E0 l\u2019ordre public dans le secteur', 'Public order (avg/yr)': 'Moyenne annuelle des troubles à lordre public dans le secteur',
'Other crime (avg/yr)': 'Moyenne annuelle des autres crimes dans le secteur', 'Other crime (avg/yr)': 'Moyenne annuelle des autres crimes dans le secteur',
'Median age': '\u00C2ge m\u00E9dian de la population locale', 'Median age': 'Âge médian de la population locale',
'% White': 'Pourcentage de la population se d\u00E9clarant Blanche', '% White': 'Pourcentage de la population se déclarant Blanche',
'% South Asian': 'Pourcentage de la population se d\u00E9clarant Sud-Asiatique', '% South Asian': 'Pourcentage de la population se déclarant Sud-Asiatique',
'% Black': 'Pourcentage de la population se d\u00E9clarant Noire', '% Black': 'Pourcentage de la population se déclarant Noire',
'% East Asian': 'Pourcentage de la population se d\u00E9clarant Est-Asiatique', '% East Asian': 'Pourcentage de la population se déclarant Est-Asiatique',
'% Mixed': 'Pourcentage de la population se d\u00E9clarant M\u00E9tisse ou de plusieurs groupes ethniques', '% Mixed': 'Pourcentage de la population se déclarant Métisse ou de plusieurs groupes ethniques',
'% Other': 'Pourcentage de la population se d\u00E9clarant d\u2019un autre groupe ethnique', '% Other': 'Pourcentage de la population se déclarant dun autre groupe ethnique',
'Distance to nearest park (km)': 'Distance au parc ou espace vert le plus proche', 'Distance to nearest park (km)': 'Distance au parc ou espace vert le plus proche',
'Number of parks within 2km': 'Nombre de parcs et espaces verts \u00E0 moins de 2 km', 'Number of parks within 2km': 'Nombre de parcs et espaces verts à moins de 2 km',
'Number of restaurants within 2km': 'Nombre de restaurants et caf\u00E9s \u00E0 moins de 2 km', 'Number of restaurants within 2km': 'Nombre de restaurants et cafés à moins de 2 km',
'Number of grocery shops and supermarkets within 2km': 'Nombre d\u2019\u00E9piceries et supermarch\u00E9s \u00E0 moins de 2 km', 'Number of grocery shops and supermarkets within 2km': 'Nombre dépiceries et supermarchés à moins de 2 km',
'Noise (dB)': 'Niveau de bruit routier au code postal en d\u00E9cibels (Lden)', 'Noise (dB)': 'Niveau de bruit routier au code postal en décibels (Lden)',
'Max available download speed (Mbps)': 'D\u00E9bit descendant maximal disponible au code postal', 'Max available download speed (Mbps)': 'Débit descendant maximal disponible au code postal',
}, },
de: { de: {
'Listing status': 'Ob die Immobilie aus historischen Verk\u00E4ufen stammt, aktuell zum Verkauf oder zur Miete steht', 'Listing status': 'Ob die Immobilie aus historischen Verkäufen stammt, aktuell zum Verkauf oder zur Miete steht',
'Property type': 'Immobilientyp: freistehend, Doppelhaush\u00E4lfte, Reihenhaus, Wohnung oder sonstige', 'Property type': 'Immobilientyp: freistehend, Doppelhaushälfte, Reihenhaus, Wohnung oder sonstige',
'Leasehold/Freehold': 'Ob die Immobilie Erbbaurecht oder Volleigentum ist', 'Leasehold/Freehold': 'Ob die Immobilie Erbbaurecht oder Volleigentum ist',
'Last known price': 'Letzter Verkaufspreis laut Land Registry', 'Last known price': 'Letzter Verkaufspreis laut Land Registry',
'Estimated current price': 'Inflationsbereinigter Sch\u00E4tzwert der Immobilie', 'Estimated current price': 'Inflationsbereinigter Schätzwert der Immobilie',
'Asking price': 'Angebotspreis f\u00FCr aktuell zum Verkauf stehende Immobilien', 'Asking price': 'Angebotspreis für aktuell zum Verkauf stehende Immobilien',
'Price per sqm': 'Verkaufspreis geteilt durch die Gesamtfl\u00E4che', 'Price per sqm': 'Verkaufspreis geteilt durch die Gesamtfläche',
'Est. price per sqm': 'Gesch\u00E4tzter aktueller Preis geteilt durch die Gesamtfl\u00E4che', 'Est. price per sqm': 'Geschätzter aktueller Preis geteilt durch die Gesamtfläche',
'Asking price per sqm': 'Angebotspreis geteilt durch die Gesamtfl\u00E4che', 'Asking price per sqm': 'Angebotspreis geteilt durch die Gesamtfläche',
'Estimated monthly rent': 'Mittlere monatliche Privatmiete in der Gegend', 'Estimated monthly rent': 'Mittlere monatliche Privatmiete in der Gegend',
'Asking rent (monthly)': 'Angebotene Monatsmiete f\u00FCr Mietimmobilien', 'Asking rent (monthly)': 'Angebotene Monatsmiete für Mietimmobilien',
'Total floor area (sqm)': 'Wohnfl\u00E4che laut EPC-Gutachten', 'Total floor area (sqm)': 'Wohnfläche laut EPC-Gutachten',
'Number of bedrooms & living rooms': 'Anzahl bewohnbarer R\u00E4ume laut EPC-Gutachten', 'Number of bedrooms & living rooms': 'Anzahl bewohnbarer Räume laut EPC-Gutachten',
'Bedrooms': 'Anzahl Schlafzimmer laut Online-Inserat', 'Bedrooms': 'Anzahl Schlafzimmer laut Online-Inserat',
'Bathrooms': 'Anzahl Badezimmer laut Online-Inserat', 'Bathrooms': 'Anzahl Badezimmer laut Online-Inserat',
'Construction year': 'Gesch\u00E4tztes Baujahr laut EPC', 'Construction year': 'Geschätztes Baujahr laut EPC',
'Date of last transaction': 'Datum des letzten Verkaufs laut Land Registry', 'Date of last transaction': 'Datum des letzten Verkaufs laut Land Registry',
'Listing date': 'Datum der Erstver\u00F6ffentlichung des Inserats', 'Listing date': 'Datum der Erstveröffentlichung des Inserats',
'Former council house': 'Ob die Immobilie jemals als Sozialbau erfasst wurde', 'Former council house': 'Ob die Immobilie jemals als Sozialbau erfasst wurde',
'Current energy rating': 'Aktuelle EPC-Energieeffizienzklasse (A = beste, G = schlechteste)', 'Current energy rating': 'Aktuelle EPC-Energieeffizienzklasse (A = beste, G = schlechteste)',
'Potential energy rating': 'Potenzielle EPC-Klasse bei Umsetzung aller empfohlenen Ma\u00DFnahmen', 'Potential energy rating': 'Potenzielle EPC-Klasse bei Umsetzung aller empfohlenen Maßnahmen',
'Interior height (m)': 'Durchschnittliche Geschossh\u00F6he laut EPC-Gutachten', 'Interior height (m)': 'Durchschnittliche Geschosshöhe laut EPC-Gutachten',
'Distance to nearest train or tube station (km)': 'Entfernung zum n\u00E4chsten Bahn- oder U-Bahnhof', 'Distance to nearest train or tube station (km)': 'Entfernung zum nächsten Bahn- oder U-Bahnhof',
'Train or tube stations within 1km': 'Anzahl Bahn- oder U-Bahnh\u00F6fe im Umkreis von 1 km',
'Good+ primary schools within 2km': 'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 2 km', 'Good+ primary schools within 2km': 'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 2 km',
'Good+ secondary schools within 2km': 'Von Ofsted mit Gut oder Hervorragend bewertete weiterf\u00FChrende Schulen im Umkreis von 2 km', 'Good+ secondary schools within 2km': 'Von Ofsted mit Gut oder Hervorragend bewertete weiterführende Schulen im Umkreis von 2 km',
'Good+ primary schools within 5km': 'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 5 km', 'Good+ primary schools within 5km': 'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 5 km',
'Good+ secondary schools within 5km': 'Von Ofsted mit Gut oder Hervorragend bewertete weiterf\u00FChrende Schulen im Umkreis von 5 km', 'Good+ secondary schools within 5km': 'Von Ofsted mit Gut oder Hervorragend bewertete weiterführende Schulen im Umkreis von 5 km',
'Education, Skills and Training Score': 'Bildungsqualit\u00E4tsscore der Gegend (h\u00F6her = besser)', 'Education, Skills and Training Score': 'Bildungsqualitätsscore der Gegend (höher = besser)',
'Income Score (rate)': 'Einkommensbenachteiligungsrate, invertiert (h\u00F6her = weniger benachteiligt)', 'Income Score (rate)': 'Einkommensbenachteiligungsrate, invertiert (höher = weniger benachteiligt)',
'Employment Score (rate)': 'Besch\u00E4ftigungsbenachteiligungsrate, invertiert (h\u00F6her = weniger benachteiligt)', 'Employment Score (rate)': 'Beschäftigungsbenachteiligungsrate, invertiert (höher = weniger benachteiligt)',
'Health Deprivation and Disability Score': 'Gesundheits- und Behinderungsscore (h\u00F6her = bessere Ergebnisse)', 'Health Deprivation and Disability Score': 'Gesundheits- und Behinderungsscore (höher = bessere Ergebnisse)',
'Living Environment Score': 'Qualit\u00E4t der Innen- und Au\u00DFenumgebung (h\u00F6her = besser)', 'Living Environment Score': 'Qualität der Innen- und Außenumgebung (höher = besser)',
'Indoors Sub-domain Score': 'Wohnqualit\u00E4t und -zustand (h\u00F6her = besser)', 'Indoors Sub-domain Score': 'Wohnqualität und -zustand (höher = besser)',
'Outdoors Sub-domain Score': 'Luftqualit\u00E4t und Verkehrssicherheit (h\u00F6her = besser)', 'Outdoors Sub-domain Score': 'Luftqualität und Verkehrssicherheit (höher = besser)',
'Serious crime per 1k residents (avg/yr)': 'Rate schwerer Straftaten pro 1.000 Einwohner pro Jahr', 'Serious crime per 1k residents (avg/yr)': 'Rate schwerer Straftaten pro 1.000 Einwohner pro Jahr',
'Minor crime per 1k residents (avg/yr)': 'Rate leichter Straftaten pro 1.000 Einwohner pro Jahr', 'Minor crime per 1k residents (avg/yr)': 'Rate leichter Straftaten pro 1.000 Einwohner pro Jahr',
'Serious crime (avg/yr)': 'Summe der schweren Straftaten-Kategorien pro Jahr', 'Serious crime (avg/yr)': 'Summe der schweren Straftaten-Kategorien pro Jahr',
'Minor crime (avg/yr)': 'Summe der leichten Straftaten-Kategorien pro Jahr', 'Minor crime (avg/yr)': 'Summe der leichten Straftaten-Kategorien pro Jahr',
'Violence and sexual offences (avg/yr)': 'J\u00E4hrlicher Durchschnitt der Gewalt- und Sexualdelikte in der Gegend', 'Violence and sexual offences (avg/yr)': 'Jährlicher Durchschnitt der Gewalt- und Sexualdelikte in der Gegend',
'Burglary (avg/yr)': 'J\u00E4hrlicher Durchschnitt der Einbr\u00FCche in der Gegend', 'Burglary (avg/yr)': 'Jährlicher Durchschnitt der Einbrüche in der Gegend',
'Robbery (avg/yr)': 'J\u00E4hrlicher Durchschnitt der Raub\u00FCberf\u00E4lle in der Gegend', 'Robbery (avg/yr)': 'Jährlicher Durchschnitt der Raubüberfälle in der Gegend',
'Vehicle crime (avg/yr)': 'J\u00E4hrlicher Durchschnitt der Fahrzeugkriminalit\u00E4t in der Gegend', 'Vehicle crime (avg/yr)': 'Jährlicher Durchschnitt der Fahrzeugkriminalität in der Gegend',
'Anti-social behaviour (avg/yr)': 'J\u00E4hrlicher Durchschnitt des antisozialen Verhaltens in der Gegend', 'Anti-social behaviour (avg/yr)': 'Jährlicher Durchschnitt des antisozialen Verhaltens in der Gegend',
'Criminal damage and arson (avg/yr)': 'J\u00E4hrlicher Durchschnitt der Sachbesch\u00E4digungen und Brandstiftungen in der Gegend', 'Criminal damage and arson (avg/yr)': 'Jährlicher Durchschnitt der Sachbeschädigungen und Brandstiftungen in der Gegend',
'Other theft (avg/yr)': 'J\u00E4hrlicher Durchschnitt des sonstigen Diebstahls in der Gegend', 'Other theft (avg/yr)': 'Jährlicher Durchschnitt des sonstigen Diebstahls in der Gegend',
'Theft from the person (avg/yr)': 'J\u00E4hrlicher Durchschnitt des Taschendiebstahls in der Gegend', 'Theft from the person (avg/yr)': 'Jährlicher Durchschnitt des Taschendiebstahls in der Gegend',
'Shoplifting (avg/yr)': 'J\u00E4hrlicher Durchschnitt des Ladendiebstahls in der Gegend', 'Shoplifting (avg/yr)': 'Jährlicher Durchschnitt des Ladendiebstahls in der Gegend',
'Bicycle theft (avg/yr)': 'J\u00E4hrlicher Durchschnitt des Fahrraddiebstahls in der Gegend', 'Bicycle theft (avg/yr)': 'Jährlicher Durchschnitt des Fahrraddiebstahls in der Gegend',
'Drugs (avg/yr)': 'J\u00E4hrlicher Durchschnitt der Drogendelikte in der Gegend', 'Drugs (avg/yr)': 'Jährlicher Durchschnitt der Drogendelikte in der Gegend',
'Possession of weapons (avg/yr)': 'J\u00E4hrlicher Durchschnitt der Waffenbesitzdelikte in der Gegend', 'Possession of weapons (avg/yr)': 'Jährlicher Durchschnitt der Waffenbesitzdelikte in der Gegend',
'Public order (avg/yr)': 'J\u00E4hrlicher Durchschnitt der St\u00F6rungen der \u00F6ffentlichen Ordnung in der Gegend', 'Public order (avg/yr)': 'Jährlicher Durchschnitt der Störungen der öffentlichen Ordnung in der Gegend',
'Other crime (avg/yr)': 'J\u00E4hrlicher Durchschnitt sonstiger Straftaten in der Gegend', 'Other crime (avg/yr)': 'Jährlicher Durchschnitt sonstiger Straftaten in der Gegend',
'Median age': 'Medianalter der lokalen Bev\u00F6lkerung', 'Median age': 'Medianalter der lokalen Bevölkerung',
'% White': 'Anteil der Bev\u00F6lkerung, der sich als Wei\u00DF identifiziert', '% White': 'Anteil der Bevölkerung, der sich als Weiß identifiziert',
'% South Asian': 'Anteil der Bev\u00F6lkerung, der sich als S\u00FCdasiatisch identifiziert', '% South Asian': 'Anteil der Bevölkerung, der sich als Südasiatisch identifiziert',
'% Black': 'Anteil der Bev\u00F6lkerung, der sich als Schwarz identifiziert', '% Black': 'Anteil der Bevölkerung, der sich als Schwarz identifiziert',
'% East Asian': 'Anteil der Bev\u00F6lkerung, der sich als Ostasiatisch identifiziert', '% East Asian': 'Anteil der Bevölkerung, der sich als Ostasiatisch identifiziert',
'% Mixed': 'Anteil der Bev\u00F6lkerung, der sich als gemischt oder mehreren ethnischen Gruppen zugeh\u00F6rig identifiziert', '% Mixed': 'Anteil der Bevölkerung, der sich als gemischt oder mehreren ethnischen Gruppen zugehörig identifiziert',
'% Other': 'Anteil der Bev\u00F6lkerung, der sich einer anderen ethnischen Gruppe zuordnet', '% Other': 'Anteil der Bevölkerung, der sich einer anderen ethnischen Gruppe zuordnet',
'Distance to nearest park (km)': 'Entfernung zum n\u00E4chsten Park oder Gr\u00FCnfl\u00E4che', 'Distance to nearest park (km)': 'Entfernung zum nächsten Park oder Grünfläche',
'Number of parks within 2km': 'Anzahl Parks und Gr\u00FCnfl\u00E4chen im Umkreis von 2 km', 'Number of parks within 2km': 'Anzahl Parks und Grünflächen im Umkreis von 2 km',
'Number of restaurants within 2km': 'Anzahl Restaurants und Caf\u00E9s im Umkreis von 2 km', 'Number of restaurants within 2km': 'Anzahl Restaurants und Cafés im Umkreis von 2 km',
'Number of grocery shops and supermarkets within 2km': 'Anzahl Lebensmittelgesch\u00E4fte und Superm\u00E4rkte im Umkreis von 2 km', 'Number of grocery shops and supermarkets within 2km': 'Anzahl Lebensmittelgeschäfte und Supermärkte im Umkreis von 2 km',
'Noise (dB)': 'Stra\u00DFenl\u00E4rmpegel an der Postleitzahl in Dezibel (Lden)', 'Noise (dB)': 'Straßenlärmpegel an der Postleitzahl in Dezibel (Lden)',
'Max available download speed (Mbps)': 'Maximal verf\u00FCgbare Breitband-Downloadgeschwindigkeit an der Postleitzahl', 'Max available download speed (Mbps)': 'Maximal verfügbare Breitband-Downloadgeschwindigkeit an der Postleitzahl',
}, },
zh: { zh: {
'Listing status': '\u8BE5\u623F\u4EA7\u662F\u5386\u53F2\u9500\u552E\u3001\u5F53\u524D\u5728\u552E\u8FD8\u662F\u51FA\u79DF', 'Listing status': '该房产是历史销售、当前在售还是出租',
'Property type': '\u623F\u4EA7\u7C7B\u578B\uFF1A\u72EC\u7ACB\u5F0F\u3001\u534A\u72EC\u7ACB\u5F0F\u3001\u8054\u6392\u3001\u516C\u5BD3\u6216\u5176\u4ED6', 'Property type': '房产类型:独立式、半独立式、联排、公寓或其他',
'Leasehold/Freehold': '\u8BE5\u623F\u4EA7\u662F\u79DF\u8D41\u4EA7\u6743\u8FD8\u662F\u6C38\u4E45\u4EA7\u6743', 'Leasehold/Freehold': '该房产是租赁产权还是永久产权',
'Last known price': 'Land Registry\u8BB0\u5F55\u7684\u6700\u8FD1\u4E00\u6B21\u552E\u4EF7', 'Last known price': 'Land Registry记录的最近一次售价',
'Estimated current price': '\u7ECF\u901A\u80C0\u8C03\u6574\u540E\u7684\u5F53\u524D\u4F30\u8BA1\u4EF7\u503C', 'Estimated current price': '经通胀调整后的当前估计价值',
'Asking price': '\u5F53\u524D\u5728\u552E\u623F\u4EA7\u7684\u6302\u724C\u4EF7', 'Asking price': '当前在售房产的挂牌价',
'Price per sqm': '\u552E\u4EF7\u9664\u4EE5\u603B\u5EFA\u7B51\u9762\u79EF', 'Price per sqm': '售价除以总建筑面积',
'Est. price per sqm': '\u4F30\u8BA1\u5F53\u524D\u4EF7\u683C\u9664\u4EE5\u603B\u5EFA\u7B51\u9762\u79EF', 'Est. price per sqm': '估计当前价格除以总建筑面积',
'Asking price per sqm': '\u6302\u724C\u4EF7\u9664\u4EE5\u603B\u5EFA\u7B51\u9762\u79EF', 'Asking price per sqm': '挂牌价除以总建筑面积',
'Estimated monthly rent': '\u5F53\u5730\u79C1\u4EBA\u79DF\u8D41\u7684\u4E2D\u4F4D\u6708\u79DF', 'Estimated monthly rent': '当地私人租赁的中位月租',
'Asking rent (monthly)': '\u5F53\u524D\u51FA\u79DF\u623F\u4EA7\u7684\u6302\u724C\u6708\u79DF', 'Asking rent (monthly)': '当前出租房产的挂牌月租',
'Total floor area (sqm)': 'EPC\u8BC4\u4F30\u7684\u5BA4\u5185\u5EFA\u7B51\u9762\u79EF', 'Total floor area (sqm)': 'EPC评估的室内建筑面积',
'Number of bedrooms & living rooms': 'EPC\u8BC4\u4F30\u7684\u5B9C\u5C45\u623F\u95F4\u6570', 'Number of bedrooms & living rooms': 'EPC评估的宜居房间数',
'Bedrooms': '\u5728\u7EBF\u623F\u6E90\u4E2D\u7684\u5367\u5BA4\u6570\u91CF', 'Bedrooms': '在线房源中的卧室数量',
'Bathrooms': '\u5728\u7EBF\u623F\u6E90\u4E2D\u7684\u6D74\u5BA4\u6570\u91CF', 'Bathrooms': '在线房源中的浴室数量',
'Construction year': 'EPC\u8BC4\u4F30\u7684\u5EFA\u9020\u5E74\u4EFD', 'Construction year': 'EPC评估的建造年份',
'Date of last transaction': 'Land Registry\u8BB0\u5F55\u7684\u6700\u8FD1\u4E00\u6B21\u9500\u552E\u65E5\u671F', 'Date of last transaction': 'Land Registry记录的最近一次销售日期',
'Listing date': '\u623F\u4EA7\u9996\u6B21\u5728\u7EBF\u4E0A\u5E02\u7684\u65E5\u671F', 'Listing date': '房产首次在线上市的日期',
'Former council house': '\u8BE5\u623F\u4EA7\u662F\u5426\u66FE\u88AB\u8BB0\u5F55\u4E3A\u516C\u5171\u4F4F\u623F', 'Former council house': '该房产是否曾被记录为公共住房',
'Current energy rating': '\u5F53\u524DEPC\u80FD\u6548\u8BC4\u7EA7\uFF08A = \u6700\u4F73\uFF0CG = \u6700\u5DEE\uFF09', 'Current energy rating': '当前EPC能效评级A = 最佳G = 最差)',
'Potential energy rating': '\u5B9E\u65BD\u6240\u6709\u5EFA\u8BAE\u6539\u8FDB\u540E\u7684\u6F5C\u5728EPC\u8BC4\u7EA7', 'Potential energy rating': '实施所有建议改进后的潜在EPC评级',
'Interior height (m)': 'EPC\u8BC4\u4F30\u7684\u5E73\u5747\u5C42\u9AD8', 'Interior height (m)': 'EPC评估的平均层高',
'Distance to nearest train or tube station (km)': '\u5230\u6700\u8FD1\u706B\u8F66\u6216\u5730\u94C1\u7AD9\u7684\u8DDD\u79BB', 'Distance to nearest train or tube station (km)': '到最近火车或地铁站的距离',
'Train or tube stations within 1km': '1\u516C\u91CC\u5185\u706B\u8F66\u6216\u5730\u94C1\u7AD9\u7684\u6570\u91CF', 'Good+ primary schools within 2km': 'Ofsted评为良好或优秀的2公里内小学',
'Good+ primary schools within 2km': 'Ofsted\u8BC4\u4E3A\u826F\u597D\u6216\u4F18\u79C0\u76842\u516C\u91CC\u5185\u5C0F\u5B66', 'Good+ secondary schools within 2km': 'Ofsted评为良好或优秀的2公里内中学',
'Good+ secondary schools within 2km': 'Ofsted\u8BC4\u4E3A\u826F\u597D\u6216\u4F18\u79C0\u76842\u516C\u91CC\u5185\u4E2D\u5B66', 'Good+ primary schools within 5km': 'Ofsted评为良好或优秀的5公里内小学',
'Good+ primary schools within 5km': 'Ofsted\u8BC4\u4E3A\u826F\u597D\u6216\u4F18\u79C0\u76845\u516C\u91CC\u5185\u5C0F\u5B66', 'Good+ secondary schools within 5km': 'Ofsted评为良好或优秀的5公里内中学',
'Good+ secondary schools within 5km': 'Ofsted\u8BC4\u4E3A\u826F\u597D\u6216\u4F18\u79C0\u76845\u516C\u91CC\u5185\u4E2D\u5B66', 'Education, Skills and Training Score': '当地教育质量得分(越高越好)',
'Education, Skills and Training Score': '\u5F53\u5730\u6559\u80B2\u8D28\u91CF\u5F97\u5206\uFF08\u8D8A\u9AD8\u8D8A\u597D\uFF09', 'Income Score (rate)': '收入贫困率,反向指标(越高越不贫困)',
'Income Score (rate)': '\u6536\u5165\u8D2B\u56F0\u7387\uFF0C\u53CD\u5411\u6307\u6807\uFF08\u8D8A\u9AD8\u8D8A\u4E0D\u8D2B\u56F0\uFF09', 'Employment Score (rate)': '就业贫困率,反向指标(越高越不贫困)',
'Employment Score (rate)': '\u5C31\u4E1A\u8D2B\u56F0\u7387\uFF0C\u53CD\u5411\u6307\u6807\uFF08\u8D8A\u9AD8\u8D8A\u4E0D\u8D2B\u56F0\uFF09', 'Health Deprivation and Disability Score': '健康与残障得分(越高健康状况越好)',
'Health Deprivation and Disability Score': '\u5065\u5EB7\u4E0E\u6B8B\u969C\u5F97\u5206\uFF08\u8D8A\u9AD8\u5065\u5EB7\u72B6\u51B5\u8D8A\u597D\uFF09', 'Living Environment Score': '室内外环境质量(越高越好)',
'Living Environment Score': '\u5BA4\u5185\u5916\u73AF\u5883\u8D28\u91CF\uFF08\u8D8A\u9AD8\u8D8A\u597D\uFF09', 'Indoors Sub-domain Score': '住房质量和状况(越高越好)',
'Indoors Sub-domain Score': '\u4F4F\u623F\u8D28\u91CF\u548C\u72B6\u51B5\uFF08\u8D8A\u9AD8\u8D8A\u597D\uFF09', 'Outdoors Sub-domain Score': '空气质量和道路安全(越高越好)',
'Outdoors Sub-domain Score': '\u7A7A\u6C14\u8D28\u91CF\u548C\u9053\u8DEF\u5B89\u5168\uFF08\u8D8A\u9AD8\u8D8A\u597D\uFF09', 'Serious crime per 1k residents (avg/yr)': '每千人每年严重犯罪率',
'Serious crime per 1k residents (avg/yr)': '\u6BCF\u5343\u4EBA\u6BCF\u5E74\u4E25\u91CD\u72AF\u7F6A\u7387', 'Minor crime per 1k residents (avg/yr)': '每千人每年轻微犯罪率',
'Minor crime per 1k residents (avg/yr)': '\u6BCF\u5343\u4EBA\u6BCF\u5E74\u8F7B\u5FAE\u72AF\u7F6A\u7387', 'Serious crime (avg/yr)': '严重犯罪类别年度总计',
'Serious crime (avg/yr)': '\u4E25\u91CD\u72AF\u7F6A\u7C7B\u522B\u5E74\u5EA6\u603B\u8BA1', 'Minor crime (avg/yr)': '轻微犯罪类别年度总计',
'Minor crime (avg/yr)': '\u8F7B\u5FAE\u72AF\u7F6A\u7C7B\u522B\u5E74\u5EA6\u603B\u8BA1', 'Violence and sexual offences (avg/yr)': '该地区年均暴力和性犯罪数',
'Violence and sexual offences (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u66B4\u529B\u548C\u6027\u72AF\u7F6A\u6570', 'Burglary (avg/yr)': '该地区年均入室盗窃数',
'Burglary (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u5165\u5BA4\u76D7\u7A83\u6570', 'Robbery (avg/yr)': '该地区年均抢劫数',
'Robbery (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u62A2\u52AB\u6570', 'Vehicle crime (avg/yr)': '该地区年均车辆犯罪数',
'Vehicle crime (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u8F66\u8F86\u72AF\u7F6A\u6570', 'Anti-social behaviour (avg/yr)': '该地区年均反社会行为数',
'Anti-social behaviour (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u53CD\u793E\u4F1A\u884C\u4E3A\u6570', 'Criminal damage and arson (avg/yr)': '该地区年均刑事毁坏和纵火数',
'Criminal damage and arson (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u5211\u4E8B\u6BC1\u574F\u548C\u7EB5\u706B\u6570', 'Other theft (avg/yr)': '该地区年均其他盗窃数',
'Other theft (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u5176\u4ED6\u76D7\u7A83\u6570', 'Theft from the person (avg/yr)': '该地区年均人身盗窃数',
'Theft from the person (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u4EBA\u8EAB\u76D7\u7A83\u6570', 'Shoplifting (avg/yr)': '该地区年均商店盗窃数',
'Shoplifting (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u5546\u5E97\u76D7\u7A83\u6570', 'Bicycle theft (avg/yr)': '该地区年均自行车盗窃数',
'Bicycle theft (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u81EA\u884C\u8F66\u76D7\u7A83\u6570', 'Drugs (avg/yr)': '该地区年均毒品犯罪数',
'Drugs (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u6BD2\u54C1\u72AF\u7F6A\u6570', 'Possession of weapons (avg/yr)': '该地区年均非法持有武器数',
'Possession of weapons (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u975E\u6CD5\u6301\u6709\u6B66\u5668\u6570', 'Public order (avg/yr)': '该地区年均扰乱公共秩序数',
'Public order (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u6270\u4E71\u516C\u5171\u79E9\u5E8F\u6570', 'Other crime (avg/yr)': '该地区年均其他犯罪数',
'Other crime (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u5176\u4ED6\u72AF\u7F6A\u6570', 'Median age': '当地人口的中位年龄',
'Median age': '\u5F53\u5730\u4EBA\u53E3\u7684\u4E2D\u4F4D\u5E74\u9F84', '% White': '白人人口比例',
'% White': '\u767D\u4EBA\u4EBA\u53E3\u6BD4\u4F8B', '% South Asian': '南亚裔人口比例',
'% South Asian': '\u5357\u4E9A\u88D4\u4EBA\u53E3\u6BD4\u4F8B', '% Black': '黑人人口比例',
'% Black': '\u9ED1\u4EBA\u4EBA\u53E3\u6BD4\u4F8B', '% East Asian': '东亚裔人口比例',
'% East Asian': '\u4E1C\u4E9A\u88D4\u4EBA\u53E3\u6BD4\u4F8B', '% Mixed': '混血或多族裔人口比例',
'% Mixed': '\u6DF7\u8840\u6216\u591A\u65CF\u88D4\u4EBA\u53E3\u6BD4\u4F8B', '% Other': '其他族裔人口比例',
'% Other': '\u5176\u4ED6\u65CF\u88D4\u4EBA\u53E3\u6BD4\u4F8B', 'Distance to nearest park (km)': '到最近公园或绿地的距离',
'Distance to nearest park (km)': '\u5230\u6700\u8FD1\u516C\u56ED\u6216\u7EFF\u5730\u7684\u8DDD\u79BB', 'Number of parks within 2km': '2公里内公园和绿地数量',
'Number of parks within 2km': '2\u516C\u91CC\u5185\u516C\u56ED\u548C\u7EFF\u5730\u6570\u91CF', 'Number of restaurants within 2km': '2公里内餐厅和咖啡馆数量',
'Number of restaurants within 2km': '2\u516C\u91CC\u5185\u9910\u5385\u548C\u5496\u5561\u9986\u6570\u91CF', 'Number of grocery shops and supermarkets within 2km': '2公里内食品店和超市数量',
'Number of grocery shops and supermarkets within 2km': '2\u516C\u91CC\u5185\u98DF\u54C1\u5E97\u548C\u8D85\u5E02\u6570\u91CF', 'Noise (dB)': '该邮编的道路噪音水平分贝Lden',
'Noise (dB)': '\u8BE5\u90AE\u7F16\u7684\u9053\u8DEF\u566A\u97F3\u6C34\u5E73\uFF08\u5206\u8D1D\uFF0CLden\uFF09', 'Max available download speed (Mbps)': '该邮编可用的最大宽带下载速度',
'Max available download speed (Mbps)': '\u8BE5\u90AE\u7F16\u53EF\u7528\u7684\u6700\u5927\u5BBD\u5E26\u4E0B\u8F7D\u901F\u5EA6',
}, },
hu: { hu: {
'Listing status': 'Az ingatlan kor\u00E1bbi elad\u00E1sb\u00F3l sz\u00E1rmazik, jelenleg elad\u00F3 vagy kiad\u00F3', 'Listing status': 'Az ingatlan korábbi eladásból származik, jelenleg eladó vagy kiadó',
'Property type': 'Ingatlant\u00EDpus: k\u00FCl\u00F6n\u00E1ll\u00F3, ikerh\u00E1z, sorh\u00E1z, lak\u00E1s vagy egy\u00E9b', 'Property type': 'Ingatlantípus: különálló, ikerház, sorház, lakás vagy egyéb',
'Leasehold/Freehold': 'Az ingatlan b\u00E9rleti jog\u00FA vagy teljes tulajdon\u00FA', 'Leasehold/Freehold': 'Az ingatlan bérleti jogú vagy teljes tulajdonú',
'Last known price': 'A Land Registry-ben r\u00F6gz\u00EDtett utols\u00F3 elad\u00E1si \u00E1r', 'Last known price': 'A Land Registry-ben rögzített utolsó eladási ár',
'Estimated current price': 'Infl\u00E1ci\u00F3val korrig\u00E1lt becs\u00FClt jelenlegi \u00E9rt\u00E9k', 'Estimated current price': 'Inflációval korrigált becsült jelenlegi érték',
'Asking price': 'A jelenleg elad\u00E1sra k\u00EDn\u00E1lt ingatlanok ir\u00E1ny\u00E1ra', 'Asking price': 'A jelenleg eladásra kínált ingatlanok irányára',
'Price per sqm': 'Elad\u00E1si \u00E1r osztva az \u00F6sszes alapter\u00FClettel', 'Price per sqm': 'Eladási ár osztva az összes alapterülettel',
'Est. price per sqm': 'Becs\u00FClt jelenlegi \u00E1r osztva az \u00F6sszes alapter\u00FClettel', 'Est. price per sqm': 'Becsült jelenlegi ár osztva az összes alapterülettel',
'Asking price per sqm': 'Ir\u00E1ny\u00E1r osztva az \u00F6sszes alapter\u00FClettel', 'Asking price per sqm': 'Irányár osztva az összes alapterülettel',
'Estimated monthly rent': 'A k\u00F6rny\u00E9k medi\u00E1n havi mag\u00E1nb\u00E9rleti d\u00EDja', 'Estimated monthly rent': 'A környék medián havi magánbérleti díja',
'Asking rent (monthly)': 'A kiad\u00F3 ingatlanok hirdetett havi b\u00E9rleti d\u00EDja', 'Asking rent (monthly)': 'A kiadó ingatlanok hirdetett havi bérleti díja',
'Total floor area (sqm)': 'Az EPC felm\u00E9r\u00E9sb\u0151l sz\u00E1rmaz\u00F3 bels\u0151 alapter\u00FClet', 'Total floor area (sqm)': 'Az EPC felmérésből származó belső alapterület',
'Number of bedrooms & living rooms': 'Lak\u00F3szob\u00E1k sz\u00E1ma az EPC felm\u00E9r\u00E9s alapj\u00E1n', 'Number of bedrooms & living rooms': 'Lakószobák száma az EPC felmérés alapján',
'Bedrooms': 'H\u00E1l\u00F3szob\u00E1k sz\u00E1ma az online hirdet\u00E9s szerint', 'Bedrooms': 'Hálószobák száma az online hirdetés szerint',
'Bathrooms': 'F\u00FCrd\u0151szob\u00E1k sz\u00E1ma az online hirdet\u00E9s szerint', 'Bathrooms': 'Fürdőszobák száma az online hirdetés szerint',
'Construction year': 'Becs\u00FClt \u00E9p\u00EDt\u00E9si \u00E9v az EPC alapj\u00E1n', 'Construction year': 'Becsült építési év az EPC alapján',
'Date of last transaction': 'Az utols\u00F3 elad\u00E1s d\u00E1tuma a Land Registry szerint', 'Date of last transaction': 'Az utolsó eladás dátuma a Land Registry szerint',
'Listing date': 'Az ingatlan els\u0151 online megjelen\u00E9s\u00E9nek d\u00E1tuma', 'Listing date': 'Az ingatlan első online megjelenésének dátuma',
'Former council house': 'Az ingatlan szerepelt-e valaha \u00F6nkorm\u00E1nyzati lak\u00E1sk\u00E9nt', 'Former council house': 'Az ingatlan szerepelt-e valaha önkormányzati lakásként',
'Current energy rating': 'Jelenlegi EPC energiabesorol\u00E1s (A = legjobb, G = legrosszabb)', 'Current energy rating': 'Jelenlegi EPC energiabesorolás (A = legjobb, G = legrosszabb)',
'Potential energy rating': 'Potenci\u00E1lis EPC besorol\u00E1s az \u00F6sszes javasolt fejleszt\u00E9s elv\u00E9gz\u00E9se ut\u00E1n', 'Potential energy rating': 'Potenciális EPC besorolás az összes javasolt fejlesztés elvégzése után',
'Interior height (m)': '\u00C1tlagos belmagass\u00E1g az EPC felm\u00E9r\u00E9s alapj\u00E1n', 'Interior height (m)': 'Átlagos belmagasság az EPC felmérés alapján',
'Distance to nearest train or tube station (km)': 'T\u00E1vols\u00E1g a legk\u00F6zelebbi vas\u00FAt- vagy metr\u00F3\u00E1llom\u00E1sig', 'Distance to nearest train or tube station (km)': 'Távolság a legközelebbi vasút- vagy metróállomásig',
'Train or tube stations within 1km': 'Vas\u00FAt- vagy metr\u00F3\u00E1llom\u00E1sok sz\u00E1ma 1 km-en bel\u00FCl', 'Good+ primary schools within 2km': 'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 2 km-en belül',
'Good+ primary schools within 2km': 'Ofsted \u00E1ltal J\u00F3 vagy Kiv\u00E1l\u00F3 min\u0151s\u00EDt\u00E9s\u0171 \u00E1ltal\u00E1nos iskol\u00E1k 2 km-en bel\u00FCl', 'Good+ secondary schools within 2km': 'Ofsted által Jó vagy Kiváló minősítésű középiskolák 2 km-en belül',
'Good+ secondary schools within 2km': 'Ofsted \u00E1ltal J\u00F3 vagy Kiv\u00E1l\u00F3 min\u0151s\u00EDt\u00E9s\u0171 k\u00F6z\u00E9piskol\u00E1k 2 km-en bel\u00FCl', 'Good+ primary schools within 5km': 'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 5 km-en belül',
'Good+ primary schools within 5km': 'Ofsted \u00E1ltal J\u00F3 vagy Kiv\u00E1l\u00F3 min\u0151s\u00EDt\u00E9s\u0171 \u00E1ltal\u00E1nos iskol\u00E1k 5 km-en bel\u00FCl', 'Good+ secondary schools within 5km': 'Ofsted által Jó vagy Kiváló minősítésű középiskolák 5 km-en belül',
'Good+ secondary schools within 5km': 'Ofsted \u00E1ltal J\u00F3 vagy Kiv\u00E1l\u00F3 min\u0151s\u00EDt\u00E9s\u0171 k\u00F6z\u00E9piskol\u00E1k 5 km-en bel\u00FCl', 'Education, Skills and Training Score': 'A környék oktatási minőségi pontszáma (magasabb = jobb)',
'Education, Skills and Training Score': 'A k\u00F6rny\u00E9k oktat\u00E1si min\u0151s\u00E9gi pontsz\u00E1ma (magasabb = jobb)', 'Income Score (rate)': 'Jövedelmi deprivációs ráta, invertálva (magasabb = kevésbé hátrányos)',
'Income Score (rate)': 'J\u00F6vedelmi depriv\u00E1ci\u00F3s r\u00E1ta, invert\u00E1lva (magasabb = kev\u00E9sb\u00E9 h\u00E1tr\u00E1nyos)', 'Employment Score (rate)': 'Foglalkoztatási deprivációs ráta, invertálva (magasabb = kevésbé hátrányos)',
'Employment Score (rate)': 'Foglalkoztat\u00E1si depriv\u00E1ci\u00F3s r\u00E1ta, invert\u00E1lva (magasabb = kev\u00E9sb\u00E9 h\u00E1tr\u00E1nyos)', 'Health Deprivation and Disability Score': 'Egészségügyi és fogyatékossági pontszám (magasabb = jobb eredmények)',
'Health Deprivation and Disability Score': 'Eg\u00E9szs\u00E9g\u00FCgyi \u00E9s fogyat\u00E9koss\u00E1gi pontsz\u00E1m (magasabb = jobb eredm\u00E9nyek)', 'Living Environment Score': 'Belső és külső környezet minősége (magasabb = jobb)',
'Living Environment Score': 'Bels\u0151 \u00E9s k\u00FCls\u0151 k\u00F6rnyezet min\u0151s\u00E9ge (magasabb = jobb)', 'Indoors Sub-domain Score': 'Lakásminőség és állapot (magasabb = jobb)',
'Indoors Sub-domain Score': 'Lak\u00E1smin\u0151s\u00E9g \u00E9s \u00E1llapot (magasabb = jobb)', 'Outdoors Sub-domain Score': 'Levegőminőség és közlekedésbiztonság (magasabb = jobb)',
'Outdoors Sub-domain Score': 'Leveg\u0151min\u0151s\u00E9g \u00E9s k\u00F6zleked\u00E9sbiztons\u00E1g (magasabb = jobb)', 'Serious crime per 1k residents (avg/yr)': 'Súlyos bűncselekmények aránya 1000 lakosra évente',
'Serious crime per 1k residents (avg/yr)': 'S\u00FAlyos b\u0171ncselekm\u00E9nyek ar\u00E1nya 1000 lakosra \u00E9vente', 'Minor crime per 1k residents (avg/yr)': 'Kisebb bűncselekmények aránya 1000 lakosra évente',
'Minor crime per 1k residents (avg/yr)': 'Kisebb b\u0171ncselekm\u00E9nyek ar\u00E1nya 1000 lakosra \u00E9vente', 'Serious crime (avg/yr)': 'Súlyos bűncselekményi kategóriák éves összesítése',
'Serious crime (avg/yr)': 'S\u00FAlyos b\u0171ncselekm\u00E9nyi kateg\u00F3ri\u00E1k \u00E9ves \u00F6sszes\u00EDt\u00E9se', 'Minor crime (avg/yr)': 'Kisebb bűncselekményi kategóriák éves összesítése',
'Minor crime (avg/yr)': 'Kisebb b\u0171ncselekm\u00E9nyi kateg\u00F3ri\u00E1k \u00E9ves \u00F6sszes\u00EDt\u00E9se', 'Violence and sexual offences (avg/yr)': 'Erőszakos és szexuális bűncselekmények éves átlaga a környéken',
'Violence and sexual offences (avg/yr)': 'Er\u0151szakos \u00E9s szexu\u00E1lis b\u0171ncselekm\u00E9nyek \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken', 'Burglary (avg/yr)': 'Betörések éves átlaga a környéken',
'Burglary (avg/yr)': 'Bet\u00F6r\u00E9sek \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken', 'Robbery (avg/yr)': 'Rablások éves átlaga a környéken',
'Robbery (avg/yr)': 'Rabl\u00E1sok \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken', 'Vehicle crime (avg/yr)': 'Gépjárművel kapcsolatos bűncselekmények éves átlaga a környéken',
'Vehicle crime (avg/yr)': 'G\u00E9pj\u00E1rm\u0171vel kapcsolatos b\u0171ncselekm\u00E9nyek \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken', 'Anti-social behaviour (avg/yr)': 'Közösségellenes magatartás éves átlaga a környéken',
'Anti-social behaviour (avg/yr)': 'K\u00F6z\u00F6ss\u00E9gellenes magatart\u00E1s \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken', 'Criminal damage and arson (avg/yr)': 'Rongálás és gyújtogatás éves átlaga a környéken',
'Criminal damage and arson (avg/yr)': 'Rong\u00E1l\u00E1s \u00E9s gy\u00FAjtogat\u00E1s \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken', 'Other theft (avg/yr)': 'Egyéb lopások éves átlaga a környéken',
'Other theft (avg/yr)': 'Egy\u00E9b lop\u00E1sok \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken', 'Theft from the person (avg/yr)': 'Személyek elleni lopások éves átlaga a környéken',
'Theft from the person (avg/yr)': 'Szem\u00E9lyek elleni lop\u00E1sok \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken', 'Shoplifting (avg/yr)': 'Bolti lopások éves átlaga a környéken',
'Shoplifting (avg/yr)': 'Bolti lop\u00E1sok \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken', 'Bicycle theft (avg/yr)': 'Kerékpárlopások éves átlaga a környéken',
'Bicycle theft (avg/yr)': 'Ker\u00E9kp\u00E1rlop\u00E1sok \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken', 'Drugs (avg/yr)': 'Kábítószerrel kapcsolatos bűncselekmények éves átlaga a környéken',
'Drugs (avg/yr)': 'K\u00E1b\u00EDt\u00F3szerrel kapcsolatos b\u0171ncselekm\u00E9nyek \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken', 'Possession of weapons (avg/yr)': 'Fegyvertartással kapcsolatos bűncselekmények éves átlaga a környéken',
'Possession of weapons (avg/yr)': 'Fegyvertart\u00E1ssal kapcsolatos b\u0171ncselekm\u00E9nyek \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken', 'Public order (avg/yr)': 'Közrend elleni bűncselekmények éves átlaga a környéken',
'Public order (avg/yr)': 'K\u00F6zrend elleni b\u0171ncselekm\u00E9nyek \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken', 'Other crime (avg/yr)': 'Egyéb bűncselekmények éves átlaga a környéken',
'Other crime (avg/yr)': 'Egy\u00E9b b\u0171ncselekm\u00E9nyek \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken', 'Median age': 'A helyi lakosság medián életkora',
'Median age': 'A helyi lakoss\u00E1g medi\u00E1n \u00E9letkora', '% White': 'A fehérként azonosított lakosság aránya',
'% White': 'A feh\u00E9rk\u00E9nt azonos\u00EDtott lakoss\u00E1g ar\u00E1nya', '% South Asian': 'A dél-ázsiaiként azonosított lakosság aránya',
'% South Asian': 'A d\u00E9l-\u00E1zsiaiként azonos\u00EDtott lakoss\u00E1g ar\u00E1nya', '% Black': 'A feketeként azonosított lakosság aránya',
'% Black': 'A feketek\u00E9nt azonos\u00EDtott lakoss\u00E1g ar\u00E1nya', '% East Asian': 'A kelet-ázsiaiként azonosított lakosság aránya',
'% East Asian': 'A kelet-\u00E1zsiaiként azonos\u00EDtott lakoss\u00E1g ar\u00E1nya', '% Mixed': 'A vegyes vagy több etnikai csoporthoz tartozóként azonosított lakosság aránya',
'% Mixed': 'A vegyes vagy t\u00F6bb etnikai csoporthoz tartoz\u00F3k\u00E9nt azonos\u00EDtott lakoss\u00E1g ar\u00E1nya', '% Other': 'Az egyéb etnikai csoportba tartozóként azonosított lakosság aránya',
'% Other': 'Az egy\u00E9b etnikai csoportba tartoz\u00F3k\u00E9nt azonos\u00EDtott lakoss\u00E1g ar\u00E1nya', 'Distance to nearest park (km)': 'Távolság a legközelebbi parkig vagy zöldterületig',
'Distance to nearest park (km)': 'T\u00E1vols\u00E1g a legk\u00F6zelebbi parkig vagy z\u00F6ldter\u00FCletig', 'Number of parks within 2km': 'Parkok és zöldterületek száma 2 km-en belül',
'Number of parks within 2km': 'Parkok \u00E9s z\u00F6ldter\u00FCletek sz\u00E1ma 2 km-en bel\u00FCl', 'Number of restaurants within 2km': 'Éttermek és kávézók száma 2 km-en belül',
'Number of restaurants within 2km': '\u00C9ttermek \u00E9s k\u00E1v\u00E9z\u00F3k sz\u00E1ma 2 km-en bel\u00FCl', 'Number of grocery shops and supermarkets within 2km': 'Élelmiszerboltok és szupermarketek száma 2 km-en belül',
'Number of grocery shops and supermarkets within 2km': '\u00C9lelmiszerboltok \u00E9s szupermarketek sz\u00E1ma 2 km-en bel\u00FCl', 'Noise (dB)': 'Közúti zajszint az irányítószámnál decibelben (Lden)',
'Noise (dB)': 'K\u00F6z\u00FAti zajszint az ir\u00E1ny\u00EDt\u00F3sz\u00E1mn\u00E1l decibelben (Lden)', 'Max available download speed (Mbps)': 'Az irányítószámnál elérhető maximális szélessávú letöltési sebesség',
'Max available download speed (Mbps)': 'Az ir\u00E1ny\u00EDt\u00F3sz\u00E1mn\u00E1l el\u00E9rhet\u0151 maxim\u00E1lis sz\u00E9less\u00E1v\u00FA let\u00F6lt\u00E9si sebess\u00E9g',
}, },
}; };

View file

@ -7,11 +7,11 @@ import hu from './locales/hu';
import zh from './locales/zh'; import zh from './locales/zh';
export const SUPPORTED_LANGUAGES = [ export const SUPPORTED_LANGUAGES = [
{ code: 'en', label: 'English', flag: '\uD83C\uDDEC\uD83C\uDDE7' }, { code: 'en', label: 'English', flag: '\uD83C\uDDEC\uD83C\uDDE7' },
{ code: 'fr', label: 'Fran\u00E7ais', flag: '\uD83C\uDDEB\uD83C\uDDF7' }, { code: 'fr', label: 'Fran\u00E7ais', flag: '\uD83C\uDDEB\uD83C\uDDF7' },
{ code: 'de', label: 'Deutsch', flag: '\uD83C\uDDE9\uD83C\uDDEA' }, { code: 'de', label: 'Deutsch', flag: '\uD83C\uDDE9\uD83C\uDDEA' },
{ code: 'hu', label: 'Magyar', flag: '\uD83C\uDDED\uD83C\uDDFA' }, { code: 'hu', label: 'Magyar', flag: '\uD83C\uDDED\uD83C\uDDFA' },
{ code: 'zh', label: '\u4E2D\u6587', flag: '\uD83C\uDDE8\uD83C\uDDF3' }, { code: 'zh', label: '\u4E2D\u6587', flag: '\uD83C\uDDE8\uD83C\uDDF3' },
] as const; ] as const;
export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]['code']; export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]['code'];
@ -19,37 +19,37 @@ export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]['code'];
const supportedCodes: Set<string> = new Set(SUPPORTED_LANGUAGES.map((l) => l.code)); const supportedCodes: Set<string> = new Set(SUPPORTED_LANGUAGES.map((l) => l.code));
function detectLanguage(): string { function detectLanguage(): string {
// 1. Explicit user choice (persisted from the language dropdown) // 1. Explicit user choice (persisted from the language dropdown)
const stored = localStorage.getItem('language'); const stored = localStorage.getItem('language');
if (stored && supportedCodes.has(stored)) return stored; if (stored && supportedCodes.has(stored)) return stored;
// 2. Browser preference (navigator.languages falls back to navigator.language) // 2. Browser preference (navigator.languages falls back to navigator.language)
for (const tag of navigator.languages ?? [navigator.language]) { for (const tag of navigator.languages ?? [navigator.language]) {
// Match full tag first (e.g. "zh-CN" → "zh"), then just the prefix // Match full tag first (e.g. "zh-CN" → "zh"), then just the prefix
const lower = tag.toLowerCase(); const lower = tag.toLowerCase();
if (supportedCodes.has(lower)) return lower; if (supportedCodes.has(lower)) return lower;
const prefix = lower.split('-')[0]; const prefix = lower.split('-')[0];
if (supportedCodes.has(prefix)) return prefix; if (supportedCodes.has(prefix)) return prefix;
} }
return 'en'; return 'en';
} }
const initialLang = detectLanguage(); const initialLang = detectLanguage();
i18n.use(initReactI18next).init({ i18n.use(initReactI18next).init({
resources: { resources: {
en: { translation: en }, en: { translation: en },
fr: { translation: fr }, fr: { translation: fr },
de: { translation: de }, de: { translation: de },
hu: { translation: hu }, hu: { translation: hu },
zh: { translation: zh }, zh: { translation: zh },
}, },
lng: initialLang, lng: initialLang,
fallbackLng: 'en', fallbackLng: 'en',
interpolation: { interpolation: {
escapeValue: false, // React already escapes escapeValue: false, // React already escapes
}, },
}); });
/** /**
@ -57,8 +57,8 @@ i18n.use(initReactI18next).init({
* Bypasses the strict type checking on t() for dynamic key construction. * Bypasses the strict type checking on t() for dynamic key construction.
*/ */
export function tDynamic(key: string): string { export function tDynamic(key: string): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
return (i18n.t as any)(key); return (i18n.t as any)(key);
} }
export default i18n; export default i18n;

View file

@ -5,9 +5,9 @@ const de: Translations = {
common: { common: {
save: 'Speichern', save: 'Speichern',
cancel: 'Abbrechen', cancel: 'Abbrechen',
close: 'Schlie\u00DFen', close: 'Schließen',
delete: 'L\u00F6schen', delete: 'Löschen',
open: '\u00D6ffnen', open: 'Öffnen',
share: 'Teilen', share: 'Teilen',
copy: 'Kopieren', copy: 'Kopieren',
copied: 'Kopiert!', copied: 'Kopiert!',
@ -25,10 +25,10 @@ const de: Translations = {
area: 'Gebiet', area: 'Gebiet',
properties: 'Immobilien', properties: 'Immobilien',
postcode: 'Postleitzahl', postcode: 'Postleitzahl',
noAreaSelected: 'Kein Gebiet ausgew\u00E4hlt', noAreaSelected: 'Kein Gebiet ausgewählt',
noAreaSelectedDesc: noAreaSelectedDesc:
'Klicke auf ein farbiges Gebiet auf der Karte, um Kriminalit\u00E4t, Schulen, Preise und mehr zu sehen', 'Klicke auf ein farbiges Gebiet auf der Karte, um Kriminalität, Schulen, Preise und mehr zu sehen',
clickForDetails: 'F\u00FCr Details klicken', clickForDetails: 'Für Details klicken',
property: 'Immobilie', property: 'Immobilie',
propertiesPlural: 'Immobilien', propertiesPlural: 'Immobilien',
}, },
@ -36,7 +36,7 @@ const de: Translations = {
// ── Header / Nav ─────────────────────────────────── // ── Header / Nav ───────────────────────────────────
header: { header: {
appName: 'Perfect Postcode', appName: 'Perfect Postcode',
dashboard: '\u00DCbersicht', dashboard: 'Übersicht',
learn: 'Infos', learn: 'Infos',
pricing: 'Preise', pricing: 'Preise',
inviteFriends: 'Freunde einladen', inviteFriends: 'Freunde einladen',
@ -47,8 +47,8 @@ const de: Translations = {
exportLabel: 'Exportieren', exportLabel: 'Exportieren',
exporting: 'Wird exportiert...', exporting: 'Wird exportiert...',
exportToExcel: 'Als Excel exportieren', exportToExcel: 'Als Excel exportieren',
openMenu: 'Men\u00FC \u00F6ffnen', openMenu: 'Menü öffnen',
closeMenu: 'Men\u00FC schlie\u00DFen', closeMenu: 'Menü schließen',
}, },
// ── User Menu ────────────────────────────────────── // ── User Menu ──────────────────────────────────────
@ -63,7 +63,7 @@ const de: Translations = {
// ── Mobile Menu ──────────────────────────────────── // ── Mobile Menu ────────────────────────────────────
mobileMenu: { mobileMenu: {
menu: 'Men\u00FC', menu: 'Menü',
home: 'Startseite', home: 'Startseite',
}, },
@ -71,9 +71,9 @@ const de: Translations = {
auth: { auth: {
logIn: 'Anmelden', logIn: 'Anmelden',
createAccount: 'Konto erstellen', createAccount: 'Konto erstellen',
resetPassword: 'Passwort zur\u00FCcksetzen', resetPassword: 'Passwort zurücksetzen',
valueProp: valueProp:
'Speichere Suchen, merke dir Immobilien und mach dort weiter, wo du aufgeh\u00F6rt hast.', 'Speichere Suchen, merke dir Immobilien und mach dort weiter, wo du aufgehört hast.',
continueWithGoogle: 'Weiter mit Google', continueWithGoogle: 'Weiter mit Google',
email: 'E-Mail', email: 'E-Mail',
emailPlaceholder: 'du@beispiel.de', emailPlaceholder: 'du@beispiel.de',
@ -81,24 +81,24 @@ const de: Translations = {
passwordPlaceholderRegister: 'Mind. 8 Zeichen', passwordPlaceholderRegister: 'Mind. 8 Zeichen',
passwordPlaceholderLogin: 'Dein Passwort', passwordPlaceholderLogin: 'Dein Passwort',
forgotPassword: 'Passwort vergessen?', forgotPassword: 'Passwort vergessen?',
resetSent: 'Pr\u00FCfe deine E-Mails f\u00FCr einen Link zum Zur\u00FCcksetzen.', resetSent: 'Prüfe deine E-Mails für einen Link zum Zurücksetzen.',
pleaseWait: 'Bitte warten...', pleaseWait: 'Bitte warten...',
sendResetLink: 'Link zum Zur\u00FCcksetzen senden', sendResetLink: 'Link zum Zurücksetzen senden',
backToLogin: 'Zur\u00FCck zur Anmeldung', backToLogin: 'Zurück zur Anmeldung',
}, },
// ── Upgrade Modal ────────────────────────────────── // ── Upgrade Modal ──────────────────────────────────
upgrade: { upgrade: {
title: 'Ganz England entdecken', title: 'Ganz England entdecken',
description: description:
'Du erkundest gerade das Demogebiet. Erhalte lebenslangen Zugang zu jeder Postleitzahl, jedem Filter, jedem Viertel. Eine Zahlung, f\u00FCr immer.', 'Du erkundest gerade das Demogebiet. Erhalte lebenslangen Zugang zu jeder Postleitzahl, jedem Filter, jedem Viertel. Eine Zahlung, für immer.',
free: 'Kostenlos', free: 'Kostenlos',
once: '/einmalig', once: '/einmalig',
freeForEarly: 'Kostenlos f\u00FCr Fr\u00FChnutzer. Keine Kreditkarte erforderlich.', freeForEarly: 'Kostenlos für Frühnutzer. Keine Kreditkarte erforderlich.',
oneTimePayment: 'Einmalzahlung. Lebenslanger Zugang. 30 Tage Geld-zur\u00FCck-Garantie.', oneTimePayment: 'Einmalzahlung. Lebenslanger Zugang. 30 Tage Geld-zurück-Garantie.',
redirecting: 'Weiterleitung...', redirecting: 'Weiterleitung...',
claimFreeAccess: 'Kostenlosen Zugang sichern', claimFreeAccess: 'Kostenlosen Zugang sichern',
upgradeFor: 'Upgrade f\u00FCr {{price}}', upgradeFor: 'Upgrade für {{price}}',
registerAndUpgrade: 'Registrieren & Upgraden', registerAndUpgrade: 'Registrieren & Upgraden',
alreadyHaveAccount: 'Bereits ein Konto? Anmelden', alreadyHaveAccount: 'Bereits ein Konto? Anmelden',
continueWithDemo: 'Mit Demo fortfahren', continueWithDemo: 'Mit Demo fortfahren',
@ -128,83 +128,88 @@ const de: Translations = {
// ── Filters ──────────────────────────────────────── // ── Filters ────────────────────────────────────────
filters: { filters: {
activeFilters: 'Aktive Filter', activeFilters: 'Aktive Filter',
addFilter: 'Filter hinzuf\u00FCgen', addFilter: 'Filter hinzufügen',
historical: 'Historisch', historical: 'Historisch',
buy: 'Kaufen', buy: 'Kaufen',
rent: 'Mieten', rent: 'Mieten',
findingPerfectPostcode: 'Die perfekte Postleitzahl finden', findingPerfectPostcode: 'Die perfekte Postleitzahl finden',
addFiltersHint: addFiltersHint:
'F\u00FCge unten Filter hinzu, um die Karte auf Gebiete einzugrenzen, die deinen Kriterien entsprechen', 'Füge unten Filter hinzu, um die Karte auf Gebiete einzugrenzen, die deinen Kriterien entsprechen',
upgradePrompt: upgradePrompt:
'Sieh Kriminalit\u00E4t, Schulen, L\u00E4rm, Breitband und 50+ weitere Filter f\u00FCr ganz England.', 'Sieh Kriminalität, Schulen, Lärm, Breitband und 50+ weitere Filter für ganz England.',
oneTimeLifetime: 'Einmalzahlung, lebenslanger Zugang.', oneTimeLifetime: 'Einmalzahlung, lebenslanger Zugang.',
upgradeToFullMap: 'Zur Vollversion upgraden', upgradeToFullMap: 'Zur Vollversion upgraden',
chooseFilters: chooseFilters:
'W\u00E4hle die Filter, die dir wichtig sind. Die Karte aktualisiert sich sofort.', 'Wähle die Filter, die dir wichtig sind. Die Karte aktualisiert sich sofort.',
searchFeatures: 'Filter durchsuchen...', searchFeatures: 'Filter durchsuchen...',
noMatchingFeatures: 'Keine passenden Filter', noMatchingFeatures: 'Keine passenden Filter',
tryDifferentSearch: 'Versuche einen anderen Suchbegriff', tryDifferentSearch: 'Versuche einen anderen Suchbegriff',
allFeaturesActive: 'Alle Filter sind aktiv', allFeaturesActive: 'Alle Filter sind aktiv',
removeFilterHint: 'Entferne einen Filter, um verf\u00FCgbare Merkmale zu sehen', removeFilterHint: 'Entferne einen Filter, um verfügbare Merkmale zu sehen',
featureInfo: 'Filterinfo', featureInfo: 'Filterinfo',
replayTutorial: 'Interaktives Tutorial erneut abspielen', replayTutorial: 'Interaktives Tutorial erneut abspielen',
clearAll: 'Alle löschen',
clearAllTitle: 'Alle Filter löschen?',
clearAllSavePrompt: 'Möchtest du deine aktuellen Filter vor dem Löschen speichern?',
saveAndClear: 'Speichern & löschen',
clearWithoutSaving: 'Ohne Speichern löschen',
}, },
// ── Philosophy Popup ─────────────────────────────── // ── Philosophy Popup ───────────────────────────────
philosophy: { philosophy: {
intro: intro:
'Beginne mit deinen Muss-Kriterien, dann f\u00FCge Kann-Kriterien hinzu. Die Karte grenzt sich ein, wenn du Filter hinzuf\u00FCgst. Die verbleibenden Gebiete sind deine besten Treffer.', 'Beginne mit deinen Muss-Kriterien, dann füge Kann-Kriterien hinzu. Die Karte grenzt sich ein, wenn du Filter hinzufügst. Die verbleibenden Gebiete sind deine besten Treffer.',
step1Title: 'Budget und Grundlagen', step1Title: 'Budget und Grundlagen',
step1Desc: '(Preisrahmen, Wohnfl\u00E4che, Immobilientyp)', step1Desc: '(Preisrahmen, Wohnfläche, Immobilientyp)',
step2Title: 'Pendelweg', step2Title: 'Pendelweg',
step2Desc: '(Fahrzeit zum Arbeitsplatz mit Auto, Fahrrad oder \u00D6PNV)', step2Desc: '(Fahrzeit zum Arbeitsplatz mit Auto, Fahrrad oder ÖPNV)',
step3Title: 'Sicherheit', step3Title: 'Sicherheit',
step3Desc: '(Kriminalit\u00E4tsraten, L\u00E4rmpegel, Bodenstabilit\u00E4t)', step3Desc: '(Kriminalitätsraten, Lärmpegel, Bodenstabilität)',
step4Title: 'Schulen', step4Title: 'Schulen',
step4Desc: '(nahe gelegene Schulen mit Ofsted-Bewertung Gut oder Hervorragend)', step4Desc: '(nahe gelegene Schulen mit Ofsted-Bewertung Gut oder Hervorragend)',
step5Title: 'Lebensstil', step5Title: 'Lebensstil',
step5Desc: '(Restaurants, Parks, Breitbandgeschwindigkeit)', step5Desc: '(Restaurants, Parks, Breitbandgeschwindigkeit)',
step6Title: 'Energie', step6Title: 'Energie',
step6Desc: '(EPC-Bewertungen, D\u00E4mmung, Heizkosten)', step6Desc: '(EPC-Bewertungen, Dämmung, Heizkosten)',
tip: 'Tipp: Wenn nichts passt, lockere eine Bedingung nach der anderen, um zu sehen, welcher Kompromiss die meisten Optionen er\u00F6ffnet.', tip: 'Tipp: Wenn nichts passt, lockere eine Bedingung nach der anderen, um zu sehen, welcher Kompromiss die meisten Optionen eröffnet.',
}, },
// ── Travel Time ──────────────────────────────────── // ── Travel Time ────────────────────────────────────
travel: { travel: {
travelTime: 'Reisezeit ({{mode}})', travelTime: 'Reisezeit ({{mode}})',
maxTime: 'Maximale Zeit', maxTime: 'Maximale Zeit',
selectDestination: 'Ziel ausw\u00E4hlen...', selectDestination: 'Ziel auswählen...',
bestCase: 'Bestfall', bestCase: 'Bestfall',
bestCaseTitle: 'Bestm\u00F6gliche Reisezeit', bestCaseTitle: 'Bestmögliche Reisezeit',
bestCaseDesc: bestCaseDesc:
'Verwendet die schnellste realistische Reisezeit (bei guter Abfahrtsplanung und guten Anschl\u00FCssen). Standard ist der <strong>Median</strong>, der eine typische Fahrt unabh\u00E4ngig vom Abfahrtszeitpunkt darstellt.', 'Verwendet die schnellste realistische Reisezeit (bei guter Abfahrtsplanung und guten Anschlüssen). Standard ist der <strong>Median</strong>, der eine typische Fahrt unabhängig vom Abfahrtszeitpunkt darstellt.',
previewOnMap: 'Auf Karte anzeigen', previewOnMap: 'Auf Karte anzeigen',
stopPreviewing: 'Vorschau beenden', stopPreviewing: 'Vorschau beenden',
removeTravelTime: 'Reisezeit entfernen', removeTravelTime: 'Reisezeit entfernen',
addTravelTime: '{{mode}}-Reisezeit hinzuf\u00FCgen', addTravelTime: '{{mode}}-Reisezeit hinzufügen',
clearDestination: 'Ziel l\u00F6schen', clearDestination: 'Ziel löschen',
typeToFilter: 'Tippen zum Filtern...', typeToFilter: 'Tippen zum Filtern...',
noDestinations: 'Keine Ziele gefunden', noDestinations: 'Keine Ziele gefunden',
modeCar: 'Auto', modeCar: 'Auto',
modeBicycle: 'Fahrrad', modeBicycle: 'Fahrrad',
modeWalking: 'Zu Fu\u00DF', modeWalking: 'Zu Fuß',
modeTransit: '\u00D6PNV', modeTransit: 'ÖPNV',
modeCarDesc: 'Fahrzeit \u00FCber die schnellste Stra\u00DFenroute', modeCarDesc: 'Fahrzeit über die schnellste Straßenroute',
modeBicycleDesc: 'Radfahrzeit auf fahrradfreundlichen Strecken', modeBicycleDesc: 'Radfahrzeit auf fahrradfreundlichen Strecken',
modeWalkingDesc: 'Gehzeit \u00FCber Fu\u00DFwege und B\u00FCrgersteige', modeWalkingDesc: 'Gehzeit über Fußwege und Bürgersteige',
modeTransitDesc: 'Reisezeit mit Bahn, U-Bahn und Bus', modeTransitDesc: 'Reisezeit mit Bahn, U-Bahn und Bus',
}, },
// ── Travel Time Info Popup ───────────────────────── // ── Travel Time Info Popup ─────────────────────────
travelInfo: { travelInfo: {
transitDesc: transitDesc:
' mit \u00F6ffentlichen Verkehrsmitteln (Bus, Bahn, U-Bahn). Die Zeiten werden \u00FCber ein typisches Werktags-Morgenfenster berechnet.', ' mit öffentlichen Verkehrsmitteln (Bus, Bahn, U-Bahn). Die Zeiten werden über ein typisches Werktags-Morgenfenster berechnet.',
carDesc: carDesc:
' mit dem Auto, basierend auf typischen Stra\u00DFengeschwindigkeiten und dem Stra\u00DFennetz.', ' mit dem Auto, basierend auf typischen Straßengeschwindigkeiten und dem Straßennetz.',
bicycleDesc: ' mit dem Fahrrad, auf fahrradfreundlichen Strecken.', bicycleDesc: ' mit dem Fahrrad, auf fahrradfreundlichen Strecken.',
walkingDesc: ' zu Fu\u00DF, \u00FCber Fu\u00DFwege und B\u00FCrgersteige.', walkingDesc: ' zu Fuß, über Fußwege und Bürgersteige.',
mainDesc: mainDesc:
'Zeigt, wie lange es dauert, das ausgew\u00E4hlte Ziel von jedem Gebiet aus zu erreichen', 'Zeigt, wie lange es dauert, das ausgewählte Ziel von jedem Gebiet aus zu erreichen',
sliderHint: sliderHint:
'Verwende den Schieberegler, um deine maximale Pendelzeit festzulegen.', 'Verwende den Schieberegler, um deine maximale Pendelzeit festzulegen.',
}, },
@ -214,21 +219,26 @@ const de: Translations = {
describeIdealArea: 'Beschreibe dein Wunschgebiet mit KI', describeIdealArea: 'Beschreibe dein Wunschgebiet mit KI',
aiSearch: 'KI-Suche', aiSearch: 'KI-Suche',
describeHint: 'beschreibe, wonach du suchst', describeHint: 'beschreibe, wonach du suchst',
placeholder: 'z.\u00A0B. ruhige Gegend, unter \u00A3400k, nahe guten Schulen...', placeholder: 'z. B. ruhige Gegend, unter £400k, nahe guten Schulen...',
example1: 'Sichere Gegend nahe guten Schulen', example1: 'Sichere Gegend nahe guten Schulen',
example2: '30 Min. Pendelweg zu Kings Cross, unter \u00A3500k', example2: '30 Min. Pendelweg zu Kings Cross, unter £500k',
example3: 'Ruhiges Dorf, 3 Schlafzimmer, schnelles Breitband', example3: 'Ruhiges Dorf, 3 Schlafzimmer, schnelles Breitband',
analysing: 'Anfrage wird analysiert...', analysing: 'Anfrage wird analysiert...',
searchingDestinations: 'Ziele werden gesucht...', searchingDestinations: 'Ziele werden gesucht...',
generatingFilters: 'Filter werden generiert...', generatingFilters: 'Filter werden generiert...',
refiningResults: 'Ergebnisse werden verfeinert...', refiningResults: 'Ergebnisse werden verfeinert...',
weeklyLimitReached: weeklyLimitReached:
'Du hast das w\u00F6chentliche KI-Nutzungslimit erreicht. Es wird n\u00E4chste Woche automatisch zur\u00FCckgesetzt.', 'Du hast das wöchentliche KI-Nutzungslimit erreicht. Es wird nächste Woche automatisch zurückgesetzt.',
}, },
// ── Map Legend ───────────────────────────────────── // ── Map Legend ─────────────────────────────────────
mapLegend: { mapLegend: {
clearColourView: 'Farbansicht zur\u00FCcksetzen', clearColourView: 'Farbansicht zurücksetzen',
historicalMatches: 'Historische Immobilientreffer',
propertiesForSale: 'Immobilien zum Verkauf',
propertiesForRent: 'Immobilien zur Miete',
numberOfProperties: 'Anzahl der Immobilien',
previewing: 'Vorschau von \u201c{{name}}\u201d',
}, },
// ── Properties Pane ──────────────────────────────── // ── Properties Pane ────────────────────────────────
@ -236,12 +246,12 @@ const de: Translations = {
unknownAddress: 'Unbekannte Adresse', unknownAddress: 'Unbekannte Adresse',
unsaveProperty: 'Immobilie nicht mehr merken', unsaveProperty: 'Immobilie nicht mehr merken',
saveProperty: 'Immobilie merken', saveProperty: 'Immobilie merken',
lastSold: 'Letzter Verkauf: \u00A3{{price}}', lastSold: 'Letzter Verkauf: £{{price}}',
estValue: 'Gesch. Wert:', estValue: 'Gesch. Wert:',
type: 'Typ:', type: 'Typ:',
builtForm: 'Bauweise:', builtForm: 'Bauweise:',
tenure: 'Besitzart:', tenure: 'Besitzart:',
floorArea: 'Wohnfl\u00E4che:', floorArea: 'Wohnfläche:',
bedrooms: 'Schlafzimmer:', bedrooms: 'Schlafzimmer:',
bathrooms: 'Badezimmer:', bathrooms: 'Badezimmer:',
rooms: 'Zimmer:', rooms: 'Zimmer:',
@ -253,34 +263,34 @@ const de: Translations = {
renovations: 'Renovierungen', renovations: 'Renovierungen',
viewExternalListing: 'Externes Inserat ansehen', viewExternalListing: 'Externes Inserat ansehen',
perMonth: '/Monat', perMonth: '/Monat',
perSqm: '/m\u00B2', perSqm: '/m²',
searchPlaceholder: 'Nach Adresse oder Postleitzahl suchen...', searchPlaceholder: 'Nach Adresse oder Postleitzahl suchen...',
propertyData: 'Immobiliendaten', propertyData: 'Immobiliendaten',
propertyDataDesc: propertyDataDesc:
'Preise stammen vom HM Land Registry (was K\u00E4ufer tats\u00E4chlich bezahlt haben). Wohnfl\u00E4che, Energiebewertungen, Baujahr und Besitzart stammen aus offiziellen EPC-Gutachten. Beide Quellen werden nach Adresse innerhalb jeder Postleitzahl abgeglichen.', 'Preise stammen vom HM Land Registry (was Käufer tatsächlich bezahlt haben). Wohnfläche, Energiebewertungen, Baujahr und Besitzart stammen aus offiziellen EPC-Gutachten. Beide Quellen werden nach Adresse innerhalb jeder Postleitzahl abgeglichen.',
}, },
// ── Area Pane ────────────────────────────────────── // ── Area Pane ──────────────────────────────────────
areaPane: { areaPane: {
areaStatistics: 'Gebietsstatistiken', areaStatistics: 'Gebietsstatistiken',
statsFor: 'Statistiken f\u00FCr alle Immobilien in diesem {{type}}', statsFor: 'Statistiken für alle Immobilien in diesem {{type}}',
matchingFilters: ', die allen aktiven Filtern entsprechen', matchingFilters: ', die allen aktiven Filtern entsprechen',
viewProperties: '{{count}} Immobilien ansehen', viewProperties: '{{count}} Immobilien ansehen',
priceHistory: 'Preisentwicklung', priceHistory: 'Preisentwicklung',
journeysFrom: 'Verbindungen ab {{label}}', journeysFrom: 'Verbindungen ab {{label}}',
to: 'Nach {{destination}}', to: 'Nach {{destination}}',
noJourneyData: 'Keine Verbindungsdaten verf\u00FCgbar', noJourneyData: 'Keine Verbindungsdaten verfügbar',
viewOnGoogleMaps: 'Auf Google Maps ansehen', viewOnGoogleMaps: 'Auf Google Maps ansehen',
walk: 'Zu Fu\u00DF', walk: 'Zu Fuß',
cycle: 'Fahrrad', cycle: 'Fahrrad',
}, },
// ── Histogram Legend ─────────────────────────────── // ── Histogram Legend ───────────────────────────────
histogramLegend: { histogramLegend: {
tealBars: 'T\u00FCrkise Balken', tealBars: 'Türkise Balken',
tealBarsDesc: 'zeigen die Verteilung im ausgew\u00E4hlten Gebiet', tealBarsDesc: 'zeigen die Verteilung im ausgewählten Gebiet',
greyBars: 'Graue Balken', greyBars: 'Graue Balken',
greyBarsDesc: 'zeigen die Gesamtverteilung \u00FCber alle Gebiete', greyBarsDesc: 'zeigen die Gesamtverteilung über alle Gebiete',
dashedLine: 'Gestrichelte Linie', dashedLine: 'Gestrichelte Linie',
dashedLineDesc: 'zeigt den landesweiten Durchschnitt', dashedLineDesc: 'zeigt den landesweiten Durchschnitt',
}, },
@ -293,9 +303,9 @@ const de: Translations = {
// ── POI Pane ─────────────────────────────────────── // ── POI Pane ───────────────────────────────────────
poiPane: { poiPane: {
pois: 'POIs', pois: 'POIs',
pointsOfInterest: 'Sehensw\u00FCrdigkeiten & Einrichtungen', pointsOfInterest: 'Sehenswürdigkeiten & Einrichtungen',
poiDescription: poiDescription:
'Daten von OpenStreetMap. Umfasst Haltestellen, Gesch\u00E4fte, Restaurants, Gesundheitseinrichtungen, Freizeit und mehr. Regelm\u00E4\u00DFig aktualisiert mit vollst\u00E4ndiger Kategorieabdeckung.', 'Daten von OpenStreetMap. Umfasst Haltestellen, Geschäfte, Restaurants, Gesundheitseinrichtungen, Freizeit und mehr. Regelmäßig aktualisiert mit vollständiger Kategorieabdeckung.',
searchCategories: 'Kategorien durchsuchen...', searchCategories: 'Kategorien durchsuchen...',
dataSourceInfo: 'Datenquelleninfo', dataSourceInfo: 'Datenquelleninfo',
}, },
@ -319,7 +329,7 @@ const de: Translations = {
// ── Mobile Drawer ────────────────────────────────── // ── Mobile Drawer ──────────────────────────────────
mobileDrawer: { mobileDrawer: {
closeDrawer: 'Schublade schlie\u00DFen', closeDrawer: 'Schublade schließen',
}, },
// ── Home Page ────────────────────────────────────── // ── Home Page ──────────────────────────────────────
@ -328,9 +338,9 @@ const de: Translations = {
heroTitle2: 'Wert', heroTitle2: 'Wert',
heroTitle3: 'Minimale Kompromisse.', heroTitle3: 'Minimale Kompromisse.',
heroSubtitle: heroSubtitle:
'Auf Immobiliensuche? Mach aus deiner gr\u00F6\u00DFten Investition deine kl\u00FCgste Entscheidung.', 'Auf Immobiliensuche? Mach aus deiner größten Investition deine klügste Entscheidung.',
heroDescription: heroDescription:
'So viele M\u00F6glichkeiten \u2014 die richtige Wahl kann \u00FCberw\u00E4ltigend sein. Unsere interaktive Karte macht es einfach: W\u00E4hle deine Muss-Kriterien und sieh sofort die passenden Gebiete.', 'So viele Möglichkeiten — die richtige Wahl kann überwältigend sein. Unsere interaktive Karte macht es einfach: Wähle deine Muss-Kriterien und sieh sofort die passenden Gebiete.',
exploreTheMap: 'Karte entdecken', exploreTheMap: 'Karte entdecken',
seeTheDifference: 'Den Unterschied sehen', seeTheDifference: 'Den Unterschied sehen',
statProperties: 'Immobilien', statProperties: 'Immobilien',
@ -339,201 +349,201 @@ const de: Translations = {
statPostcodeInEngland: 'Postleitzahl in England', statPostcodeInEngland: 'Postleitzahl in England',
ourPhilosophy: 'Unsere Philosophie', ourPhilosophy: 'Unsere Philosophie',
philosophyP1: philosophyP1:
'Auf Rightmove w\u00E4hlt man zuerst ein Gebiet und hofft, dass es gut ist. Am Ende vergleicht man Kriminalit\u00E4tsstatistiken, Schulberichte und Breitband-Checker in einem Dutzend Tabs, eine Postleitzahl nach der anderen.', 'Auf Rightmove wählt man zuerst ein Gebiet und hofft, dass es gut ist. Am Ende vergleicht man Kriminalitätsstatistiken, Schulberichte und Breitband-Checker in einem Dutzend Tabs, eine Postleitzahl nach der anderen.',
philosophyP2: philosophyP2:
'Wir drehen das um. Sag uns, was du brauchst (Budget, Pendelweg, Schulen, Sicherheit), und wir zeigen dir jedes Gebiet in England, das passt. Kein Raten. Keine verschwendeten Besichtigungen.', 'Wir drehen das um. Sag uns, was du brauchst (Budget, Pendelweg, Schulen, Sicherheit), und wir zeigen dir jedes Gebiet in England, das passt. Kein Raten. Keine verschwendeten Besichtigungen.',
howToUseIt: 'So funktioniert es', howToUseIt: 'So funktioniert es',
howStep1Title: 'Lege deine Muss-Kriterien fest', howStep1Title: 'Lege deine Muss-Kriterien fest',
howStep1Desc: 'Budget, Pendelweg, Schulen \u2014 die Karte zeigt nur, was passt.', howStep1Desc: 'Budget, Pendelweg, Schulen die Karte zeigt nur, was passt.',
howStep2Title: 'Entdecke Gebiete und versteckte Perlen', howStep2Title: 'Entdecke Gebiete und versteckte Perlen',
howStep2Desc: 'Zoom rein, schau dir Details und Kann-Kriterien an.', howStep2Desc: 'Zoom rein, schau dir Details und Kann-Kriterien an.',
howStep3Title: 'Einzelne Postleitzahlen erkunden', howStep3Title: 'Einzelne Postleitzahlen erkunden',
howStep3Desc: howStep3Desc:
'Sieh einzelne Immobilien, Verkaufspreise, Wohnfl\u00E4chen und vergleiche.', 'Sieh einzelne Immobilien, Verkaufspreise, Wohnflächen und vergleiche.',
howStep4Title: 'Engere Auswahl mit Zuversicht', howStep4Title: 'Engere Auswahl mit Zuversicht',
howStep4Desc: howStep4Desc:
'Jedes Gebiet auf deiner Liste erf\u00FCllt deine tats\u00E4chlichen Kriterien \u2014 nicht nur, was diese Woche inseriert war.', 'Jedes Gebiet auf deiner Liste erfüllt deine tatsächlichen Kriterien — nicht nur, was diese Woche inseriert war.',
othersVs: 'Andere vs', othersVs: 'Andere vs',
listingPortals: 'Immobilienportale', listingPortals: 'Immobilienportale',
checkMyPostcode: '\u201EMeine Postleitzahl pr\u00FCfen\u201C', checkMyPostcode: '„Meine Postleitzahl prüfen“',
areaGuides: 'Gebietsratgeber', areaGuides: 'Gebietsratgeber',
compSearchWithout: 'Suchen, ohne zuerst ein Gebiet auszuw\u00E4hlen', compSearchWithout: 'Suchen, ohne zuerst ein Gebiet auszuwählen',
compSearchWithoutSub: '(starte mit Bed\u00FCrfnissen, nicht mit einem Ort)', compSearchWithoutSub: '(starte mit Bedürfnissen, nicht mit einem Ort)',
compAreaData: 'Gebietsdaten', compAreaData: 'Gebietsdaten',
compAreaDataSub: '(Kriminalit\u00E4t, Schulen, L\u00E4rm, Breitband)', compAreaDataSub: '(Kriminalität, Schulen, Lärm, Breitband)',
compPropertyData: 'Immobilienspezifische Daten', compPropertyData: 'Immobilienspezifische Daten',
compPropertyDataSub: '(Preis, EPC, Wohnfl\u00E4che)', compPropertyDataSub: '(Preis, EPC, Wohnfläche)',
compFilters: '56 kombinierbare Filter an einem Ort', compFilters: '56 kombinierbare Filter an einem Ort',
compFiltersSub: '(alle Einblicke, eine interaktive Karte)', compFiltersSub: '(alle Einblicke, eine interaktive Karte)',
ctaTitle: ctaTitle:
'Mach aus deiner gr\u00F6\u00DFten Investition deine kl\u00FCgste\u00A0Entscheidung.', 'Mach aus deiner größten Investition deine klügste Entscheidung.',
ctaDescription: ctaDescription:
'Das verdient die richtigen Werkzeuge \u2014 \u00FCberlass es nicht dem Zufall.', 'Das verdient die richtigen Werkzeuge — überlass es nicht dem Zufall.',
}, },
// ── Pricing Page ─────────────────────────────────── // ── Pricing Page ───────────────────────────────────
pricingPage: { pricingPage: {
title: 'Fr\u00FChzugangspreis', title: 'Frühzugangspreis',
subtitle: subtitle:
'Einmal zahlen, f\u00FCr immer nutzen. Je fr\u00FCher du dabei bist, desto weniger zahlst du.', 'Einmal zahlen, für immer nutzen. Je früher du dabei bist, desto weniger zahlst du.',
costContext: costContext:
'Ein Hauskauf kostet \u00A310.000+ an Grunderwerbsteuer, \u00A31.500 an Anwaltsgeb\u00FChren, \u00A3500 f\u00FCr ein Gutachten. W\u00E4hlst du das falsche Gebiet, steckst du mit einem langen Pendelweg, schlechten Schulen oder einer Stra\u00DFe fest, von der du nichts wusstest.', 'Ein Hauskauf kostet £10.000+ an Grunderwerbsteuer, £1.500 an Anwaltsgebühren, £500 für ein Gutachten. Wählst du das falsche Gebiet, steckst du mit einem langen Pendelweg, schlechten Schulen oder einer Straße fest, von der du nichts wusstest.',
lessThanSurvey: 'Weniger als ein Hausgutachten. Deutlich n\u00FCtzlicher.', lessThanSurvey: 'Weniger als ein Hausgutachten. Deutlich nützlicher.',
currentTier: 'Aktuelle Stufe', currentTier: 'Aktuelle Stufe',
firstNUsers: 'Erste {{count}} Nutzer', firstNUsers: 'Erste {{count}} Nutzer',
everyoneAfter: 'Alle danach', everyoneAfter: 'Alle danach',
nextNUsers: 'N\u00E4chste {{count}} Nutzer', nextNUsers: 'Nächste {{count}} Nutzer',
lifetime: '/lebenslang', lifetime: '/lebenslang',
spotsRemaining: '{{count}} Platz verbleibend', spotsRemaining: '{{count}} Platz verbleibend',
spotsRemainingPlural: '{{count}} Pl\u00E4tze verbleibend', spotsRemainingPlural: '{{count}} Plätze verbleibend',
filled: 'Vergeben', filled: 'Vergeben',
openDashboard: '\u00DCbersicht \u00F6ffnen', openDashboard: 'Übersicht öffnen',
getStarted: 'Jetzt starten', getStarted: 'Jetzt starten',
getStartedPrice: 'Jetzt starten \u2014 {{price}}', getStartedPrice: 'Jetzt starten {{price}}',
noCreditCard: 'Keine Kreditkarte erforderlich', noCreditCard: 'Keine Kreditkarte erforderlich',
moneyBackGuarantee: '30 Tage Geld-zur\u00FCck-Garantie', moneyBackGuarantee: '30 Tage Geld-zurück-Garantie',
soldOut: 'Ausverkauft', soldOut: 'Ausverkauft',
upcoming: 'Demn\u00E4chst', upcoming: 'Demnächst',
failedToLoad: failedToLoad:
'Preise konnten nicht geladen werden. Bitte sp\u00E4ter erneut versuchen.', 'Preise konnten nicht geladen werden. Bitte später erneut versuchen.',
feat1: '56 Datenebenen f\u00FCr ganz England', feat1: '56 Datenebenen für ganz England',
feat2: 'Jede Postleitzahl bewertet und filterbar', feat2: 'Jede Postleitzahl bewertet und filterbar',
feat3: 'Unbegrenztes Erkunden der Karte und Exporte', feat3: 'Unbegrenztes Erkunden der Karte und Exporte',
feat4: 'Mehrere Jahrzehnte historischer Preisdaten', feat4: 'Mehrere Jahrzehnte historischer Preisdaten',
feat5: 'Kriminalit\u00E4t, Schulen, Verkehr, Breitband und mehr', feat5: 'Kriminalität, Schulen, Verkehr, Breitband und mehr',
feat6: 'Alle zuk\u00FCnftigen Datenaktualisierungen inklusive', feat6: 'Alle zukünftigen Datenaktualisierungen inklusive',
}, },
// ── Learn Page ───────────────────────────────────── // ── Learn Page ─────────────────────────────────────
learnPage: { learnPage: {
faq: 'H\u00E4ufige Fragen', faq: 'Häufige Fragen',
dataSources: 'Datenquellen', dataSources: 'Datenquellen',
support: 'Support', support: 'Support',
dataSourcesIntro: 'Diese Anwendung kombiniert {{count}} offene Datens\u00E4tze zu Immobilienpreisen, Energieeffizienz, Verkehr, Demografie, Kriminalit\u00E4t, Umwelt und mehr.', dataSourcesIntro: 'Diese Anwendung kombiniert {{count}} offene Datensätze zu Immobilienpreisen, Energieeffizienz, Verkehr, Demografie, Kriminalität, Umwelt und mehr.',
faqIntro: 'Ob Sie kaufen, mieten oder einfach nur st\u00F6bern \u2013 so hilft Ihnen Perfect Postcode, das richtige Gebiet zu finden.', faqIntro: 'Ob Sie kaufen, mieten oder einfach nur stöbern so hilft Ihnen Perfect Postcode, das richtige Gebiet zu finden.',
supportIntro: 'Haben Sie eine Frage? Schauen Sie in unsere FAQ oder kontaktieren Sie uns direkt.', supportIntro: 'Haben Sie eine Frage? Schauen Sie in unsere FAQ oder kontaktieren Sie uns direkt.',
source: 'Quelle:', source: 'Quelle:',
optOut: 'Widerspruch gegen \u00F6ffentliche Offenlegung', optOut: 'Widerspruch gegen öffentliche Offenlegung',
attribution: 'Quellenangaben', attribution: 'Quellenangaben',
attrLandRegistry: 'Enth\u00E4lt Daten des HM Land Registry \u00A9 Crown copyright and database right 2025.', attrLandRegistry: 'Enthält Daten des HM Land Registry © Crown copyright and database right 2025.',
attrOgl: 'Enth\u00E4lt \u00F6ffentliche Informationen lizenziert unter der', attrOgl: 'Enthält öffentliche Informationen lizenziert unter der',
attrOglLink: 'Open Government Licence v3.0', attrOglLink: 'Open Government Licence v3.0',
attrOs: 'Enth\u00E4lt OS-Daten \u00A9 Crown copyright and database rights 2025.', attrOs: 'Enthält OS-Daten © Crown copyright and database rights 2025.',
attrTfl: 'Betrieben mit TfL Open Data.', attrTfl: 'Betrieben mit TfL Open Data.',
attrOsm: 'Enth\u00E4lt Daten von', attrOsm: 'Enthält Daten von',
attrOsmContrib: '\u00A9 OpenStreetMap contributors', attrOsmContrib: '© OpenStreetMap contributors',
attrOsmLicense: 'verf\u00FCgbar unter der', attrOsmLicense: 'verfügbar unter der',
attrOsmLicenseLink: 'Open Data Commons Open Database License (ODbL)', attrOsmLicenseLink: 'Open Data Commons Open Database License (ODbL)',
// Data source names & descriptions // Data source names & descriptions
dsPricePaidName: 'Price Paid Data', dsPricePaidName: 'Price Paid Data',
dsPricePaidOrigin: 'HM Land Registry', dsPricePaidOrigin: 'HM Land Registry',
dsPricePaidUse: 'Vollst\u00E4ndige historische Immobilien-Verkaufspreise f\u00FCr England.', dsPricePaidUse: 'Vollständige historische Immobilien-Verkaufspreise für England.',
dsEpcName: 'Energy Performance Certificates (EPC)', dsEpcName: 'Energy Performance Certificates (EPC)',
dsEpcOrigin: 'Ministry of Housing, Communities & Local Government', dsEpcOrigin: 'Ministry of Housing, Communities & Local Government',
dsEpcUse: 'Energieausweise f\u00FCr Wohngeb\u00E4ude mit Angaben zu Wohnfl\u00E4che, Zimmeranzahl, Baujahr, Energiebewertungen, Immobilientyp und Bauform. \u00DCber Adresse innerhalb jeder Postleitzahl mit Price-Paid-Daten verkn\u00FCpft. Eigent\u00FCmer k\u00F6nnen der \u00F6ffentlichen Offenlegung widersprechen.', dsEpcUse: 'Energieausweise für Wohngebäude mit Angaben zu Wohnfläche, Zimmeranzahl, Baujahr, Energiebewertungen, Immobilientyp und Bauform. Über Adresse innerhalb jeder Postleitzahl mit Price-Paid-Daten verknüpft. Eigentümer können der öffentlichen Offenlegung widersprechen.',
dsNsplName: 'National Statistics Postcode Lookup (NSPL)', dsNsplName: 'National Statistics Postcode Lookup (NSPL)',
dsNsplOrigin: 'ONS / ArcGIS', dsNsplOrigin: 'ONS / ArcGIS',
dsNsplUse: 'Ordnet Postleitzahlen Koordinaten und statistischen Gebietscodes zu, um alle gebietsbezogenen Datens\u00E4tze mit einzelnen Immobilien zu verkn\u00FCpfen.', dsNsplUse: 'Ordnet Postleitzahlen Koordinaten und statistischen Gebietscodes zu, um alle gebietsbezogenen Datensätze mit einzelnen Immobilien zu verknüpfen.',
dsIodName: 'English Indices of Deprivation 2025', dsIodName: 'English Indices of Deprivation 2025',
dsIodOrigin: 'Ministry of Housing, Communities & Local Government', dsIodOrigin: 'Ministry of Housing, Communities & Local Government',
dsIodUse: 'Relative Benachteiligungswerte f\u00FCr Einkommen, Besch\u00E4ftigung, Bildung, Gesundheit, Kriminalit\u00E4t und Wohnumfeld f\u00FCr jedes Viertel in England.', dsIodUse: 'Relative Benachteiligungswerte für Einkommen, Beschäftigung, Bildung, Gesundheit, Kriminalität und Wohnumfeld für jedes Viertel in England.',
dsEthnicityName: 'Bev\u00F6lkerung nach Ethnie (Zensus 2021)', dsEthnicityName: 'Bevölkerung nach Ethnie (Zensus 2021)',
dsEthnicityOrigin: 'ONS', dsEthnicityOrigin: 'ONS',
dsEthnicityUse: 'Bev\u00F6lkerungsanteile nach ethnischer Gruppe (s\u00FCdasiatisch, ostasiatisch, schwarz, gemischt, wei\u00DF, andere) pro Bezirk.', dsEthnicityUse: 'Bevölkerungsanteile nach ethnischer Gruppe (südasiatisch, ostasiatisch, schwarz, gemischt, weiß, andere) pro Bezirk.',
dsCrimeName: 'Street-level Crime Data', dsCrimeName: 'Street-level Crime Data',
dsCrimeOrigin: 'data.police.uk', dsCrimeOrigin: 'data.police.uk',
dsCrimeUse: 'Kriminalit\u00E4tsdaten auf Stra\u00DFenebene von 2023 bis 2025, aggregiert als Jahresdurchschnitte nach LSOA und Deliktsart (Gewalt, Einbruch, antisoziales Verhalten, Drogen, Fahrzeugkriminalit\u00E4t usw.).', dsCrimeUse: 'Kriminalitätsdaten auf Straßenebene von 2023 bis 2025, aggregiert als Jahresdurchschnitte nach LSOA und Deliktsart (Gewalt, Einbruch, antisoziales Verhalten, Drogen, Fahrzeugkriminalität usw.).',
dsOsmName: 'OpenStreetMap POIs', dsOsmName: 'OpenStreetMap POIs',
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik', dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
dsOsmUse: 'Sehensw\u00FCrdigkeiten und Einrichtungen wie Gesch\u00E4fte, Restaurants, Gesundheitseinrichtungen, Freizeit, Tourismus und mehr in ganz Gro\u00DFbritannien.', dsOsmUse: 'Sehenswürdigkeiten und Einrichtungen wie Geschäfte, Restaurants, Gesundheitseinrichtungen, Freizeit, Tourismus und mehr in ganz Großbritannien.',
dsGreenspaceName: 'OS Open Greenspace', dsGreenspaceName: 'OS Open Greenspace',
dsGreenspaceOrigin: 'Ordnance Survey', dsGreenspaceOrigin: 'Ordnance Survey',
dsGreenspaceUse: 'Offizielle Gr\u00FCnfl\u00E4chengrenzen f\u00FCr Gro\u00DFbritannien, einschlie\u00DFlich \u00F6ffentlicher Parks, G\u00E4rten, Sportpl\u00E4tze und Spielpl\u00E4tze. Polygon-Schwerpunkte werden f\u00FCr die Parkn\u00E4hez\u00E4hlung und Entfernungsberechnung zum n\u00E4chsten Park verwendet.', dsGreenspaceUse: 'Offizielle Grünflächengrenzen für Großbritannien, einschließlich öffentlicher Parks, Gärten, Sportplätze und Spielplätze. Polygon-Schwerpunkte werden für die Parknähezählung und Entfernungsberechnung zum nächsten Park verwendet.',
dsNaptanName: 'NaPTAN (Public Transport Stops)', dsNaptanName: 'NaPTAN (Public Transport Stops)',
dsNaptanOrigin: 'Department for Transport', dsNaptanOrigin: 'Department for Transport',
dsNaptanUse: 'Standorte von Bahnh\u00F6fen und Haltestellen f\u00FCr Bahn, Bus, U-Bahn/Stra\u00DFenbahn, F\u00E4hre und Flugh\u00E4fen in ganz England.', dsNaptanUse: 'Standorte von Bahnhöfen und Haltestellen für Bahn, Bus, U-Bahn/Straßenbahn, Fähre und Flughäfen in ganz England.',
dsNoiseName: 'Defra Noise Mapping', dsNoiseName: 'Defra Noise Mapping',
dsNoiseOrigin: 'Defra / Environment Agency', dsNoiseOrigin: 'Defra / Environment Agency',
dsNoiseUse: 'Stra\u00DFenl\u00E4rmpegel (24-Stunden-gewichteter Durchschnitt) aus der strategischen L\u00E4rmkartierung 2022, hochaufl\u00F6send modelliert und an jeder Postleitzahl abgetastet.', dsNoiseUse: 'Straßenlärmpegel (24-Stunden-gewichteter Durchschnitt) aus der strategischen Lärmkartierung 2022, hochauflösend modelliert und an jeder Postleitzahl abgetastet.',
dsOfstedName: 'Ofsted School Inspections', dsOfstedName: 'Ofsted School Inspections',
dsOfstedOrigin: 'Ofsted', dsOfstedOrigin: 'Ofsted',
dsOfstedUse: 'Neueste Inspektionsergebnisse f\u00FCr staatlich finanzierte Schulen (Stand April 2025). Pro Postleitzahl gemittelt f\u00FCr einen lokalen Schulqualit\u00E4tswert (1=Hervorragend bis 4=Unzureichend).', dsOfstedUse: 'Neueste Inspektionsergebnisse für staatlich finanzierte Schulen (Stand April 2025). Pro Postleitzahl gemittelt für einen lokalen Schulqualitätswert (1=Hervorragend bis 4=Unzureichend).',
dsBroadbandName: 'Ofcom Broadband Performance', dsBroadbandName: 'Ofcom Broadband Performance',
dsBroadbandOrigin: 'Ofcom', dsBroadbandOrigin: 'Ofcom',
dsBroadbandUse: 'Festnetz-Breitbandabdeckung und maximale Download-Geschwindigkeiten nach Gebiet aus Ofcom Connected Nations 2025.', dsBroadbandUse: 'Festnetz-Breitbandabdeckung und maximale Download-Geschwindigkeiten nach Gebiet aus Ofcom Connected Nations 2025.',
dsCouncilTaxName: 'Council Tax Levels 2025-26', dsCouncilTaxName: 'Council Tax Levels 2025-26',
dsCouncilTaxOrigin: 'Ministry of Housing, Communities & Local Government', dsCouncilTaxOrigin: 'Ministry of Housing, Communities & Local Government',
dsCouncilTaxUse: 'J\u00E4hrliche Council-Tax-S\u00E4tze f\u00FCr die Stufen A bis H f\u00FCr alle 296 Abrechnungsbeh\u00F6rden in England, f\u00FCr eine von zwei Erwachsenen bewohnte Immobilie. \u00DCber den Bezirkscode aus dem NSPL-Postleitzahlenverzeichnis mit Immobilien verkn\u00FCpft.', dsCouncilTaxUse: 'Jährliche Council-Tax-Sätze für die Stufen A bis H für alle 296 Abrechnungsbehörden in England, für eine von zwei Erwachsenen bewohnte Immobilie. Über den Bezirkscode aus dem NSPL-Postleitzahlenverzeichnis mit Immobilien verknüpft.',
dsRentalName: 'Private Rental Market Statistics', dsRentalName: 'Private Rental Market Statistics',
dsRentalOrigin: 'ONS / Valuation Office Agency', dsRentalOrigin: 'ONS / Valuation Office Agency',
dsRentalUse: 'Monatliche Medianmieten des privaten Mietmarkts nach Bezirk und Schlafzimmerkategorie (Okt. 2022 - Sept. 2023). \u00DCber Bezirkscode und gesch\u00E4tzte Schlafzimmeranzahl mit Immobilien verkn\u00FCpft.', dsRentalUse: 'Monatliche Medianmieten des privaten Mietmarkts nach Bezirk und Schlafzimmerkategorie (Okt. 2022 - Sept. 2023). Über Bezirkscode und geschätzte Schlafzimmeranzahl mit Immobilien verknüpft.',
// FAQ section titles // FAQ section titles
faqFindingTitle: 'Ihr Gebiet finden', faqFindingTitle: 'Ihr Gebiet finden',
faqCommuteTitle: 'Pendelweg und Reisezeit', faqCommuteTitle: 'Pendelweg und Reisezeit',
faqBudgetTitle: 'Budget und Preis-Leistung', faqBudgetTitle: 'Budget und Preis-Leistung',
faqSafetyTitle: 'Sicherheit und Nachbarschaft', faqSafetyTitle: 'Sicherheit und Nachbarschaft',
faqFamiliesTitle: 'Familien und Schulen', faqFamiliesTitle: 'Familien und Schulen',
faqEnvironmentTitle: 'Umwelt und Lebensqualit\u00E4t', faqEnvironmentTitle: 'Umwelt und Lebensqualität',
faqWhyTitle: 'Warum Perfect Postcode', faqWhyTitle: 'Warum Perfect Postcode',
faqPricingTitle: 'Preise und Zugang', faqPricingTitle: 'Preise und Zugang',
faqTipsTitle: 'Tipps und Tricks', faqTipsTitle: 'Tipps und Tricks',
// FAQ items — Finding Your Area // FAQ items — Finding Your Area
faqFinding1Q: 'Ich wei\u00DF nicht einmal, welche Gebiete ich mir ansehen soll. Kann mir das helfen?', faqFinding1Q: 'Ich weiß nicht einmal, welche Gebiete ich mir ansehen soll. Kann mir das helfen?',
faqFinding1A: 'Genau daf\u00FCr ist es da. Legen Sie Ihre Filter fest (Budget, Pendelzeit, geringe Kriminalit\u00E4t, gute Schulen) und die Karte leuchtet auf, um Ihnen jedes Gebiet zu zeigen, das alle Kriterien erf\u00FCllt. Kein n\u00E4chtliches Googeln nach \u201Ebeste Wohngegenden bei Manchester\u201C mehr.', faqFinding1A: 'Genau dafür ist es da. Legen Sie Ihre Filter fest (Budget, Pendelzeit, geringe Kriminalität, gute Schulen) und die Karte leuchtet auf, um Ihnen jedes Gebiet zu zeigen, das alle Kriterien erfüllt. Kein nächtliches Googeln nach „beste Wohngegenden bei Manchester“ mehr.',
faqFinding2Q: 'Ich ziehe irgendwohin, wo ich noch nie war. Wie fange ich \u00FCberhaupt an?', faqFinding2Q: 'Ich ziehe irgendwohin, wo ich noch nie war. Wie fange ich überhaupt an?',
faqFinding2A: 'Stellen Sie Ihre Filter f\u00FCr das ein, was Ihnen wichtig ist, und die Karte hebt sofort die passenden Gebiete hervor. Sie gehen von \u201EIch kenne keine einzige Stra\u00DFe\u201C zu einer Auswahlliste in wenigen Minuten.', faqFinding2A: 'Stellen Sie Ihre Filter für das ein, was Ihnen wichtig ist, und die Karte hebt sofort die passenden Gebiete hervor. Sie gehen von „Ich kenne keine einzige Straße“ zu einer Auswahlliste in wenigen Minuten.',
faqFinding3Q: 'Wie finde ich Gebiete, die alle meine Kriterien gleichzeitig erf\u00FCllen?', faqFinding3Q: 'Wie finde ich Gebiete, die alle meine Kriterien gleichzeitig erfüllen?',
faqFinding3A: 'Kombinieren Sie mehrere Filter (Kriminalit\u00E4t unter dem Durchschnitt, gute Schulen, Pendelweg unter 40 Minuten) und f\u00E4rben Sie die Karte nach Preis, um die Gebiete mit dem besten Preis-Leistungs-Verh\u00E4ltnis zu finden. Die Karte aktualisiert sich in Echtzeit, wenn Sie die Regler bewegen.', faqFinding3A: 'Kombinieren Sie mehrere Filter (Kriminalität unter dem Durchschnitt, gute Schulen, Pendelweg unter 40 Minuten) und färben Sie die Karte nach Preis, um die Gebiete mit dem besten Preis-Leistungs-Verhältnis zu finden. Die Karte aktualisiert sich in Echtzeit, wenn Sie die Regler bewegen.',
// FAQ items — Commute and Travel // FAQ items — Commute and Travel
faqCommute1Q: 'Kann ich sehen, wie lange mein Pendelweg aus verschiedenen Gebieten tats\u00E4chlich dauern w\u00FCrde?', faqCommute1Q: 'Kann ich sehen, wie lange mein Pendelweg aus verschiedenen Gebieten tatsächlich dauern würde?',
faqCommute1A: 'Legen Sie Ihren Arbeitsplatz als Ziel fest und wir f\u00E4rben jede Postleitzahl nach Fahrzeit \u2013 ob mit Auto, Fahrrad oder \u00F6ffentlichen Verkehrsmitteln. Filtern Sie nach Ihrer maximalen Pendelzeit und der Rest verschwindet.', faqCommute1A: 'Legen Sie Ihren Arbeitsplatz als Ziel fest und wir färben jede Postleitzahl nach Fahrzeit ob mit Auto, Fahrrad oder öffentlichen Verkehrsmitteln. Filtern Sie nach Ihrer maximalen Pendelzeit und der Rest verschwindet.',
faqCommute2Q: 'Wie ist das besser als Google Maps?', faqCommute2Q: 'Wie ist das besser als Google Maps?',
faqCommute2A: 'Google Maps zeigt Ihnen eine Fahrt auf einmal. Wir f\u00E4rben jede Postleitzahl in England nach Pendelzeit in einem Blick, sodass Sie Hunderte von Gebieten nebeneinander vergleichen k\u00F6nnen, anstatt sie einzeln zu suchen.', faqCommute2A: 'Google Maps zeigt Ihnen eine Fahrt auf einmal. Wir färben jede Postleitzahl in England nach Pendelzeit in einem Blick, sodass Sie Hunderte von Gebieten nebeneinander vergleichen können, anstatt sie einzeln zu suchen.',
// FAQ items — Budget and Value // FAQ items — Budget and Value
faqBudget1Q: 'Wie finde ich Gebiete, in denen ich am meisten Wohnfl\u00E4che f\u00FCr mein Geld bekomme?', faqBudget1Q: 'Wie finde ich Gebiete, in denen ich am meisten Wohnfläche für mein Geld bekomme?',
faqBudget1A: 'Filtern Sie nach Preis pro m\u00B2 und Sie sehen sofort, welche Postleitzahlen am meisten Fl\u00E4che pro Pfund bieten. Kombinieren Sie es mit dem Energiebewertungsfilter, um Immobilien mit hohen Heizkosten zu vermeiden.', faqBudget1A: 'Filtern Sie nach Preis pro m² und Sie sehen sofort, welche Postleitzahlen am meisten Fläche pro Pfund bieten. Kombinieren Sie es mit dem Energiebewertungsfilter, um Immobilien mit hohen Heizkosten zu vermeiden.',
faqBudget2Q: 'Wie stelle ich sicher, dass ein g\u00FCnstiges Gebiet nicht aus gutem Grund g\u00FCnstig ist?', faqBudget2Q: 'Wie stelle ich sicher, dass ein günstiges Gebiet nicht aus gutem Grund günstig ist?',
faqBudget2A: 'Legen Sie Benachteiligungswerte, Kriminalit\u00E4tsstatistiken, Schulbewertungen und Breitbandgeschwindigkeiten neben den Preis. Wenn eine Postleitzahl erschwinglich ist und bei allem, was z\u00E4hlt, gut abschneidet, haben Sie echten Wert gefunden \u2013 nicht nur einen niedrigen Preis mit Kompromissen, die Sie noch nicht bemerkt haben.', faqBudget2A: 'Legen Sie Benachteiligungswerte, Kriminalitätsstatistiken, Schulbewertungen und Breitbandgeschwindigkeiten neben den Preis. Wenn eine Postleitzahl erschwinglich ist und bei allem, was zählt, gut abschneidet, haben Sie echten Wert gefunden nicht nur einen niedrigen Preis mit Kompromissen, die Sie noch nicht bemerkt haben.',
// FAQ items — Safety and Neighbourhood // FAQ items — Safety and Neighbourhood
faqSafety1Q: 'Wie kann ich pr\u00FCfen, ob ein Gebiet sicher ist, bevor ich dorthin ziehe?', faqSafety1Q: 'Wie kann ich prüfen, ob ein Gebiet sicher ist, bevor ich dorthin ziehe?',
faqSafety1A: 'Wir \u00FCberlagern echte polizeilich erfasste Kriminalit\u00E4tsdaten, aufgeschl\u00FCsselt nach Art, \u00FCber jedes Viertel in England. Filtern Sie nach Gewaltkriminalit\u00E4t, Einbruch oder antisozialem Verhalten und sehen Sie sofort, welche Postleitzahlen die niedrigsten Zahlen haben.', faqSafety1A: 'Wir überlagern echte polizeilich erfasste Kriminalitätsdaten, aufgeschlüsselt nach Art, über jedes Viertel in England. Filtern Sie nach Gewaltkriminalität, Einbruch oder antisozialem Verhalten und sehen Sie sofort, welche Postleitzahlen die niedrigsten Zahlen haben.',
faqSafety2Q: 'Ich finde st\u00E4ndig Wohnungen, die online toll aussehen, aber dann stellt sich die Gegend als schwierig heraus.', faqSafety2Q: 'Ich finde ständig Wohnungen, die online toll aussehen, aber dann stellt sich die Gegend als schwierig heraus.',
faqSafety2A: 'Genau daf\u00FCr gibt es dieses Tool. Kombinieren Sie Kriminalit\u00E4tsraten, L\u00E4rmpegel, Benachteiligungswerte, Pubs und Parks in der N\u00E4he sowie Breitbandgeschwindigkeiten auf einer Karte, damit Sie wissen, wie ein Viertel wirklich ist, bevor Sie eine Besichtigung buchen.', faqSafety2A: 'Genau dafür gibt es dieses Tool. Kombinieren Sie Kriminalitätsraten, Lärmpegel, Benachteiligungswerte, Pubs und Parks in der Nähe sowie Breitbandgeschwindigkeiten auf einer Karte, damit Sie wissen, wie ein Viertel wirklich ist, bevor Sie eine Besichtigung buchen.',
// FAQ items — Families and Schools // FAQ items — Families and Schools
faqFamilies1Q: 'Kann ich Gebiete mit guten Schulen UND geringer Kriminalit\u00E4t in einer Suche finden?', faqFamilies1Q: 'Kann ich Gebiete mit guten Schulen UND geringer Kriminalität in einer Suche finden?',
faqFamilies1A: 'Ja. Kombinieren Sie Filter f\u00FCr Ofsted-Bewertungen, Kriminalit\u00E4tsraten, Parks und alles andere, was f\u00FCr Ihre Familie wichtig ist, und die Karte hebt nur die Gebiete hervor, die alles erf\u00FCllen. Kein Abgleich \u00FCber f\u00FCnf verschiedene Websites mehr.', faqFamilies1A: 'Ja. Kombinieren Sie Filter für Ofsted-Bewertungen, Kriminalitätsraten, Parks und alles andere, was für Ihre Familie wichtig ist, und die Karte hebt nur die Gebiete hervor, die alles erfüllen. Kein Abgleich über fünf verschiedene Websites mehr.',
faqFamilies2Q: 'Woher wei\u00DF ich, ob ein Viertel Parks und Spielpl\u00E4tze in der N\u00E4he hat?', faqFamilies2Q: 'Woher weiß ich, ob ein Viertel Parks und Spielplätze in der Nähe hat?',
faqFamilies2A: 'Schalten Sie die POI-Ebene f\u00FCr Parks und Gr\u00FCnfl\u00E4chen ein, um sie direkt auf der Karte zu sehen. Sie k\u00F6nnen auch nach der Anzahl der fu\u00DFl\u00E4ufig erreichbaren Parks pro Postleitzahl filtern.', faqFamilies2A: 'Schalten Sie die POI-Ebene für Parks und Grünflächen ein, um sie direkt auf der Karte zu sehen. Sie können auch nach der Anzahl der fußläufig erreichbaren Parks pro Postleitzahl filtern.',
// FAQ items — Environment and Quality of Life // FAQ items — Environment and Quality of Life
faqEnv1Q: 'Kann ich energieeffiziente Wohnungen finden, die nicht an einer lauten Stra\u00DFe liegen?', faqEnv1Q: 'Kann ich energieeffiziente Wohnungen finden, die nicht an einer lauten Straße liegen?',
faqEnv1A: 'Filtern Sie nach EPC-Bewertung (A bis C), dann \u00FCberlagern Sie die Stra\u00DFenl\u00E4rmdaten, um alles \u00FCber Ihrem Schwellenwert auszuschlie\u00DFen. F\u00E4rben Sie nach einem der beiden Kriterien, um ruhige, effiziente Stra\u00DFen auf einen Blick zu erkennen.', faqEnv1A: 'Filtern Sie nach EPC-Bewertung (A bis C), dann überlagern Sie die Straßenlärmdaten, um alles über Ihrem Schwellenwert auszuschließen. Färben Sie nach einem der beiden Kriterien, um ruhige, effiziente Straßen auf einen Blick zu erkennen.',
faqEnv2Q: 'Zeigt es Hochwasser- oder Senkungsrisiken?', faqEnv2Q: 'Zeigt es Hochwasser- oder Senkungsrisiken?',
faqEnv2A: 'Wir integrieren Bodenstabilit\u00E4tsdaten, damit Sie vor dem Kauf auf Senkungen, Schrumpf-Quell-Tone und andere geologische Risiken pr\u00FCfen k\u00F6nnen. Schlie\u00DFen Sie Risikogebiete fr\u00FChzeitig aus.', faqEnv2A: 'Wir integrieren Bodenstabilitätsdaten, damit Sie vor dem Kauf auf Senkungen, Schrumpf-Quell-Tone und andere geologische Risiken prüfen können. Schließen Sie Risikogebiete frühzeitig aus.',
faqEnv3Q: 'Kann ich Gebiete mit schnellem Breitband finden, die wirklich ruhig sind?', faqEnv3Q: 'Kann ich Gebiete mit schnellem Breitband finden, die wirklich ruhig sind?',
faqEnv3A: '\u00DCberlagern Sie den Breitbandfilter mit den Stra\u00DFenl\u00E4rmdaten, um Stra\u00DFen mit guter Anbindung und wenig Verkehrsl\u00E4rm zu finden. F\u00E4rben Sie nach einem der beiden Kriterien, um Gebiete auf einen Blick zu vergleichen.', faqEnv3A: 'Überlagern Sie den Breitbandfilter mit den Straßenlärmdaten, um Straßen mit guter Anbindung und wenig Verkehrslärm zu finden. Färben Sie nach einem der beiden Kriterien, um Gebiete auf einen Blick zu vergleichen.',
// FAQ items — Why Perfect Postcode // FAQ items — Why Perfect Postcode
faqWhy1Q: 'Ich benutze bereits Rightmove. Was bringt mir das zus\u00E4tzlich?', faqWhy1Q: 'Ich benutze bereits Rightmove. Was bringt mir das zusätzlich?',
faqWhy1A: 'Rightmove zeigt Ihnen H\u00E4user. Wir zeigen Ihnen Gebiete. Kriminalit\u00E4tsraten, Schulbewertungen, Breitbandgeschwindigkeiten, L\u00E4rmpegel, Benachteiligungswerte und mehr \u2013 alles filterbar auf einer Karte. Sie k\u00F6nnen ein Viertel beurteilen, bevor Sie sich die Angebote ansehen.', faqWhy1A: 'Rightmove zeigt Ihnen Häuser. Wir zeigen Ihnen Gebiete. Kriminalitätsraten, Schulbewertungen, Breitbandgeschwindigkeiten, Lärmpegel, Benachteiligungswerte und mehr alles filterbar auf einer Karte. Sie können ein Viertel beurteilen, bevor Sie sich die Angebote ansehen.',
faqWhy2Q: 'Kann ich das nicht alles kostenlos selbst recherchieren?', faqWhy2Q: 'Kann ich das nicht alles kostenlos selbst recherchieren?',
faqWhy2A: 'Sie k\u00F6nnten Polizeidaten, Ofsted-Berichte, EPC-Register, Land-Registry-Eintr\u00E4ge und ONS-Statistiken eine Postleitzahl nach der anderen abgleichen. Oder Sie haben alles filterbar und farbkodiert auf einer Karte in Sekunden.', faqWhy2A: 'Sie könnten Polizeidaten, Ofsted-Berichte, EPC-Register, Land-Registry-Einträge und ONS-Statistiken eine Postleitzahl nach der anderen abgleichen. Oder Sie haben alles filterbar und farbkodiert auf einer Karte in Sekunden.',
faqWhy3Q: 'Woher stammen die Daten tats\u00E4chlich?', faqWhy3Q: 'Woher stammen die Daten tatsächlich?',
faqWhy3A: 'Jeder Datensatz stammt aus offiziellen britischen Regierungsquellen: Land Registry, EPC-Register, ONS, Ofsted, Ofcom, data.police.uk und Defra. Wir scrapen keine Makler und erfinden nichts. Sie k\u00F6nnen jeden Eintrag anhand der Originalquelle \u00FCberpr\u00FCfen.', faqWhy3A: 'Jeder Datensatz stammt aus offiziellen britischen Regierungsquellen: Land Registry, EPC-Register, ONS, Ofsted, Ofcom, data.police.uk und Defra. Wir scrapen keine Makler und erfinden nichts. Sie können jeden Eintrag anhand der Originalquelle überprüfen.',
// FAQ items — Pricing and Access // FAQ items — Pricing and Access
faqPricing1Q: 'Lohnt es sich wirklich, f\u00FCr ein Immobilien-Suchtool zu bezahlen?', faqPricing1Q: 'Lohnt es sich wirklich, für ein Immobilien-Suchtool zu bezahlen?',
faqPricing1A: 'Ein Hauskauf ist wahrscheinlich die gr\u00F6\u00DFte Anschaffung Ihres Lebens. Ein einziges Warnsignal zu erkennen (eine laute Stra\u00DFe, schlechtes Breitband, steigende Kriminalit\u00E4t) bevor Sie sich festlegen, k\u00F6nnte Ihnen Jahre des Bedauerns ersparen. Das kostet weniger als eine Tankf\u00FCllung.', faqPricing1A: 'Ein Hauskauf ist wahrscheinlich die größte Anschaffung Ihres Lebens. Ein einziges Warnsignal zu erkennen (eine laute Straße, schlechtes Breitband, steigende Kriminalität) bevor Sie sich festlegen, könnte Ihnen Jahre des Bedauerns ersparen. Das kostet weniger als eine Tankfüllung.',
faqPricing2Q: 'Ist das ein Abonnement?', faqPricing2Q: 'Ist das ein Abonnement?',
faqPricing2A: 'Nein. Einmalzahlung, Ihres f\u00FCr immer. Nutzen Sie es intensiv w\u00E4hrend Ihrer Suche, kommen Sie zur\u00FCck, wenn Sie neugierig auf ein neues Gebiet sind, und es ist immer noch da, falls Sie erneut umziehen.', faqPricing2A: 'Nein. Einmalzahlung, Ihres für immer. Nutzen Sie es intensiv während Ihrer Suche, kommen Sie zurück, wenn Sie neugierig auf ein neues Gebiet sind, und es ist immer noch da, falls Sie erneut umziehen.',
faqPricing3Q: 'Was kann ich mit der kostenlosen Version nutzen?', faqPricing3Q: 'Was kann ich mit der kostenlosen Version nutzen?',
faqPricing3A: 'Kostenlose Nutzer k\u00F6nnen alle Funktionen im Demogebiet erkunden (Innenstadt London, ungef\u00E4hr Zonen 1 bis 2). F\u00FCr den Zugang zu Daten f\u00FCr den Rest Englands ben\u00F6tigen Sie den lebenslangen Zugang.', faqPricing3A: '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.',
faqPricing4Q: 'Kann ich eine R\u00FCckerstattung erhalten?', faqPricing4Q: 'Kann ich eine Rückerstattung erhalten?',
faqPricing4A: 'Selbstverst\u00E4ndlich. Wir bieten eine 30-Tage-Geld-zur\u00FCck-Garantie. Wenn Sie nicht zufrieden sind, schreiben Sie innerhalb von 30 Tagen an support@perfect-postcode.co.uk f\u00FCr eine vollst\u00E4ndige R\u00FCckerstattung.', faqPricing4A: 'Selbstverständlich. Wir bieten eine 30-Tage-Geld-zurück-Garantie. Wenn Sie nicht zufrieden sind, schreiben Sie innerhalb von 30 Tagen an support@perfect-postcode.co.uk für eine vollständige Rückerstattung.',
// FAQ items — Tips and Tricks // FAQ items — Tips and Tricks
faqTips1Q: 'Wie nutze ich den KI-Filter, anstatt Filter einzeln hinzuzuf\u00FCgen?', faqTips1Q: 'Wie nutze ich den KI-Filter, anstatt Filter einzeln hinzuzufügen?',
faqTips1A: 'Beschreiben Sie, was Sie suchen, z.\u00A0B. \u201Eruhige Gegend nahe guten Schulen mit schnellem Breitband unter \u00A3400k\u201C, und die KI richtet alle relevanten Filter auf einmal ein. Passen Sie danach manuell an.', faqTips1A: 'Beschreiben Sie, was Sie suchen, z. B. „ruhige Gegend nahe guten Schulen mit schnellem Breitband unter £400k“, und die KI richtet alle relevanten Filter auf einmal ein. Passen Sie danach manuell an.',
faqTips2Q: 'Kann ich eine Suche speichern und sp\u00E4ter darauf zur\u00FCckkommen?', faqTips2Q: 'Kann ich eine Suche speichern und später darauf zurückkommen?',
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\u00F6rt haben, oder teilen Sie den Link mit Ihrem Partner.', 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?', faqTips3Q: 'Kann ich die angezeigten Daten exportieren?',
faqTips3A: 'Nutzen Sie den Export-Button, um die aktuell gefilterten Immobilien als Tabelle herunterzuladen. Der Export ber\u00FCcksichtigt alle aktiven Filter, sodass Sie genau die gew\u00FCnschten Daten erhalten.', faqTips3A: 'Nutzen Sie den Export-Button, um die aktuell gefilterten Immobilien als Tabelle herunterzuladen. Der Export berücksichtigt alle aktiven Filter, sodass Sie genau die gewünschten Daten erhalten.',
}, },
// ── Account Page ─────────────────────────────────── // ── Account Page ───────────────────────────────────
@ -541,7 +551,7 @@ const de: Translations = {
emailLabel: 'E-Mail', emailLabel: 'E-Mail',
subscriptionLabel: 'Abonnement', subscriptionLabel: 'Abonnement',
upgrade: 'Upgraden', upgrade: 'Upgraden',
redirecting: 'Weiterleitung\u2026', redirecting: 'Weiterleitung',
receiveNewsletter: 'Newsletter-E-Mails erhalten', receiveNewsletter: 'Newsletter-E-Mails erhalten',
needHelp: 'Brauchst du Hilfe? Schreib uns an', needHelp: 'Brauchst du Hilfe? Schreib uns an',
responseTime: 'Wir antworten in der Regel innerhalb von 24 Stunden.', responseTime: 'Wir antworten in der Regel innerhalb von 24 Stunden.',
@ -552,20 +562,20 @@ const de: Translations = {
searches: 'Suchen', searches: 'Suchen',
noSavedSearches: 'Noch keine gespeicherten Suchen', noSavedSearches: 'Noch keine gespeicherten Suchen',
noSavedSearchesDesc: noSavedSearchesDesc:
'Speichere deine Filter und Kartenansicht, um genau dort weiterzumachen, wo du aufgeh\u00F6rt hast.', 'Speichere deine Filter und Kartenansicht, um genau dort weiterzumachen, wo du aufgehört hast.',
noSavedProperties: 'Noch keine gespeicherten Immobilien', noSavedProperties: 'Noch keine gespeicherten Immobilien',
noSavedPropertiesDesc: noSavedPropertiesDesc:
'Merke dir Immobilien w\u00E4hrend du erkundest und erstelle deine Auswahlliste, ohne den \u00DCberblick zu verlieren.', 'Merke dir Immobilien während du erkundest und erstelle deine Auswahlliste, ohne den Überblick zu verlieren.',
openPostcode: 'Postleitzahl \u00F6ffnen', openPostcode: 'Postleitzahl öffnen',
viewListing: 'Inserat ansehen', viewListing: 'Inserat ansehen',
clickToRename: 'Klicken zum Umbenennen', clickToRename: 'Klicken zum Umbenennen',
notesPlaceholder: 'Notiere deine Gedanken...', notesPlaceholder: 'Notiere deine Gedanken...',
deleteSearch: 'Suche l\u00F6schen', deleteSearch: 'Suche löschen',
deleteSearchConfirm: deleteSearchConfirm:
'M\u00F6chtest du diese gespeicherte Suche wirklich l\u00F6schen? Dies kann nicht r\u00FCckg\u00E4ngig gemacht werden.', 'Möchtest du diese gespeicherte Suche wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
deleteProperty: 'Immobilie l\u00F6schen', deleteProperty: 'Immobilie löschen',
deletePropertyConfirm: deletePropertyConfirm:
'M\u00F6chtest du diese gespeicherte Immobilie wirklich l\u00F6schen? Dies kann nicht r\u00FCckg\u00E4ngig gemacht werden.', 'Möchtest du diese gespeicherte Immobilie wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
bed: 'Schlafz.', bed: 'Schlafz.',
epc: 'EPC', epc: 'EPC',
}, },
@ -573,7 +583,7 @@ const de: Translations = {
// ── Invites Page ─────────────────────────────────── // ── Invites Page ───────────────────────────────────
invitesPage: { invitesPage: {
inviteLinksLicensed: inviteLinksLicensed:
'Einladungslinks sind f\u00FCr lizenzierte Nutzer verf\u00FCgbar.', 'Einladungslinks sind für lizenzierte Nutzer verfügbar.',
inviteAdminLabel: 'Freunde einladen (100% Rabatt)', inviteAdminLabel: 'Freunde einladen (100% Rabatt)',
inviteReferralLabel: 'Freunde einladen (30% Rabatt)', inviteReferralLabel: 'Freunde einladen (30% Rabatt)',
generateFreeInvite: 'Kostenlosen Einladungslink erstellen', generateFreeInvite: 'Kostenlosen Einladungslink erstellen',
@ -586,7 +596,7 @@ const de: Translations = {
link: 'Link', link: 'Link',
status: 'Status', status: 'Status',
created: 'Erstellt', created: 'Erstellt',
redeemed: 'Eingel\u00F6st', redeemed: 'Eingelöst',
pending: 'Ausstehend', pending: 'Ausstehend',
}, },
@ -604,21 +614,21 @@ const de: Translations = {
'Ein Freund hat 30% Rabatt auf lebenslangen Zugang mit dir geteilt.', 'Ein Freund hat 30% Rabatt auf lebenslangen Zugang mit dir geteilt.',
exploreEvery: 'Entdecke jedes Viertel in England', exploreEvery: 'Entdecke jedes Viertel in England',
propertyInfo: propertyInfo:
'Immobilienpreise, Energiebewertungen, Kriminalit\u00E4tsstatistiken, Schulbewertungen und mehr', 'Immobilienpreise, Energiebewertungen, Kriminalitätsstatistiken, Schulbewertungen und mehr',
invalidInvite: 'Ung\u00FCltige Einladung', invalidInvite: 'Ungültige Einladung',
inviteAlreadyUsed: 'Einladung bereits verwendet', inviteAlreadyUsed: 'Einladung bereits verwendet',
inviteAlreadyUsedDesc: inviteAlreadyUsedDesc:
'Dieser Einladungslink wurde bereits eingel\u00F6st.', 'Dieser Einladungslink wurde bereits eingelöst.',
invalidInviteLink: 'Ung\u00FCltiger Einladungslink', invalidInviteLink: 'Ungültiger Einladungslink',
invalidInviteLinkDesc: invalidInviteLinkDesc:
'Dieser Einladungslink ist ung\u00FCltig oder abgelaufen.', 'Dieser Einladungslink ist ungültig oder abgelaufen.',
licenseActivated: 'Lizenz aktiviert!', licenseActivated: 'Lizenz aktiviert!',
fullAccessGranted: fullAccessGranted:
'Du hast jetzt vollen Zugang zu Perfect Postcode.', 'Du hast jetzt vollen Zugang zu Perfect Postcode.',
activating: 'Wird aktiviert...', activating: 'Wird aktiviert...',
activateLicense: 'Lizenz aktivieren', activateLicense: 'Lizenz aktivieren',
claimDiscount: 'Rabatt einl\u00F6sen', claimDiscount: 'Rabatt einlösen',
registerToClaim: 'Registrieren zum Einl\u00F6sen', registerToClaim: 'Registrieren zum Einlösen',
youAlreadyHaveLicense: 'Du hast bereits eine Lizenz', youAlreadyHaveLicense: 'Du hast bereits eine Lizenz',
accountHasFullAccess: 'Dein Konto hat bereits vollen Zugang.', accountHasFullAccess: 'Dein Konto hat bereits vollen Zugang.',
failedToValidate: 'Einladungslink konnte nicht validiert werden', failedToValidate: 'Einladungslink konnte nicht validiert werden',
@ -642,7 +652,7 @@ const de: Translations = {
poiCategories: '{{count}} POI-Kategorien', poiCategories: '{{count}} POI-Kategorien',
travelDestination: '{{count}} Fahrziel', travelDestination: '{{count}} Fahrziel',
travelDestinations: '{{count}} Fahrziele', travelDestinations: '{{count}} Fahrziele',
propertiesMatch: '{{count}} Immobilien stimmen \u00FCberein', propertiesMatch: '{{count}} Immobilien stimmen überein',
setFilters: '{{count}} Filter setzen: {{list}}', setFilters: '{{count}} Filter setzen: {{list}}',
noFiltersSet: 'Keine Filter gesetzt', noFiltersSet: 'Keine Filter gesetzt',
toDestination: '{{mode}} nach {{label}} {{bounds}}', toDestination: '{{mode}} nach {{label}} {{bounds}}',
@ -652,18 +662,18 @@ const de: Translations = {
// ── Tutorial ────────────────────────────────────── // ── Tutorial ──────────────────────────────────────
tutorial: { tutorial: {
step1Title: 'Sagen Sie der Karte, was z\u00E4hlt', step1Title: 'Sagen Sie der Karte, was zählt',
step1Content: 'Legen Sie Ihr Budget, maximale Pendelzeit, Schulqualit\u00E4t und Kriminalit\u00E4tsschwelle fest. Was Ihnen wichtig ist. Nur qualifizierende Gebiete bleiben hervorgehoben. Nutzen Sie das Augensymbol, um nach beliebigem Merkmal einzuf\u00E4rben.', step1Content: 'Legen Sie Ihr Budget, maximale Pendelzeit, Schulqualität und Kriminalitätsschwelle fest. Was Ihnen wichtig ist. Nur qualifizierende Gebiete bleiben hervorgehoben. Nutzen Sie das Augensymbol, um nach beliebigem Merkmal einzufärben.',
step2Title: 'Oder einfach beschreiben', step2Title: 'Oder einfach beschreiben',
step2Content: 'Tippen Sie auf Deutsch ein, was Sie suchen, z.\u00A0B. \u201Eruhige Gegend nahe guter Schulen unter \u00A3400k\u201C, und wir richten die Filter f\u00FCr Sie ein.', step2Content: 'Tippen Sie auf Deutsch ein, was Sie suchen, z. B. „ruhige Gegend nahe guter Schulen unter £400k“, und wir richten die Filter für Sie ein.',
step3Title: 'Erkunden Sie, was es gibt', step3Title: 'Erkunden Sie, was es gibt',
step3Content: 'Schwenken und zoomen Sie durch England. Klicken Sie auf ein beliebiges farbiges Gebiet, um Kriminalit\u00E4t, Schulen, Preise, Breitband, L\u00E4rm und mehr zu sehen.', step3Content: 'Schwenken und zoomen Sie durch England. Klicken Sie auf ein beliebiges farbiges Gebiet, um Kriminalität, Schulen, Preise, Breitband, Lärm und mehr zu sehen.',
step4Title: 'Direkt zu einem Ort springen', step4Title: 'Direkt zu einem Ort springen',
step4Content: 'Suchen Sie nach einem Ort oder einer Postleitzahl, um sofort dorthin zu gelangen.', step4Content: 'Suchen Sie nach einem Ort oder einer Postleitzahl, um sofort dorthin zu gelangen.',
step5Title: 'Ins Detail gehen', step5Title: 'Ins Detail gehen',
step5Content: 'Sehen Sie Gebietsstatistiken, Histogramme und einzelne Immobiliendaten: Preise, Wohnfl\u00E4che, Energiebewertungen und mehr.', step5Content: 'Sehen Sie Gebietsstatistiken, Histogramme und einzelne Immobiliendaten: Preise, Wohnfläche, Energiebewertungen und mehr.',
step6Title: 'Was ist in der N\u00E4he?', step6Title: 'Was ist in der Nähe?',
step6Content: 'Blenden Sie Schulen, Gesch\u00E4fte, Bahnh\u00F6fe, Parks und Restaurants auf der Karte ein, um zu sehen, was erreichbar ist.', step6Content: 'Blenden Sie Schulen, Geschäfte, Bahnhöfe, Parks und Restaurants auf der Karte ein, um zu sehen, was erreichbar ist.',
}, },
// ── Server-derived values ────────────────────────── // ── Server-derived values ──────────────────────────
@ -675,7 +685,7 @@ const de: Translations = {
'Transport': 'Verkehr', 'Transport': 'Verkehr',
'Education': 'Bildung', 'Education': 'Bildung',
'Deprivation': 'Benachteiligung', 'Deprivation': 'Benachteiligung',
'Crime': 'Kriminalit\u00E4t', 'Crime': 'Kriminalität',
'Demographics': 'Demografie', 'Demographics': 'Demografie',
'Amenities': 'Infrastruktur', 'Amenities': 'Infrastruktur',
@ -684,14 +694,14 @@ const de: Translations = {
'Property type': 'Immobilientyp', 'Property type': 'Immobilientyp',
'Leasehold/Freehold': 'Erbbaurecht/Volleigentum', 'Leasehold/Freehold': 'Erbbaurecht/Volleigentum',
'Last known price': 'Letzter bekannter Preis', 'Last known price': 'Letzter bekannter Preis',
'Estimated current price': 'Gesch\u00E4tzter aktueller Preis', 'Estimated current price': 'Geschätzter aktueller Preis',
'Asking price': 'Angebotspreis', 'Asking price': 'Angebotspreis',
'Price per sqm': 'Preis pro m\u00B2', 'Price per sqm': 'Preis pro m²',
'Est. price per sqm': 'Gesch. Preis pro m\u00B2', 'Est. price per sqm': 'Gesch. Preis pro m²',
'Asking price per sqm': 'Angebotspreis pro m\u00B2', 'Asking price per sqm': 'Angebotspreis pro m²',
'Estimated monthly rent': 'Gesch\u00E4tzte Monatsmiete', 'Estimated monthly rent': 'Geschätzte Monatsmiete',
'Asking rent (monthly)': 'Angebotsmiete (monatlich)', 'Asking rent (monthly)': 'Angebotsmiete (monatlich)',
'Total floor area (sqm)': 'Gesamtwohnfl\u00E4che (m\u00B2)', 'Total floor area (sqm)': 'Gesamtwohnfläche (m²)',
'Number of bedrooms & living rooms': 'Anzahl Schlaf- & Wohnzimmer', 'Number of bedrooms & living rooms': 'Anzahl Schlaf- & Wohnzimmer',
'Bedrooms': 'Schlafzimmer', 'Bedrooms': 'Schlafzimmer',
'Bathrooms': 'Badezimmer', 'Bathrooms': 'Badezimmer',
@ -701,26 +711,25 @@ const de: Translations = {
'Former council house': 'Ehemaliger Sozialbau', 'Former council house': 'Ehemaliger Sozialbau',
'Current energy rating': 'Aktuelle Energiebewertung', 'Current energy rating': 'Aktuelle Energiebewertung',
'Potential energy rating': 'Potenzielle Energiebewertung', 'Potential energy rating': 'Potenzielle Energiebewertung',
'Interior height (m)': 'Raumh\u00F6he (m)', 'Interior height (m)': 'Raumhöhe (m)',
// ─ Feature names (Transport) ─ // ─ Feature names (Transport) ─
'Distance to nearest train or tube station (km)': 'Entfernung zum n\u00E4chsten Bahn- oder U-Bahnhof (km)', 'Distance to nearest train or tube station (km)': 'Entfernung zum nächsten Bahn- oder U-Bahnhof (km)',
'Train or tube stations within 1km': 'Bahn- oder U-Bahnh\u00F6fe im Umkreis von 1 km',
// ─ Feature names (Education) ─ // ─ Feature names (Education) ─
'Good+ primary schools within 2km': 'Gute+ Grundschulen im Umkreis von 2 km', 'Good+ primary schools within 2km': 'Gute+ Grundschulen im Umkreis von 2 km',
'Good+ secondary schools within 2km': 'Gute+ weiterf\u00FChrende Schulen im Umkreis von 2 km', 'Good+ secondary schools within 2km': 'Gute+ weiterführende Schulen im Umkreis von 2 km',
'Good+ primary schools within 5km': 'Gute+ Grundschulen im Umkreis von 5 km', 'Good+ primary schools within 5km': 'Gute+ Grundschulen im Umkreis von 5 km',
'Good+ secondary schools within 5km': 'Gute+ weiterf\u00FChrende Schulen im Umkreis von 5 km', 'Good+ secondary schools within 5km': 'Gute+ weiterführende Schulen im Umkreis von 5 km',
'Education, Skills and Training Score': 'Score f\u00FCr Bildung, Kompetenzen und Ausbildung', 'Education, Skills and Training Score': 'Score für Bildung, Kompetenzen und Ausbildung',
// ─ Feature names (Deprivation) ─ // ─ Feature names (Deprivation) ─
'Income Score (rate)': 'Einkommensscore (Rate)', 'Income Score (rate)': 'Einkommensscore (Rate)',
'Employment Score (rate)': 'Besch\u00E4ftigungsscore (Rate)', 'Employment Score (rate)': 'Beschäftigungsscore (Rate)',
'Health Deprivation and Disability Score': 'Score f\u00FCr Gesundheit und Behinderung', 'Health Deprivation and Disability Score': 'Score für Gesundheit und Behinderung',
'Living Environment Score': 'Score der Wohnumgebung', 'Living Environment Score': 'Score der Wohnumgebung',
'Indoors Sub-domain Score': 'Score der Wohnqualit\u00E4t (innen)', 'Indoors Sub-domain Score': 'Score der Wohnqualität (innen)',
'Outdoors Sub-domain Score': 'Score der Umgebungsqualit\u00E4t (au\u00DFen)', 'Outdoors Sub-domain Score': 'Score der Umgebungsqualität (außen)',
// ─ Feature names (Crime) ─ // ─ Feature names (Crime) ─
'Serious crime per 1k residents (avg/yr)': 'Schwere Straftaten pro 1k Einwohner (Durchschn./Jahr)', 'Serious crime per 1k residents (avg/yr)': 'Schwere Straftaten pro 1k Einwohner (Durchschn./Jahr)',
@ -728,36 +737,36 @@ const de: Translations = {
'Serious crime (avg/yr)': 'Schwere Straftaten (Durchschn./Jahr)', 'Serious crime (avg/yr)': 'Schwere Straftaten (Durchschn./Jahr)',
'Minor crime (avg/yr)': 'Leichte Straftaten (Durchschn./Jahr)', 'Minor crime (avg/yr)': 'Leichte Straftaten (Durchschn./Jahr)',
'Violence and sexual offences (avg/yr)': 'Gewalt- und Sexualdelikte (Durchschn./Jahr)', 'Violence and sexual offences (avg/yr)': 'Gewalt- und Sexualdelikte (Durchschn./Jahr)',
'Burglary (avg/yr)': 'Einbr\u00FCche (Durchschn./Jahr)', 'Burglary (avg/yr)': 'Einbrüche (Durchschn./Jahr)',
'Robbery (avg/yr)': 'Raub\u00FCberf\u00E4lle (Durchschn./Jahr)', 'Robbery (avg/yr)': 'Raubüberfälle (Durchschn./Jahr)',
'Vehicle crime (avg/yr)': 'Fahrzeugkriminalit\u00E4t (Durchschn./Jahr)', 'Vehicle crime (avg/yr)': 'Fahrzeugkriminalität (Durchschn./Jahr)',
'Anti-social behaviour (avg/yr)': 'Antisoziales Verhalten (Durchschn./Jahr)', 'Anti-social behaviour (avg/yr)': 'Antisoziales Verhalten (Durchschn./Jahr)',
'Criminal damage and arson (avg/yr)': 'Sachbesch\u00E4digung und Brandstiftung (Durchschn./Jahr)', 'Criminal damage and arson (avg/yr)': 'Sachbeschädigung und Brandstiftung (Durchschn./Jahr)',
'Other theft (avg/yr)': 'Sonstiger Diebstahl (Durchschn./Jahr)', 'Other theft (avg/yr)': 'Sonstiger Diebstahl (Durchschn./Jahr)',
'Theft from the person (avg/yr)': 'Taschendiebstahl (Durchschn./Jahr)', 'Theft from the person (avg/yr)': 'Taschendiebstahl (Durchschn./Jahr)',
'Shoplifting (avg/yr)': 'Ladendiebstahl (Durchschn./Jahr)', 'Shoplifting (avg/yr)': 'Ladendiebstahl (Durchschn./Jahr)',
'Bicycle theft (avg/yr)': 'Fahrraddiebstahl (Durchschn./Jahr)', 'Bicycle theft (avg/yr)': 'Fahrraddiebstahl (Durchschn./Jahr)',
'Drugs (avg/yr)': 'Drogendelikte (Durchschn./Jahr)', 'Drugs (avg/yr)': 'Drogendelikte (Durchschn./Jahr)',
'Possession of weapons (avg/yr)': 'Waffenbesitz (Durchschn./Jahr)', 'Possession of weapons (avg/yr)': 'Waffenbesitz (Durchschn./Jahr)',
'Public order (avg/yr)': 'St\u00F6rung der \u00F6ffentlichen Ordnung (Durchschn./Jahr)', 'Public order (avg/yr)': 'Störung der öffentlichen Ordnung (Durchschn./Jahr)',
'Other crime (avg/yr)': 'Sonstige Straftaten (Durchschn./Jahr)', 'Other crime (avg/yr)': 'Sonstige Straftaten (Durchschn./Jahr)',
// ─ Feature names (Demographics) ─ // ─ Feature names (Demographics) ─
'Median age': 'Medianalter', 'Median age': 'Medianalter',
'% White': '% Wei\u00DF', '% White': '% Weiß',
'% South Asian': '% S\u00FCdasiatisch', '% South Asian': '% Südasiatisch',
'% Black': '% Schwarz', '% Black': '% Schwarz',
'% East Asian': '% Ostasiatisch', '% East Asian': '% Ostasiatisch',
'% Mixed': '% Gemischt', '% Mixed': '% Gemischt',
'% Other': '% Sonstige', '% Other': '% Sonstige',
// ─ Feature names (Amenities) ─ // ─ Feature names (Amenities) ─
'Distance to nearest park (km)': 'Entfernung zum n\u00E4chsten Park (km)', 'Distance to nearest park (km)': 'Entfernung zum nächsten Park (km)',
'Number of parks within 2km': 'Anzahl Parks im Umkreis von 2 km', 'Number of parks within 2km': 'Anzahl Parks im Umkreis von 2 km',
'Number of restaurants within 2km': 'Anzahl Restaurants im Umkreis von 2 km', 'Number of restaurants within 2km': 'Anzahl Restaurants im Umkreis von 2 km',
'Number of grocery shops and supermarkets within 2km': 'Anzahl Lebensmittelgesch\u00E4fte und Superm\u00E4rkte im Umkreis von 2 km', 'Number of grocery shops and supermarkets within 2km': 'Anzahl Lebensmittelgeschäfte und Supermärkte im Umkreis von 2 km',
'Noise (dB)': 'L\u00E4rm (dB)', 'Noise (dB)': 'Lärm (dB)',
'Max available download speed (Mbps)': 'Max. verf\u00FCgbare Downloadgeschwindigkeit (Mbps)', 'Max available download speed (Mbps)': 'Max. verfügbare Downloadgeschwindigkeit (Mbps)',
// ─ Enum values ─ // ─ Enum values ─
@ -765,7 +774,7 @@ const de: Translations = {
'For sale': 'Zum Verkauf', 'For sale': 'Zum Verkauf',
'For rent': 'Zur Miete', 'For rent': 'Zur Miete',
'Detached': 'Freistehend', 'Detached': 'Freistehend',
'Semi-Detached': 'Doppelhaush\u00E4lfte', 'Semi-Detached': 'Doppelhaushälfte',
'Terraced': 'Reihenhaus', 'Terraced': 'Reihenhaus',
'Flats/Maisonettes': 'Wohnungen/Maisonetten', 'Flats/Maisonettes': 'Wohnungen/Maisonetten',
'Other': 'Sonstige', 'Other': 'Sonstige',
@ -780,25 +789,25 @@ const de: Translations = {
'Ethnic composition': 'Ethnische Zusammensetzung', 'Ethnic composition': 'Ethnische Zusammensetzung',
// ─ POI group names ─ // ─ POI group names ─
'Public Transport': '\u00D6ffentlicher Nahverkehr', 'Public Transport': 'Öffentlicher Nahverkehr',
'Leisure': 'Freizeit', 'Leisure': 'Freizeit',
'Health': 'Gesundheit', 'Health': 'Gesundheit',
'Emergency Services': 'Rettungsdienste', 'Emergency Services': 'Rettungsdienste',
'Groceries': 'Lebensmittel', 'Groceries': 'Lebensmittel',
'Local Businesses': 'Lokale Gesch\u00E4fte', 'Local Businesses': 'Lokale Geschäfte',
'Culture': 'Kultur', 'Culture': 'Kultur',
'Services': 'Dienstleistungen', 'Services': 'Dienstleistungen',
'Shops': 'Gesch\u00E4fte', 'Shops': 'Geschäfte',
// ─ POI categories ─ // ─ POI categories ─
'Airport': 'Flughafen', 'Airport': 'Flughafen',
'Ferry': 'F\u00E4hre', 'Ferry': 'Fähre',
'Rail station': 'Bahnhof', 'Rail station': 'Bahnhof',
'Bus stop': 'Bushaltestelle', 'Bus stop': 'Bushaltestelle',
'Bus station': 'Busbahnhof', 'Bus station': 'Busbahnhof',
'Taxi rank': 'Taxistand', 'Taxi rank': 'Taxistand',
'Metro or Tram stop': 'U-Bahn- oder Stra\u00DFenbahnhaltestelle', 'Metro or Tram stop': 'U-Bahn- oder Straßenbahnhaltestelle',
'Caf\u00E9': 'Caf\u00E9', 'Café': 'Café',
'Restaurant': 'Restaurant', 'Restaurant': 'Restaurant',
'Pub': 'Pub', 'Pub': 'Pub',
'Bar': 'Bar', 'Bar': 'Bar',
@ -812,12 +821,12 @@ const de: Translations = {
'Sports Centre': 'Sportzentrum', 'Sports Centre': 'Sportzentrum',
'Entertainment': 'Unterhaltung', 'Entertainment': 'Unterhaltung',
'Supermarket': 'Supermarkt', 'Supermarket': 'Supermarkt',
'Convenience Store': 'Sp\u00E4tkauf', 'Convenience Store': 'Spätkauf',
'Bakery': 'B\u00E4ckerei', 'Bakery': 'Bäckerei',
'Butcher & Fishmonger': 'Metzgerei & Fischh\u00E4ndler', 'Butcher & Fishmonger': 'Metzgerei & Fischhändler',
'Greengrocer': 'Gem\u00FCseh\u00E4ndler', 'Greengrocer': 'Gemüsehändler',
'Off-Licence': 'Getr\u00E4nkeladen', 'Off-Licence': 'Getränkeladen',
'Deli & Specialty': 'Feinkost & Spezialit\u00E4ten', 'Deli & Specialty': 'Feinkost & Spezialitäten',
'Fashion & Clothing': 'Mode & Bekleidung', 'Fashion & Clothing': 'Mode & Bekleidung',
'Electronics': 'Elektronik', 'Electronics': 'Elektronik',
'Charity Shop': 'Secondhand-Laden', 'Charity Shop': 'Secondhand-Laden',
@ -826,18 +835,18 @@ const de: Translations = {
'Bookshop': 'Buchhandlung', 'Bookshop': 'Buchhandlung',
'Pet Shop': 'Tierhandlung', 'Pet Shop': 'Tierhandlung',
'Sports & Outdoor': 'Sport & Outdoor', 'Sports & Outdoor': 'Sport & Outdoor',
'Newsagent': 'Zeitungsh\u00E4ndler', 'Newsagent': 'Zeitungshändler',
'Department Store': 'Kaufhaus', 'Department Store': 'Kaufhaus',
'Gift & Hobby': 'Geschenke & Hobby', 'Gift & Hobby': 'Geschenke & Hobby',
'Specialist Shop': 'Fachgesch\u00E4ft', 'Specialist Shop': 'Fachgeschäft',
'Hairdresser & Beauty': 'Friseur & Kosmetik', 'Hairdresser & Beauty': 'Friseur & Kosmetik',
'Gym & Fitness': 'Fitnessstudio', 'Gym & Fitness': 'Fitnessstudio',
'Dry Cleaner & Laundry': 'Reinigung & W\u00E4scherei', 'Dry Cleaner & Laundry': 'Reinigung & Wäscherei',
'Car Services': 'Autoservice', 'Car Services': 'Autoservice',
'Post Office': 'Postamt', 'Post Office': 'Postamt',
'Vet & Pet Care': 'Tierarzt & Tierpflege', 'Vet & Pet Care': 'Tierarzt & Tierpflege',
'Bank': 'Bank', 'Bank': 'Bank',
'Travel Agent': 'Reiseb\u00FCro', 'Travel Agent': 'Reisebüro',
'Police': 'Polizei', 'Police': 'Polizei',
'Fire Station': 'Feuerwache', 'Fire Station': 'Feuerwache',
'Ambulance Station': 'Rettungswache', 'Ambulance Station': 'Rettungswache',
@ -849,18 +858,18 @@ const de: Translations = {
'Physiotherapy': 'Physiotherapie', 'Physiotherapy': 'Physiotherapie',
'Counselling & Therapy': 'Beratung & Therapie', 'Counselling & Therapy': 'Beratung & Therapie',
'Care Home': 'Pflegeheim', 'Care Home': 'Pflegeheim',
'Medical & Mobility': 'Medizintechnik & Mobilit\u00E4t', 'Medical & Mobility': 'Medizintechnik & Mobilität',
'Museum': 'Museum', 'Museum': 'Museum',
'Gallery': 'Galerie', 'Gallery': 'Galerie',
'Library': 'Bibliothek', 'Library': 'Bibliothek',
'Place of Worship': 'Gebetsst\u00E4tte', 'Place of Worship': 'Gebetsstätte',
'Arts Centre': 'Kunstzentrum', 'Arts Centre': 'Kunstzentrum',
'Zoo': 'Zoo', 'Zoo': 'Zoo',
'Tourist Attraction': 'Touristenattraktion', 'Tourist Attraction': 'Touristenattraktion',
'School': 'Schule', 'School': 'Schule',
'Hotel': 'Hotel', 'Hotel': 'Hotel',
'Local Business': 'Lokales Gesch\u00E4ft', 'Local Business': 'Lokales Geschäft',
'Offices': 'B\u00FCros', 'Offices': 'Büros',
'EV Charging': 'E-Ladestation', 'EV Charging': 'E-Ladestation',
'Fuel Station': 'Tankstelle', 'Fuel Station': 'Tankstelle',
'Community Centre': 'Gemeindezentrum', 'Community Centre': 'Gemeindezentrum',
@ -868,7 +877,7 @@ const de: Translations = {
// ─ Suffixes (used in formatters) ─ // ─ Suffixes (used in formatters) ─
'/mo': '/Monat', '/mo': '/Monat',
'/yr': '/Jahr', '/yr': '/Jahr',
' sqm': ' m\u00B2', ' sqm': ' m²',
' km': ' km', ' km': ' km',
' m': ' m', ' m': ' m',
' dB': ' dB', ' dB': ' dB',

View file

@ -139,6 +139,11 @@ const en = {
removeFilterHint: 'Remove a filter to see available features', removeFilterHint: 'Remove a filter to see available features',
featureInfo: 'Feature info', featureInfo: 'Feature info',
replayTutorial: 'Replay interactive tutorial', replayTutorial: 'Replay interactive tutorial',
clearAll: 'Clear all',
clearAllTitle: 'Clear all filters?',
clearAllSavePrompt: 'Would you like to save your current filters before clearing?',
saveAndClear: 'Save & Clear',
clearWithoutSaving: 'Clear without saving',
}, },
// ── Philosophy Popup ─────────────────────────────── // ── Philosophy Popup ───────────────────────────────
@ -199,9 +204,9 @@ const en = {
describeIdealArea: 'Describe your ideal area with AI', describeIdealArea: 'Describe your ideal area with AI',
aiSearch: 'AI Search', aiSearch: 'AI Search',
describeHint: "describe what you're looking for", describeHint: "describe what you're looking for",
placeholder: 'e.g. quiet area, under \u00A3400k, near good schools...', placeholder: 'e.g. quiet area, under £400k, near good schools...',
example1: 'Safe area near good schools', example1: 'Safe area near good schools',
example2: '30 min commute to Kings Cross, under \u00A3500k', example2: '30 min commute to Kings Cross, under £500k',
example3: 'Quiet village, 3 bed, fast broadband', example3: 'Quiet village, 3 bed, fast broadband',
analysing: 'Analysing your query...', analysing: 'Analysing your query...',
searchingDestinations: 'Searching for destinations...', searchingDestinations: 'Searching for destinations...',
@ -213,6 +218,11 @@ const en = {
// ── Map Legend ───────────────────────────────────── // ── Map Legend ─────────────────────────────────────
mapLegend: { mapLegend: {
clearColourView: 'Clear colour view', clearColourView: 'Clear colour view',
historicalMatches: 'Historical property matches',
propertiesForSale: 'Properties for sale',
propertiesForRent: 'Properties for rent',
numberOfProperties: 'Number of properties',
previewing: 'Previewing \u201c{{name}}\u201d',
}, },
// ── Properties Pane ──────────────────────────────── // ── Properties Pane ────────────────────────────────
@ -220,7 +230,7 @@ const en = {
unknownAddress: 'Unknown Address', unknownAddress: 'Unknown Address',
unsaveProperty: 'Unsave property', unsaveProperty: 'Unsave property',
saveProperty: 'Save property', saveProperty: 'Save property',
lastSold: 'Last sold: \u00A3{{price}}', lastSold: 'Last sold: £{{price}}',
estValue: 'Est. value:', estValue: 'Est. value:',
type: 'Type:', type: 'Type:',
builtForm: 'Built form:', builtForm: 'Built form:',
@ -237,7 +247,7 @@ const en = {
renovations: 'Renovations', renovations: 'Renovations',
viewExternalListing: 'View external listing', viewExternalListing: 'View external listing',
perMonth: '/mo', perMonth: '/mo',
perSqm: '/m\u00B2', perSqm: '/m²',
searchPlaceholder: 'Search by address or postcode...', searchPlaceholder: 'Search by address or postcode...',
propertyData: 'Property Data', propertyData: 'Property Data',
propertyDataDesc: 'Prices come from HM Land Registry (what buyers actually paid). Floor area, energy ratings, construction year, and tenure come from official EPC surveys. Both sources are matched by address within each postcode.', propertyDataDesc: 'Prices come from HM Land Registry (what buyers actually paid). Floor area, energy ratings, construction year, and tenure come from official EPC surveys. Both sources are matched by address within each postcode.',
@ -322,16 +332,16 @@ const en = {
philosophyP2: 'We flip that. Tell us what you need (budget, commute, schools, safety) and we show you every area in England that qualifies. No guesswork. No wasted viewings.', philosophyP2: 'We flip that. Tell us what you need (budget, commute, schools, safety) and we show you every area in England that qualifies. No guesswork. No wasted viewings.',
howToUseIt: 'How to use it', howToUseIt: 'How to use it',
howStep1Title: 'Set your must-haves', howStep1Title: 'Set your must-haves',
howStep1Desc: 'Budget, commute, schools \u2014 the map shows only what qualifies.', howStep1Desc: 'Budget, commute, schools the map shows only what qualifies.',
howStep2Title: 'Explore areas and discover hidden gems', howStep2Title: 'Explore areas and discover hidden gems',
howStep2Desc: 'Zoom in, dig into details and nice to haves.', howStep2Desc: 'Zoom in, dig into details and nice to haves.',
howStep3Title: 'Drill into postcodes', howStep3Title: 'Drill into postcodes',
howStep3Desc: 'See individual properties, sale prices, floor area, and compare.', howStep3Desc: 'See individual properties, sale prices, floor area, and compare.',
howStep4Title: 'Shortlist with confidence', howStep4Title: 'Shortlist with confidence',
howStep4Desc: 'Every area on your list meets your actual criteria \u2014 not just what was listed that week.', howStep4Desc: 'Every area on your list meets your actual criteria not just what was listed that week.',
othersVs: 'Others vs', othersVs: 'Others vs',
listingPortals: 'Listing portals', listingPortals: 'Listing portals',
checkMyPostcode: '\u201CCheck my postcode\u201D', checkMyPostcode: '“Check my postcode”',
areaGuides: 'Area guides', areaGuides: 'Area guides',
compSearchWithout: 'Search without choosing an area first', compSearchWithout: 'Search without choosing an area first',
compSearchWithoutSub: '(start with needs, not a location)', compSearchWithoutSub: '(start with needs, not a location)',
@ -341,7 +351,7 @@ const en = {
compPropertyDataSub: '(price, EPC, floor area)', compPropertyDataSub: '(price, EPC, floor area)',
compFilters: '56 combinable filters in one place', compFilters: '56 combinable filters in one place',
compFiltersSub: '(all insights, one interactive map)', compFiltersSub: '(all insights, one interactive map)',
ctaTitle: 'Make your biggest investment your smartest\u00A0move.', ctaTitle: 'Make your biggest investment your smartest move.',
ctaDescription: "This deserves proper tools behind it, don't leave it to luck.", ctaDescription: "This deserves proper tools behind it, don't leave it to luck.",
}, },
@ -349,7 +359,7 @@ const en = {
pricingPage: { pricingPage: {
title: 'Early access pricing', title: 'Early access pricing',
subtitle: 'Pay once, access forever. The earlier you join, the less you pay.', subtitle: 'Pay once, access forever. The earlier you join, the less you pay.',
costContext: "Buying a home costs \u00A310k+ in stamp duty, \u00A31,500 in solicitor fees, \u00A3500 for a survey. Get the wrong area and you're stuck with a long commute, bad schools, or a road you didn't know about.", costContext: "Buying a home costs £10k+ in stamp duty, £1,500 in solicitor fees, £500 for a survey. Get the wrong area and you're stuck with a long commute, bad schools, or a road you didn't know about.",
lessThanSurvey: 'Less than a home survey. Far more useful.', lessThanSurvey: 'Less than a home survey. Far more useful.',
currentTier: 'Current tier', currentTier: 'Current tier',
firstNUsers: 'First {{count}} users', firstNUsers: 'First {{count}} users',
@ -386,13 +396,13 @@ const en = {
source: 'Source:', source: 'Source:',
optOut: 'Opt out of public disclosure', optOut: 'Opt out of public disclosure',
attribution: 'Attribution', attribution: 'Attribution',
attrLandRegistry: 'Contains HM Land Registry data \u00A9 Crown copyright and database right 2025.', attrLandRegistry: 'Contains HM Land Registry data © Crown copyright and database right 2025.',
attrOgl: 'Contains public sector information licensed under the', attrOgl: 'Contains public sector information licensed under the',
attrOglLink: 'Open Government Licence v3.0', attrOglLink: 'Open Government Licence v3.0',
attrOs: 'Contains OS data \u00A9 Crown copyright and database rights 2025.', attrOs: 'Contains OS data © Crown copyright and database rights 2025.',
attrTfl: 'Powered by TfL Open Data.', attrTfl: 'Powered by TfL Open Data.',
attrOsm: 'Contains data from', attrOsm: 'Contains data from',
attrOsmContrib: '\u00A9 OpenStreetMap contributors', attrOsmContrib: '© OpenStreetMap contributors',
attrOsmLicense: 'available under the', attrOsmLicense: 'available under the',
attrOsmLicenseLink: 'Open Data Commons Open Database License (ODbL)', attrOsmLicenseLink: 'Open Data Commons Open Database License (ODbL)',
// Data source names & descriptions // Data source names & descriptions
@ -497,12 +507,12 @@ const en = {
faqPricing3Q: 'What can I access on the free tier?', 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.', 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.',
faqPricing4Q: 'Can I get a refund?', faqPricing4Q: 'Can I get a refund?',
faqPricing4A: 'Absolutely. We offer a 30-day money-back guarantee. If you\u2019re not satisfied, email support@perfect-postcode.co.uk within 30 days for a full refund.', faqPricing4A: 'Absolutely. We offer a 30-day money-back guarantee. If youre not satisfied, email support@perfect-postcode.co.uk within 30 days for a full refund.',
// FAQ items — Tips and Tricks // FAQ items — Tips and Tricks
faqTips1Q: 'How do I use the AI filter instead of adding filters one by one?', faqTips1Q: 'How do I use the AI filter instead of adding filters one by one?',
faqTips1A: 'Type what you want in plain English, something like "quiet area near good schools with fast broadband under \u00A3400k", and it\'ll set up all the relevant filters in one go. Tweak any of them manually afterwards.', faqTips1A: 'Type what you want in plain English, something like "quiet area near good schools with fast broadband under £400k", and it\'ll set up all the relevant filters in one go. Tweak any of them manually afterwards.',
faqTips2Q: 'Can I save a search and come back to it later?', faqTips2Q: 'Can I save a search and come back to it later?',
faqTips2A: 'Hit the save button and everything is captured: your filters, zoom level, and which data layer you\u2019re colouring by. Pick up exactly where you left off or share the link with your partner.', 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 I'm looking at?", faqTips3Q: "Can I export the data I'm looking at?",
faqTips3A: 'Use the export button to download the currently filtered properties as a spreadsheet. The export respects all your active filters, so you get exactly the data you want.', faqTips3A: 'Use the export button to download the currently filtered properties as a spreadsheet. The export respects all your active filters, so you get exactly the data you want.',
}, },
@ -512,7 +522,7 @@ const en = {
emailLabel: 'Email', emailLabel: 'Email',
subscriptionLabel: 'Subscription', subscriptionLabel: 'Subscription',
upgrade: 'Upgrade', upgrade: 'Upgrade',
redirecting: 'Redirecting\u2026', redirecting: 'Redirecting',
receiveNewsletter: 'Receive newsletter emails', receiveNewsletter: 'Receive newsletter emails',
needHelp: 'Need help? Email us at', needHelp: 'Need help? Email us at',
responseTime: 'We typically respond within 24 hours.', responseTime: 'We typically respond within 24 hours.',
@ -613,15 +623,15 @@ const en = {
step1Title: 'Tell the map what matters', step1Title: 'Tell the map what matters',
step1Content: 'Set your budget, commute limit, school quality, crime threshold. Whatever matters to you. Only areas that qualify stay lit. Use the eye icon to colour by any feature.', step1Content: 'Set your budget, commute limit, school quality, crime threshold. Whatever matters to you. Only areas that qualify stay lit. Use the eye icon to colour by any feature.',
step2Title: 'Or just describe it', step2Title: 'Or just describe it',
step2Content: 'Type what you want in plain English, like "quiet area near good schools under \u00A3400k", and we\u2019ll set up the filters for you.', step2Content: 'Type what you want in plain English, like "quiet area near good schools under £400k", and well set up the filters for you.',
step3Title: 'Explore what\u2019s out there', step3Title: 'Explore whats out there',
step3Content: 'Pan and zoom across England. Click any coloured area to see crime, schools, prices, broadband, noise, and more about that neighbourhood.', step3Content: 'Pan and zoom across England. Click any coloured area to see crime, schools, prices, broadband, noise, and more about that neighbourhood.',
step4Title: 'Jump to a location', step4Title: 'Jump to a location',
step4Content: 'Search for any place or postcode to fly straight there.', step4Content: 'Search for any place or postcode to fly straight there.',
step5Title: 'Dig into the details', step5Title: 'Dig into the details',
step5Content: 'See area statistics, histograms, and individual property records: prices, floor area, energy ratings, and more.', step5Content: 'See area statistics, histograms, and individual property records: prices, floor area, energy ratings, and more.',
step6Title: 'What\u2019s nearby?', step6Title: 'Whats nearby?',
step6Content: 'Toggle schools, shops, stations, parks, and restaurants on the map to see what\u2019s within reach.', step6Content: 'Toggle schools, shops, stations, parks, and restaurants on the map to see whats within reach.',
}, },
// ── Server-derived values ────────────────────────── // ── Server-derived values ──────────────────────────
@ -663,7 +673,6 @@ const en = {
// ─ Feature names (Transport) ─ // ─ Feature names (Transport) ─
'Distance to nearest train or tube station (km)': 'Distance to nearest train or tube station (km)', 'Distance to nearest train or tube station (km)': 'Distance to nearest train or tube station (km)',
'Train or tube stations within 1km': 'Train or tube stations within 1km',
// ─ Feature names (Education) ─ // ─ Feature names (Education) ─
'Good+ primary schools within 2km': 'Good+ primary schools within 2km', 'Good+ primary schools within 2km': 'Good+ primary schools within 2km',
@ -756,7 +765,7 @@ const en = {
'Bus station': 'Bus station', 'Bus station': 'Bus station',
'Taxi rank': 'Taxi rank', 'Taxi rank': 'Taxi rank',
'Metro or Tram stop': 'Metro or Tram stop', 'Metro or Tram stop': 'Metro or Tram stop',
'Caf\u00E9': 'Caf\u00E9', 'Café': 'Café',
'Restaurant': 'Restaurant', 'Restaurant': 'Restaurant',
'Pub': 'Pub', 'Pub': 'Pub',
'Bar': 'Bar', 'Bar': 'Bar',

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -141,6 +141,11 @@ const zh: Translations = {
removeFilterHint: '移除一个筛选条件以查看可用的数据指标', removeFilterHint: '移除一个筛选条件以查看可用的数据指标',
featureInfo: '数据指标信息', featureInfo: '数据指标信息',
replayTutorial: '重新播放交互教程', replayTutorial: '重新播放交互教程',
clearAll: '全部清除',
clearAllTitle: '清除所有筛选条件?',
clearAllSavePrompt: '是否要在清除前保存当前的筛选条件?',
saveAndClear: '保存并清除',
clearWithoutSaving: '不保存直接清除',
}, },
// ── Philosophy Popup ─────────────────────────────── // ── Philosophy Popup ───────────────────────────────
@ -201,9 +206,9 @@ const zh: Translations = {
describeIdealArea: '用 AI 描述您的理想区域', describeIdealArea: '用 AI 描述您的理想区域',
aiSearch: 'AI 搜索', aiSearch: 'AI 搜索',
describeHint: '描述您要找的区域', describeHint: '描述您要找的区域',
placeholder: '例如:安静的区域,低于 \u00A340万靠近好学校...', placeholder: '例如:安静的区域,低于 £40万靠近好学校...',
example1: '安全的区域,靠近好学校', example1: '安全的区域,靠近好学校',
example2: '到国王十字站30分钟通勤低于 \u00A350万', example2: '到国王十字站30分钟通勤低于 £50万',
example3: '安静的村庄3间卧室快速宽带', example3: '安静的村庄3间卧室快速宽带',
analysing: '正在分析您的需求...', analysing: '正在分析您的需求...',
searchingDestinations: '正在搜索目的地...', searchingDestinations: '正在搜索目的地...',
@ -215,6 +220,11 @@ const zh: Translations = {
// ── Map Legend ───────────────────────────────────── // ── Map Legend ─────────────────────────────────────
mapLegend: { mapLegend: {
clearColourView: '清除颜色视图', clearColourView: '清除颜色视图',
historicalMatches: '历史房产匹配',
propertiesForSale: '待售房产',
propertiesForRent: '待租房产',
numberOfProperties: '房产数量',
previewing: '预览\u201c{{name}}\u201d',
}, },
// ── Properties Pane ──────────────────────────────── // ── Properties Pane ────────────────────────────────
@ -222,7 +232,7 @@ const zh: Translations = {
unknownAddress: '地址未知', unknownAddress: '地址未知',
unsaveProperty: '取消收藏', unsaveProperty: '取消收藏',
saveProperty: '收藏房产', saveProperty: '收藏房产',
lastSold: '上次成交价:\u00A3{{price}}', lastSold: '上次成交价:£{{price}}',
estValue: '估计价值:', estValue: '估计价值:',
type: '类型:', type: '类型:',
builtForm: '建筑形式:', builtForm: '建筑形式:',
@ -239,7 +249,7 @@ const zh: Translations = {
renovations: '翻新记录', renovations: '翻新记录',
viewExternalListing: '查看外部房源', viewExternalListing: '查看外部房源',
perMonth: '/月', perMonth: '/月',
perSqm: '/m\u00B2', perSqm: '/m²',
searchPlaceholder: '按地址或邮编搜索...', searchPlaceholder: '按地址或邮编搜索...',
propertyData: '房产数据', propertyData: '房产数据',
propertyDataDesc: '价格来自英国土地注册局(买家实际支付的金额)。建筑面积、能源评级、建造年份和产权来自官方能源性能证书调查。两个数据源通过每个邮编内的地址进行匹配。', propertyDataDesc: '价格来自英国土地注册局(买家实际支付的金额)。建筑面积、能源评级、建造年份和产权来自官方能源性能证书调查。两个数据源通过每个邮编内的地址进行匹配。',
@ -343,7 +353,7 @@ const zh: Translations = {
compPropertyDataSub: '(价格、能源性能证书、建筑面积)', compPropertyDataSub: '(价格、能源性能证书、建筑面积)',
compFilters: '56 项可组合筛选条件,尽在一处', compFilters: '56 项可组合筛选条件,尽在一处',
compFiltersSub: '(所有信息,一张交互式地图)', compFiltersSub: '(所有信息,一张交互式地图)',
ctaTitle: '让您最大的投资成为最明智的\u00A0决定。', ctaTitle: '让您最大的投资成为最明智的 决定。',
ctaDescription: '这值得用专业的工具来做,别全靠运气。', ctaDescription: '这值得用专业的工具来做,别全靠运气。',
}, },
@ -351,7 +361,7 @@ const zh: Translations = {
pricingPage: { pricingPage: {
title: '早期访问价格', title: '早期访问价格',
subtitle: '一次付款,永久访问。越早加入,价格越优惠。', subtitle: '一次付款,永久访问。越早加入,价格越优惠。',
costContext: '买房需要支付超过 \u00A310,000 的印花税、\u00A31,500 的律师费、\u00A3500 的房屋评估费。选错区域,您可能要忍受漫长的通勤、差劲的学校,或一条您事先不知道的嘈杂马路。', costContext: '买房需要支付超过 £10,000 的印花税、£1,500 的律师费、£500 的房屋评估费。选错区域,您可能要忍受漫长的通勤、差劲的学校,或一条您事先不知道的嘈杂马路。',
lessThanSurvey: '不到一次房屋评估的费用,却有用得多。', lessThanSurvey: '不到一次房屋评估的费用,却有用得多。',
currentTier: '当前档位', currentTier: '当前档位',
firstNUsers: '前 {{count}} 名用户', firstNUsers: '前 {{count}} 名用户',
@ -514,7 +524,7 @@ const zh: Translations = {
emailLabel: '邮箱', emailLabel: '邮箱',
subscriptionLabel: '订阅', subscriptionLabel: '订阅',
upgrade: '升级', upgrade: '升级',
redirecting: '跳转中\u2026', redirecting: '跳转中',
receiveNewsletter: '接收新闻邮件', receiveNewsletter: '接收新闻邮件',
needHelp: '需要帮助?请发邮件至', needHelp: '需要帮助?请发邮件至',
responseTime: '我们通常在 24 小时内回复。', responseTime: '我们通常在 24 小时内回复。',
@ -592,38 +602,38 @@ const zh: Translations = {
// ── Format / Time ────────────────────────────────── // ── Format / Time ──────────────────────────────────
format: { format: {
justNow: '\u521A\u521A', justNow: '刚刚',
minutesAgo: '{{count}}\u5206\u949F\u524D', minutesAgo: '{{count}}分钟前',
hoursAgo: '{{count}}\u5C0F\u65F6\u524D', hoursAgo: '{{count}}小时前',
daysAgo: '{{count}}\u5929\u524D', daysAgo: '{{count}}天前',
nFilters: '{{count}} \u4E2A\u7B5B\u9009', nFilters: '{{count}} 个筛选',
noFilters: '\u65E0\u7B5B\u9009', noFilters: '无筛选',
poiCategory: '{{count}} \u4E2A POI \u7C7B\u522B', poiCategory: '{{count}} 个 POI 类别',
poiCategories: '{{count}} \u4E2A POI \u7C7B\u522B', poiCategories: '{{count}} 个 POI 类别',
travelDestination: '{{count}} \u4E2A\u51FA\u884C\u76EE\u7684\u5730', travelDestination: '{{count}} 个出行目的地',
travelDestinations: '{{count}} \u4E2A\u51FA\u884C\u76EE\u7684\u5730', travelDestinations: '{{count}} 个出行目的地',
propertiesMatch: '{{count}} \u5957\u623F\u4EA7\u7B26\u5408', propertiesMatch: '{{count}} 套房产符合',
setFilters: '\u8BBE\u7F6E {{count}} \u4E2A\u7B5B\u9009\uFF1A{{list}}', setFilters: '设置 {{count}} 个筛选:{{list}}',
noFiltersSet: '\u672A\u8BBE\u7F6E\u7B5B\u9009', noFiltersSet: '未设置筛选',
toDestination: '{{mode}}\u5230 {{label}} {{bounds}}', toDestination: '{{mode}} {{label}} {{bounds}}',
lessThanMin: '< {{max}} \u5206\u949F', lessThanMin: '< {{max}} 分钟',
moreThanMin: '> {{min}} \u5206\u949F', moreThanMin: '> {{min}} 分钟',
}, },
// ── Tutorial ────────────────────────────────────── // ── Tutorial ──────────────────────────────────────
tutorial: { tutorial: {
step1Title: '\u544A\u8BC9\u5730\u56FE\u4EC0\u4E48\u91CD\u8981', step1Title: '告诉地图什么重要',
step1Content: '\u8BBE\u7F6E\u9884\u7B97\u3001\u901A\u52E4\u4E0A\u9650\u3001\u5B66\u6821\u8D28\u91CF\u3001\u72AF\u7F6A\u95E8\u69DB\u3002\u60A8\u5173\u5FC3\u7684\u4E00\u5207\u3002\u53EA\u6709\u7B26\u5408\u6761\u4EF6\u7684\u533A\u57DF\u4F1A\u4FDD\u6301\u9AD8\u4EAE\u3002\u4F7F\u7528\u773C\u775B\u56FE\u6807\u6309\u4EFB\u610F\u7279\u5F81\u7740\u8272\u3002', step1Content: '设置预算、通勤上限、学校质量、犯罪门槛。您关心的一切。只有符合条件的区域会保持高亮。使用眼睛图标按任意特征着色。',
step2Title: '\u6216\u8005\u76F4\u63A5\u63CF\u8FF0', step2Title: '或者直接描述',
step2Content: '\u7528\u4E2D\u6587\u8F93\u5165\u60A8\u7684\u9700\u6C42\uFF0C\u4F8B\u5982\u201C\u5B89\u9759\u7684\u5730\u533A\uFF0C\u9760\u8FD1\u597D\u5B66\u6821\uFF0C\u00A3400k \u4EE5\u4E0B\u201D\uFF0C\u6211\u4EEC\u4F1A\u4E3A\u60A8\u8BBE\u7F6E\u7B5B\u9009\u3002', step2Content: '用中文输入您的需求例如“安静的地区靠近好学校£400k 以下”,我们会为您设置筛选。',
step3Title: '\u63A2\u7D22\u73B0\u6709\u4F4F\u5B85', step3Title: '探索现有住宅',
step3Content: '\u5728\u82F1\u683C\u5170\u5404\u5730\u5E73\u79FB\u548C\u7F29\u653E\u3002\u70B9\u51FB\u4EFB\u4F55\u5F69\u8272\u533A\u57DF\u67E5\u770B\u72AF\u7F6A\u3001\u5B66\u6821\u3001\u4EF7\u683C\u3001\u5BBD\u5E26\u3001\u566A\u97F3\u7B49\u4FE1\u606F\u3002', step3Content: '在英格兰各地平移和缩放。点击任何彩色区域查看犯罪、学校、价格、宽带、噪音等信息。',
step4Title: '\u8DF3\u8F6C\u5230\u67D0\u4E2A\u4F4D\u7F6E', step4Title: '跳转到某个位置',
step4Content: '\u641C\u7D22\u4EFB\u4F55\u5730\u70B9\u6216\u90AE\u7F16\uFF0C\u5373\u53EF\u76F4\u63A5\u8DF3\u8F6C\u3002', step4Content: '搜索任何地点或邮编,即可直接跳转。',
step5Title: '\u6DF1\u5165\u4E86\u89E3\u7EC6\u8282', step5Title: '深入了解细节',
step5Content: '\u67E5\u770B\u533A\u57DF\u7EDF\u8BA1\u3001\u76F4\u65B9\u56FE\u548C\u5355\u4E2A\u623F\u4EA7\u8BB0\u5F55\uFF1A\u4EF7\u683C\u3001\u5EFA\u7B51\u9762\u79EF\u3001\u80FD\u6548\u8BC4\u7EA7\u7B49\u3002', step5Content: '查看区域统计、直方图和单个房产记录:价格、建筑面积、能效评级等。',
step6Title: '\u9644\u8FD1\u6709\u4EC0\u4E48\uFF1F', step6Title: '附近有什么?',
step6Content: '\u5728\u5730\u56FE\u4E0A\u5F00\u542F\u5B66\u6821\u3001\u5546\u5E97\u3001\u8F66\u7AD9\u3001\u516C\u56ED\u548C\u9910\u5385\u56FE\u5C42\uFF0C\u67E5\u770B\u5468\u8FB9\u8BBE\u65BD\u3002', step6Content: '在地图上开启学校、商店、车站、公园和餐厅图层,查看周边设施。',
}, },
// ── Server-derived values ────────────────────────── // ── Server-derived values ──────────────────────────
@ -631,209 +641,208 @@ const zh: Translations = {
// The English keys MUST match exactly what the API returns. // The English keys MUST match exactly what the API returns.
server: { server: {
// ─ Feature group names ─ // ─ Feature group names ─
'Properties': '\u623F\u4EA7', 'Properties': '房产',
'Transport': '\u4EA4\u901A', 'Transport': '交通',
'Education': '\u6559\u80B2', 'Education': '教育',
'Deprivation': '\u8D2B\u56F0\u6307\u6570', 'Deprivation': '贫困指数',
'Crime': '\u72AF\u7F6A', 'Crime': '犯罪',
'Demographics': '\u4EBA\u53E3\u7EDF\u8BA1', 'Demographics': '人口统计',
'Amenities': '\u914D\u5957\u8BBE\u65BD', 'Amenities': '配套设施',
// ─ Feature names (Properties) ─ // ─ Feature names (Properties) ─
'Listing status': '\u623F\u6E90\u72B6\u6001', 'Listing status': '房源状态',
'Property type': '\u623F\u4EA7\u7C7B\u578B', 'Property type': '房产类型',
'Leasehold/Freehold': '\u79DF\u8D41\u4EA7\u6743/\u6C38\u4E45\u4EA7\u6743', 'Leasehold/Freehold': '租赁产权/永久产权',
'Last known price': '\u4E0A\u6B21\u6210\u4EA4\u4EF7', 'Last known price': '上次成交价',
'Estimated current price': '\u4F30\u8BA1\u5F53\u524D\u4EF7\u683C', 'Estimated current price': '估计当前价格',
'Asking price': '\u6302\u724C\u4EF7', 'Asking price': '挂牌价',
'Price per sqm': '\u6BCF\u5E73\u65B9\u7C73\u4EF7\u683C', 'Price per sqm': '每平方米价格',
'Est. price per sqm': '\u4F30\u8BA1\u6BCF\u5E73\u65B9\u7C73\u4EF7\u683C', 'Est. price per sqm': '估计每平方米价格',
'Asking price per sqm': '\u6302\u724C\u4EF7\u6BCF\u5E73\u65B9\u7C73', 'Asking price per sqm': '挂牌价每平方米',
'Estimated monthly rent': '\u4F30\u8BA1\u6708\u79DF', 'Estimated monthly rent': '估计月租',
'Asking rent (monthly)': '\u6708\u79DF', 'Asking rent (monthly)': '月租',
'Total floor area (sqm)': '\u603B\u5EFA\u7B51\u9762\u79EF\uFF08\u5E73\u65B9\u7C73\uFF09', 'Total floor area (sqm)': '总建筑面积(平方米)',
'Number of bedrooms & living rooms': '\u5367\u5BA4\u548C\u5BA2\u5385\u6570\u91CF', 'Number of bedrooms & living rooms': '卧室和客厅数量',
'Bedrooms': '\u5367\u5BA4', 'Bedrooms': '卧室',
'Bathrooms': '\u6D74\u5BA4', 'Bathrooms': '浴室',
'Construction year': '\u5EFA\u9020\u5E74\u4EFD', 'Construction year': '建造年份',
'Date of last transaction': '\u4E0A\u6B21\u4EA4\u6613\u65E5\u671F', 'Date of last transaction': '上次交易日期',
'Listing date': '\u4E0A\u5E02\u65E5\u671F', 'Listing date': '上市日期',
'Former council house': '\u539F\u516C\u5171\u4F4F\u623F', 'Former council house': '原公共住房',
'Current energy rating': '\u5F53\u524D\u80FD\u6E90\u8BC4\u7EA7', 'Current energy rating': '当前能源评级',
'Potential energy rating': '\u6F5C\u5728\u80FD\u6E90\u8BC4\u7EA7', 'Potential energy rating': '潜在能源评级',
'Interior height (m)': '\u5BA4\u5185\u5C42\u9AD8\uFF08\u7C73\uFF09', 'Interior height (m)': '室内层高(米)',
// ─ Feature names (Transport) ─ // ─ Feature names (Transport) ─
'Distance to nearest train or tube station (km)': '\u5230\u6700\u8FD1\u706B\u8F66\u6216\u5730\u94C1\u7AD9\u7684\u8DDD\u79BB\uFF08\u516C\u91CC\uFF09', 'Distance to nearest train or tube station (km)': '到最近火车或地铁站的距离(公里)',
'Train or tube stations within 1km': '1\u516C\u91CC\u5185\u706B\u8F66\u6216\u5730\u94C1\u7AD9\u6570\u91CF',
// ─ Feature names (Education) ─ // ─ Feature names (Education) ─
'Good+ primary schools within 2km': '2\u516C\u91CC\u5185\u826F\u597D+\u5C0F\u5B66\u6570\u91CF', 'Good+ primary schools within 2km': '2公里内良好+小学数量',
'Good+ secondary schools within 2km': '2\u516C\u91CC\u5185\u826F\u597D+\u4E2D\u5B66\u6570\u91CF', 'Good+ secondary schools within 2km': '2公里内良好+中学数量',
'Good+ primary schools within 5km': '5\u516C\u91CC\u5185\u826F\u597D+\u5C0F\u5B66\u6570\u91CF', 'Good+ primary schools within 5km': '5公里内良好+小学数量',
'Good+ secondary schools within 5km': '5\u516C\u91CC\u5185\u826F\u597D+\u4E2D\u5B66\u6570\u91CF', 'Good+ secondary schools within 5km': '5公里内良好+中学数量',
'Education, Skills and Training Score': '\u6559\u80B2\u3001\u6280\u80FD\u548C\u57F9\u8BAD\u5F97\u5206', 'Education, Skills and Training Score': '教育、技能和培训得分',
// ─ Feature names (Deprivation) ─ // ─ Feature names (Deprivation) ─
'Income Score (rate)': '\u6536\u5165\u5F97\u5206\uFF08\u6BD4\u7387\uFF09', 'Income Score (rate)': '收入得分(比率)',
'Employment Score (rate)': '\u5C31\u4E1A\u5F97\u5206\uFF08\u6BD4\u7387\uFF09', 'Employment Score (rate)': '就业得分(比率)',
'Health Deprivation and Disability Score': '\u5065\u5EB7\u4E0E\u6B8B\u969C\u5F97\u5206', 'Health Deprivation and Disability Score': '健康与残障得分',
'Living Environment Score': '\u5C45\u4F4F\u73AF\u5883\u5F97\u5206', 'Living Environment Score': '居住环境得分',
'Indoors Sub-domain Score': '\u5BA4\u5185\u5B50\u9886\u57DF\u5F97\u5206', 'Indoors Sub-domain Score': '室内子领域得分',
'Outdoors Sub-domain Score': '\u5BA4\u5916\u5B50\u9886\u57DF\u5F97\u5206', 'Outdoors Sub-domain Score': '室外子领域得分',
// ─ Feature names (Crime) ─ // ─ Feature names (Crime) ─
'Serious crime per 1k residents (avg/yr)': '\u6BCF\u5343\u4EBA\u4E25\u91CD\u72AF\u7F6A\uFF08\u5E74\u5747\uFF09', 'Serious crime per 1k residents (avg/yr)': '每千人严重犯罪(年均)',
'Minor crime per 1k residents (avg/yr)': '\u6BCF\u5343\u4EBA\u8F7B\u5FAE\u72AF\u7F6A\uFF08\u5E74\u5747\uFF09', 'Minor crime per 1k residents (avg/yr)': '每千人轻微犯罪(年均)',
'Serious crime (avg/yr)': '\u4E25\u91CD\u72AF\u7F6A\uFF08\u5E74\u5747\uFF09', 'Serious crime (avg/yr)': '严重犯罪(年均)',
'Minor crime (avg/yr)': '\u8F7B\u5FAE\u72AF\u7F6A\uFF08\u5E74\u5747\uFF09', 'Minor crime (avg/yr)': '轻微犯罪(年均)',
'Violence and sexual offences (avg/yr)': '\u66B4\u529B\u548C\u6027\u72AF\u7F6A\uFF08\u5E74\u5747\uFF09', 'Violence and sexual offences (avg/yr)': '暴力和性犯罪(年均)',
'Burglary (avg/yr)': '\u5165\u5BA4\u76D7\u7A83\uFF08\u5E74\u5747\uFF09', 'Burglary (avg/yr)': '入室盗窃(年均)',
'Robbery (avg/yr)': '\u62A2\u52AB\uFF08\u5E74\u5747\uFF09', 'Robbery (avg/yr)': '抢劫(年均)',
'Vehicle crime (avg/yr)': '\u8F66\u8F86\u72AF\u7F6A\uFF08\u5E74\u5747\uFF09', 'Vehicle crime (avg/yr)': '车辆犯罪(年均)',
'Anti-social behaviour (avg/yr)': '\u53CD\u793E\u4F1A\u884C\u4E3A\uFF08\u5E74\u5747\uFF09', 'Anti-social behaviour (avg/yr)': '反社会行为(年均)',
'Criminal damage and arson (avg/yr)': '\u5211\u4E8B\u6BC1\u574F\u548C\u7EB5\u706B\uFF08\u5E74\u5747\uFF09', 'Criminal damage and arson (avg/yr)': '刑事毁坏和纵火(年均)',
'Other theft (avg/yr)': '\u5176\u4ED6\u76D7\u7A83\uFF08\u5E74\u5747\uFF09', 'Other theft (avg/yr)': '其他盗窃(年均)',
'Theft from the person (avg/yr)': '\u4EBA\u8EAB\u76D7\u7A83\uFF08\u5E74\u5747\uFF09', 'Theft from the person (avg/yr)': '人身盗窃(年均)',
'Shoplifting (avg/yr)': '\u5546\u5E97\u76D7\u7A83\uFF08\u5E74\u5747\uFF09', 'Shoplifting (avg/yr)': '商店盗窃(年均)',
'Bicycle theft (avg/yr)': '\u81EA\u884C\u8F66\u76D7\u7A83\uFF08\u5E74\u5747\uFF09', 'Bicycle theft (avg/yr)': '自行车盗窃(年均)',
'Drugs (avg/yr)': '\u6BD2\u54C1\u72AF\u7F6A\uFF08\u5E74\u5747\uFF09', 'Drugs (avg/yr)': '毒品犯罪(年均)',
'Possession of weapons (avg/yr)': '\u975E\u6CD5\u6301\u6709\u6B66\u5668\uFF08\u5E74\u5747\uFF09', 'Possession of weapons (avg/yr)': '非法持有武器(年均)',
'Public order (avg/yr)': '\u6270\u4E71\u516C\u5171\u79E9\u5E8F\uFF08\u5E74\u5747\uFF09', 'Public order (avg/yr)': '扰乱公共秩序(年均)',
'Other crime (avg/yr)': '\u5176\u4ED6\u72AF\u7F6A\uFF08\u5E74\u5747\uFF09', 'Other crime (avg/yr)': '其他犯罪(年均)',
// ─ Feature names (Demographics) ─ // ─ Feature names (Demographics) ─
'Median age': '\u4E2D\u4F4D\u5E74\u9F84', 'Median age': '中位年龄',
'% White': '% \u767D\u4EBA', '% White': '% 白人',
'% South Asian': '% \u5357\u4E9A\u88D4', '% South Asian': '% 南亚裔',
'% Black': '% \u9ED1\u4EBA', '% Black': '% 黑人',
'% East Asian': '% \u4E1C\u4E9A\u88D4', '% East Asian': '% 东亚裔',
'% Mixed': '% \u6DF7\u8840', '% Mixed': '% 混血',
'% Other': '% \u5176\u4ED6', '% Other': '% 其他',
// ─ Feature names (Amenities) ─ // ─ Feature names (Amenities) ─
'Distance to nearest park (km)': '\u5230\u6700\u8FD1\u516C\u56ED\u7684\u8DDD\u79BB\uFF08\u516C\u91CC\uFF09', 'Distance to nearest park (km)': '到最近公园的距离(公里)',
'Number of parks within 2km': '2\u516C\u91CC\u5185\u516C\u56ED\u6570\u91CF', 'Number of parks within 2km': '2公里内公园数量',
'Number of restaurants within 2km': '2\u516C\u91CC\u5185\u9910\u5385\u6570\u91CF', 'Number of restaurants within 2km': '2公里内餐厅数量',
'Number of grocery shops and supermarkets within 2km': '2\u516C\u91CC\u5185\u98DF\u54C1\u5E97\u548C\u8D85\u5E02\u6570\u91CF', 'Number of grocery shops and supermarkets within 2km': '2公里内食品店和超市数量',
'Noise (dB)': '\u566A\u97F3\uFF08\u5206\u8D1D\uFF09', 'Noise (dB)': '噪音(分贝)',
'Max available download speed (Mbps)': '\u6700\u5927\u53EF\u7528\u4E0B\u8F7D\u901F\u5EA6\uFF08Mbps\uFF09', 'Max available download speed (Mbps)': '最大可用下载速度Mbps',
// ─ Enum values ─ // ─ Enum values ─
'Historical sale': '\u5386\u53F2\u4EA4\u6613', 'Historical sale': '历史交易',
'For sale': '\u5728\u552E', 'For sale': '在售',
'For rent': '\u51FA\u79DF', 'For rent': '出租',
'Detached': '\u72EC\u7ACB\u5F0F\u4F4F\u5B85', 'Detached': '独立式住宅',
'Semi-Detached': '\u534A\u72EC\u7ACB\u5F0F\u4F4F\u5B85', 'Semi-Detached': '半独立式住宅',
'Terraced': '\u8054\u6392\u4F4F\u5B85', 'Terraced': '联排住宅',
'Flats/Maisonettes': '\u516C\u5BD3/\u590D\u5F0F\u516C\u5BD3', 'Flats/Maisonettes': '公寓/复式公寓',
'Other': '\u5176\u4ED6', 'Other': '其他',
'Freehold': '\u6C38\u4E45\u4EA7\u6743', 'Freehold': '永久产权',
'Leasehold': '\u79DF\u8D41\u4EA7\u6743', 'Leasehold': '租赁产权',
'Yes': '\u662F', 'Yes': '',
'No': '\u5426', 'No': '',
// ─ Stacked chart labels ─ // ─ Stacked chart labels ─
'Serious crime': '\u4E25\u91CD\u72AF\u7F6A', 'Serious crime': '严重犯罪',
'Minor crime': '\u8F7B\u5FAE\u72AF\u7F6A', 'Minor crime': '轻微犯罪',
'Ethnic composition': '\u65CF\u88D4\u7EC4\u6210', 'Ethnic composition': '族裔组成',
// ─ POI group names ─ // ─ POI group names ─
'Public Transport': '\u516C\u5171\u4EA4\u901A', 'Public Transport': '公共交通',
'Leisure': '\u4F11\u95F2', 'Leisure': '休闲',
'Health': '\u5065\u5EB7', 'Health': '健康',
'Emergency Services': '\u7D27\u6025\u670D\u52A1', 'Emergency Services': '紧急服务',
'Groceries': '\u98DF\u54C1\u6742\u8D27', 'Groceries': '食品杂货',
'Local Businesses': '\u672C\u5730\u5546\u4E1A', 'Local Businesses': '本地商业',
'Culture': '\u6587\u5316', 'Culture': '文化',
'Services': '\u670D\u52A1', 'Services': '服务',
'Shops': '\u5546\u5E97', 'Shops': '商店',
// ─ POI categories ─ // ─ POI categories ─
'Airport': '\u673A\u573A', 'Airport': '机场',
'Ferry': '\u6E21\u8F6E', 'Ferry': '渡轮',
'Rail station': '\u706B\u8F66\u7AD9', 'Rail station': '火车站',
'Bus stop': '\u516C\u4EA4\u7AD9', 'Bus stop': '公交站',
'Bus station': '\u516C\u4EA4\u67A2\u7EBD', 'Bus station': '公交枢纽',
'Taxi rank': '\u51FA\u79DF\u8F66\u7AD9', 'Taxi rank': '出租车站',
'Metro or Tram stop': '\u5730\u94C1\u6216\u6709\u8F68\u7535\u8F66\u7AD9', 'Metro or Tram stop': '地铁或有轨电车站',
'Caf\u00E9': '\u5496\u5561\u9986', 'Café': '咖啡馆',
'Restaurant': '\u9910\u5385', 'Restaurant': '餐厅',
'Pub': '\u9152\u5427', 'Pub': '酒吧',
'Bar': '\u9152\u5427', 'Bar': '酒吧',
'Fast Food': '\u5FEB\u9910', 'Fast Food': '快餐',
'Nightclub': '\u591C\u5E97', 'Nightclub': '夜店',
'Cinema': '\u7535\u5F71\u9662', 'Cinema': '电影院',
'Theatre': '\u5267\u9662', 'Theatre': '剧院',
'Live Music & Events': '\u73B0\u573A\u97F3\u4E50\u4E0E\u6D3B\u52A8', 'Live Music & Events': '现场音乐与活动',
'Park': '\u516C\u56ED', 'Park': '公园',
'Playground': '\u6E38\u4E50\u573A', 'Playground': '游乐场',
'Sports Centre': '\u4F53\u80B2\u4E2D\u5FC3', 'Sports Centre': '体育中心',
'Entertainment': '\u5A31\u4E50', 'Entertainment': '娱乐',
'Supermarket': '\u8D85\u5E02', 'Supermarket': '超市',
'Convenience Store': '\u4FBF\u5229\u5E97', 'Convenience Store': '便利店',
'Bakery': '\u9762\u5305\u623A', 'Bakery': '面包戺',
'Butcher & Fishmonger': '\u8089\u94FA\u4E0E\u9C7C\u94FA', 'Butcher & Fishmonger': '肉铺与鱼铺',
'Greengrocer': '\u679C\u852C\u5E97', 'Greengrocer': '果蔬店',
'Off-Licence': '\u9152\u7C7B\u5546\u5E97', 'Off-Licence': '酒类商店',
'Deli & Specialty': '\u719F\u98DF\u4E0E\u7279\u4EA7\u5E97', 'Deli & Specialty': '熟食与特产店',
'Fashion & Clothing': '\u65F6\u88C5\u670D\u9970', 'Fashion & Clothing': '时装服饰',
'Electronics': '\u7535\u5B50\u4EA7\u54C1', 'Electronics': '电子产品',
'Charity Shop': '\u6148\u5584\u5546\u5E97', 'Charity Shop': '慈善商店',
'DIY & Hardware': '\u5EFA\u6750\u4E94\u91D1', 'DIY & Hardware': '建材五金',
'Home & Garden': '\u5BB6\u5C45\u4E0E\u56ED\u827A', 'Home & Garden': '家居与园艺',
'Bookshop': '\u4E66\u5E97', 'Bookshop': '书店',
'Pet Shop': '\u5BA0\u7269\u5E97', 'Pet Shop': '宠物店',
'Sports & Outdoor': '\u4F53\u80B2\u4E0E\u6237\u5916', 'Sports & Outdoor': '体育与户外',
'Newsagent': '\u62A5\u520A\u4EAD', 'Newsagent': '报刊亭',
'Department Store': '\u767E\u8D27\u5546\u5E97', 'Department Store': '百货商店',
'Gift & Hobby': '\u793C\u54C1\u4E0E\u7231\u597D', 'Gift & Hobby': '礼品与爱好',
'Specialist Shop': '\u4E13\u4E1A\u5546\u5E97', 'Specialist Shop': '专业商店',
'Hairdresser & Beauty': '\u7F8E\u53D1\u4E0E\u7F8E\u5BB9', 'Hairdresser & Beauty': '美发与美容',
'Gym & Fitness': '\u5065\u8EAB\u623F', 'Gym & Fitness': '健身房',
'Dry Cleaner & Laundry': '\u5E72\u6D17\u4E0E\u6D17\u8863', 'Dry Cleaner & Laundry': '干洗与洗衣',
'Car Services': '\u6C7D\u8F66\u670D\u52A1', 'Car Services': '汽车服务',
'Post Office': '\u90AE\u5C40', 'Post Office': '邮局',
'Vet & Pet Care': '\u5BA0\u7269\u533B\u9662\u4E0E\u62A4\u7406', 'Vet & Pet Care': '宠物医院与护理',
'Bank': '\u94F6\u884C', 'Bank': '银行',
'Travel Agent': '\u65C5\u884C\u793E', 'Travel Agent': '旅行社',
'Police': '\u8B66\u5BDF', 'Police': '警察',
'Fire Station': '\u6D88\u9632\u7AD9', 'Fire Station': '消防站',
'Ambulance Station': '\u6025\u6551\u7AD9', 'Ambulance Station': '急救站',
'GP Surgery': '\u5168\u79D1\u8BCA\u6240', 'GP Surgery': '全科诊所',
'Dentist': '\u7259\u79D1', 'Dentist': '牙科',
'Pharmacy': '\u836F\u623F', 'Pharmacy': '药房',
'Hospital & Clinic': '\u533B\u9662\u4E0E\u8BCA\u6240', 'Hospital & Clinic': '医院与诊所',
'Optician': '\u773C\u955C\u5E97', 'Optician': '眼镜店',
'Physiotherapy': '\u7406\u7597', 'Physiotherapy': '理疗',
'Counselling & Therapy': '\u5FC3\u7406\u54A8\u8BE2\u4E0E\u6CBB\u7597', 'Counselling & Therapy': '心理咨询与治疗',
'Care Home': '\u517B\u8001\u9662', 'Care Home': '养老院',
'Medical & Mobility': '\u533B\u7597\u5668\u68B0\u4E0E\u8F85\u52A9\u8BBE\u5907', 'Medical & Mobility': '医疗器械与辅助设备',
'Museum': '\u535A\u7269\u9986', 'Museum': '博物馆',
'Gallery': '\u7F8E\u672F\u9986', 'Gallery': '美术馆',
'Library': '\u56FE\u4E66\u9986', 'Library': '图书馆',
'Place of Worship': '\u5B97\u6559\u573A\u6240', 'Place of Worship': '宗教场所',
'Arts Centre': '\u827A\u672F\u4E2D\u5FC3', 'Arts Centre': '艺术中心',
'Zoo': '\u52A8\u7269\u56ED', 'Zoo': '动物园',
'Tourist Attraction': '\u65C5\u6E38\u666F\u70B9', 'Tourist Attraction': '旅游景点',
'School': '\u5B66\u6821', 'School': '学校',
'Hotel': '\u9152\u5E97', 'Hotel': '酒店',
'Local Business': '\u672C\u5730\u5546\u4E1A', 'Local Business': '本地商业',
'Offices': '\u5199\u5B57\u697C', 'Offices': '写字楼',
'EV Charging': '\u7535\u52A8\u8F66\u5145\u7535\u7AD9', 'EV Charging': '电动车充电站',
'Fuel Station': '\u52A0\u6CB9\u7AD9', 'Fuel Station': '加油站',
'Community Centre': '\u793E\u533A\u4E2D\u5FC3', 'Community Centre': '社区中心',
// ─ Suffixes (used in formatters) ─ // ─ Suffixes (used in formatters) ─
'/mo': '/\u6708', '/mo': '/',
'/yr': '/\u5E74', '/yr': '/',
' sqm': ' \u5E73\u65B9\u7C73', ' sqm': ' 平方米',
' km': ' \u516C\u91CC', ' km': ' 公里',
' m': ' \u7C73', ' m': ' ',
' dB': ' \u5206\u8D1D', ' dB': ' 分贝',
' years': ' \u5E74', ' years': ' ',
' rooms': ' \u95F4', ' rooms': ' ',
}, },
}; };

View file

@ -141,15 +141,6 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
), ),
// ── Transport ──────────────────────────────── // ── Transport ────────────────────────────────
'Train or tube stations within 1km': (
<>
<rect x="4" y="3" width="16" height="14" rx="2" />
<path d="M4 11h16" />
<circle cx="8" cy="15" r="1" fill="currentColor" />
<circle cx="16" cy="15" r="1" fill="currentColor" />
<path d="M8 21l-2-4h12l-2 4" />
</>
),
'Distance to nearest train or tube station (km)': ( 'Distance to nearest train or tube station (km)': (
<> <>
<path d="M12 2v8" /> <path d="M12 2v8" />

View file

@ -50,7 +50,6 @@ _AREA_COLUMNS = [
"Number of restaurants within 2km", "Number of restaurants within 2km",
"Number of grocery shops and supermarkets within 2km", "Number of grocery shops and supermarkets within 2km",
"Number of parks within 2km", "Number of parks within 2km",
"Train or tube stations within 1km",
"Distance to nearest train or tube station (km)", "Distance to nearest train or tube station (km)",
"Distance to nearest park (km)", "Distance to nearest park (km)",
# Environment # Environment
@ -325,7 +324,6 @@ def _build(
"restaurants_2km": "Number of restaurants within 2km", "restaurants_2km": "Number of restaurants within 2km",
"groceries_2km": "Number of grocery shops and supermarkets within 2km", "groceries_2km": "Number of grocery shops and supermarkets within 2km",
"parks_2km": "Number of parks within 2km", "parks_2km": "Number of parks within 2km",
"train_tube_1km": "Train or tube stations within 1km",
"train_tube_nearest_km": "Distance to nearest train or tube station (km)", "train_tube_nearest_km": "Distance to nearest train or tube station (km)",
"parks_nearest_km": "Distance to nearest park (km)", "parks_nearest_km": "Distance to nearest park (km)",
"latest_price": "Last known price", "latest_price": "Last known price",

View file

@ -15,11 +15,6 @@ POI_GROUPS_2KM = {
"groceries": ["Greengrocer", "Supermarket", "Convenience Store"], "groceries": ["Greengrocer", "Supermarket", "Convenience Store"],
} }
# Train/tube stations counted at 1km radius
TRAIN_TUBE_GROUP = {
"train_tube": ["Metro or Tram stop", "Rail station"],
}
# Groups for which to compute distance to nearest POI (from filtered POIs) # Groups for which to compute distance to nearest POI (from filtered POIs)
DISTANCE_GROUPS = { DISTANCE_GROUPS = {
"train_tube": ["Metro or Tram stop", "Rail station"], "train_tube": ["Metro or Tram stop", "Rail station"],
@ -67,11 +62,6 @@ def main():
postcodes, pois, groups=POI_GROUPS_2KM, radius_km=2 postcodes, pois, groups=POI_GROUPS_2KM, radius_km=2
) )
# Count train/tube stations within 1km
counts_1km = count_pois_per_postcode(
postcodes, pois, groups=TRAIN_TUBE_GROUP, radius_km=1
)
# Distance to nearest train/tube station (from filtered POIs) # Distance to nearest train/tube station (from filtered POIs)
distances = min_distance_per_postcode(postcodes, pois, groups=DISTANCE_GROUPS) distances = min_distance_per_postcode(postcodes, pois, groups=DISTANCE_GROUPS)
@ -86,8 +76,7 @@ def main():
# Join all results on postcode # Join all results on postcode
result = ( result = (
counts_2km.join(counts_1km, on="postcode") counts_2km.join(distances, on="postcode")
.join(distances, on="postcode")
.join(park_counts_2km, on="postcode") .join(park_counts_2km, on="postcode")
.join(park_distances, on="postcode") .join(park_distances, on="postcode")
) )

View file

@ -25,6 +25,6 @@ pub const AI_FILTERS_WEEKLY_TOKEN_LIMIT: u64 = 10_000_000;
/// Timeout for outbound HTTP service calls (seconds). /// Timeout for outbound HTTP service calls (seconds).
pub const SERVICE_CALL_TIMEOUT: u64 = 120; pub const SERVICE_CALL_TIMEOUT: u64 = 120;
/// Inner London free zone bounds (south, west, north, east) — roughly zone 1. /// Demo free zone bounds (south, west, north, east) — inner London, roughly zone 1.
/// Users without a license can only query data within these bounds. /// Users without a license can only query data within these bounds.
pub const FREE_ZONE_BOUNDS: (f64, f64, f64, f64) = (51.44, -0.31, 51.59, 0.05); pub const FREE_ZONE_BOUNDS: (f64, f64, f64, f64) = (51.44, -0.31, 51.59, 0.05);

View file

@ -402,23 +402,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
modes: &[], modes: &[],
linked: "", linked: "",
}), }),
Feature::Numeric(FeatureConfig {
name: "Train or tube stations within 1km",
bounds: Bounds::Percentile {
low: 5.0,
high: 95.0,
},
step: 1.0,
description: "Number of train or tube stations within 1km",
detail: "Rail stations and Tube/metro/tram stops within 1km of the postcode. Does not include bus stops.",
source: "naptan",
prefix: "",
suffix: "",
raw: false,
absolute: false,
modes: &[],
linked: "",
}),
], ],
}, },
FeatureGroup { FeatureGroup {

View file

@ -28,7 +28,7 @@ pub fn check_license_bounds(
let body = json!({ let body = json!({
"error": "license_required", "error": "license_required",
"message": "A license is required to view data outside inner London", "message": "A license is required to view data outside the demo area",
"free_zone": { "free_zone": {
"south": fz_south, "south": fz_south,
"west": fz_west, "west": fz_west,

View file

@ -5,5 +5,7 @@ mod h3;
pub use bounds::{bounds_intersect, h3_cell_bounds, parse_bounds, require_bounds}; pub use bounds::{bounds_intersect, h3_cell_bounds, parse_bounds, require_bounds};
pub use fields::{parse_field_indices, parse_field_set}; pub use fields::{parse_field_indices, parse_field_set};
pub use filters::{parse_filters, row_passes_filters, ParsedEnumFilter, ParsedFilter}; pub use filters::{
count_filter_impacts, parse_filters, row_passes_filters, ParsedEnumFilter, ParsedFilter,
};
pub use h3::{cell_for_row, cell_for_row_cached, needs_parent, validate_h3_resolution}; pub use h3::{cell_for_row, cell_for_row_cached, needs_parent, validate_h3_resolution};

View file

@ -121,6 +121,65 @@ pub fn row_passes_filters(
}) })
} }
/// Single-pass marginal impact counting.
///
/// Returns `(total_passing, impacts)` where `impacts[i]` is how many MORE rows
/// would pass if the i-th filter (numeric first, then enum) were removed.
///
/// For each row we record which filters reject it:
/// - 0 failures → passes (counted in `total_passing`)
/// - exactly 1 failure → that filter's marginal cost (counted in `impacts[i]`)
/// - 2+ failures → removing any single filter won't recover it (ignored)
pub fn count_filter_impacts(
filters: &[ParsedFilter],
enum_filters: &[ParsedEnumFilter],
feature_data: &[u16],
num_features: usize,
rows: impl Iterator<Item = u32>,
) -> (u32, Vec<u32>) {
let n = filters.len() + enum_filters.len();
let mut total_passing: u32 = 0;
let mut impacts = vec![0u32; n];
for row_idx in rows {
let base = row_idx as usize * num_features;
let mut fail_count: u32 = 0;
let mut fail_index: usize = 0;
for (i, f) in filters.iter().enumerate() {
let raw = feature_data[base + f.feat_idx];
if raw == NAN_U16 || raw < f.min_u16 || raw > f.max_u16 {
fail_count += 1;
fail_index = i;
if fail_count > 1 {
break;
}
}
}
if fail_count <= 1 {
for (i, f) in enum_filters.iter().enumerate() {
let raw = feature_data[base + f.feat_idx];
if raw == NAN_U16 || !f.allowed.contains(&raw) {
fail_count += 1;
fail_index = filters.len() + i;
if fail_count > 1 {
break;
}
}
}
}
match fail_count {
0 => total_passing += 1,
1 => impacts[fail_index] += 1,
_ => {}
}
}
(total_passing, impacts)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -536,4 +595,85 @@ mod tests {
assert!(!row_passes_filters(0, &[], &enum_filters, &feature_data, 1)); assert!(!row_passes_filters(0, &[], &enum_filters, &feature_data, 1));
} }
#[test]
fn filter_impacts_single_pass() {
// 2 numeric features, 4 rows:
// row 0: price=150, area=100 → passes both
// row 1: price=600, area=100 → fails price only
// row 2: price=150, area=300 → fails area only
// row 3: price=600, area=300 → fails both
let tq = test_quant(2, 2);
let feature_data = vec![
tq.encode(0, 150.0), tq.encode(1, 100.0), // row 0
tq.encode(0, 600.0), tq.encode(1, 100.0), // row 1
tq.encode(0, 150.0), tq.encode(1, 300.0), // row 2
tq.encode(0, 600.0), tq.encode(1, 300.0), // row 3
];
let filters = vec![
ParsedFilter {
feat_idx: 0,
min_u16: tq.as_ref().encode_min(0, 100.0),
max_u16: tq.as_ref().encode_max(0, 500.0),
},
ParsedFilter {
feat_idx: 1,
min_u16: tq.as_ref().encode_min(1, 50.0),
max_u16: tq.as_ref().encode_max(1, 200.0),
},
];
let (total, impacts) =
count_filter_impacts(&filters, &[], &feature_data, 2, (0..4u32).into_iter());
assert_eq!(total, 1); // only row 0 passes
assert_eq!(impacts[0], 1); // row 1 fails price only
assert_eq!(impacts[1], 1); // row 2 fails area only
// row 3 fails both → not counted
}
#[test]
fn filter_impacts_with_enum() {
// 1 numeric + 1 enum, 3 rows:
// row 0: price=150, type=0(A) → passes both
// row 1: price=150, type=2(C) → fails enum only
// row 2: price=600, type=0(A) → fails numeric only
let tq = test_quant(2, 1);
let feature_data = vec![
tq.encode(0, 150.0), 0u16, // row 0
tq.encode(0, 150.0), 2u16, // row 1
tq.encode(0, 600.0), 0u16, // row 2
];
let num_filters = vec![ParsedFilter {
feat_idx: 0,
min_u16: tq.as_ref().encode_min(0, 100.0),
max_u16: tq.as_ref().encode_max(0, 500.0),
}];
let enum_filters = vec![ParsedEnumFilter {
feat_idx: 1,
allowed: [0u16, 1].into_iter().collect(),
}];
let (total, impacts) = count_filter_impacts(
&num_filters,
&enum_filters,
&feature_data,
2,
(0..3u32).into_iter(),
);
assert_eq!(total, 1); // row 0
assert_eq!(impacts[0], 1); // row 2 fails numeric only → impacts[0]
assert_eq!(impacts[1], 1); // row 1 fails enum only → impacts[1]
}
#[test]
fn filter_impacts_no_filters() {
let tq = test_quant(1, 1);
let feature_data = vec![tq.encode(0, 100.0)];
let (total, impacts) =
count_filter_impacts(&[], &[], &feature_data, 1, (0..1u32).into_iter());
assert_eq!(total, 1);
assert!(impacts.is_empty());
}
} }

View file

@ -1,6 +1,7 @@
mod ai_filters; mod ai_filters;
mod checkout; mod checkout;
mod export; mod export;
mod filter_counts;
mod features; mod features;
mod hexagon_stats; mod hexagon_stats;
pub(crate) mod hexagons; pub(crate) mod hexagons;
@ -31,6 +32,7 @@ pub(crate) mod travel_time;
pub use ai_filters::{build_system_prompt, post_ai_filters}; pub use ai_filters::{build_system_prompt, post_ai_filters};
pub use checkout::post_checkout; pub use checkout::post_checkout;
pub use export::get_export; pub use export::get_export;
pub use filter_counts::get_filter_counts;
pub use features::{build_features_response, get_features, FeatureInfo, FeaturesResponse}; pub use features::{build_features_response, get_features, FeatureInfo, FeaturesResponse};
pub use hexagon_stats::get_hexagon_stats; pub use hexagon_stats::get_hexagon_stats;
pub use hexagons::get_hexagons; pub use hexagons::get_hexagons;
@ -43,7 +45,7 @@ pub use places::get_places;
pub use pois::{get_poi_categories, get_pois}; pub use pois::{get_poi_categories, get_pois};
pub use postcode_properties::get_postcode_properties; pub use postcode_properties::get_postcode_properties;
pub use postcode_stats::get_postcode_stats; pub use postcode_stats::get_postcode_stats;
pub use postcodes::{get_postcode_lookup, get_postcodes}; pub use postcodes::{get_nearest_postcode, get_postcode_lookup, get_postcodes};
pub use pricing::get_pricing; pub use pricing::get_pricing;
pub use properties::get_hexagon_properties; pub use properties::get_hexagon_properties;
pub use reload::post_reload; pub use reload::post_reload;

View file

@ -39,8 +39,6 @@ pub struct AiFiltersRequest {
query: String, query: String,
/// Current filters for conversational refinement (e.g. "make it cheaper") /// Current filters for conversational refinement (e.g. "make it cheaper")
context: Option<AiFiltersContext>, context: Option<AiFiltersContext>,
/// Current listing mode (historical/buy/rent). Defaults to "historical".
listing_type: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -62,8 +60,6 @@ pub struct AiFiltersResponse {
/// What the LLM couldn't map to existing filters (empty if everything matched) /// What the LLM couldn't map to existing filters (empty if everything matched)
#[serde(skip_serializing_if = "String::is_empty")] #[serde(skip_serializing_if = "String::is_empty")]
notes: String, notes: String,
/// The listing mode used for this response (historical/buy/rent)
listing_type: String,
/// Number of properties matching the proposed filters (excludes travel time) /// Number of properties matching the proposed filters (excludes travel time)
match_count: usize, match_count: usize,
} }
@ -345,34 +341,19 @@ pub fn build_system_prompt(
modes_list, modes_list,
)); ));
// Listing modes section // Feature guidance — only historical features are available
parts.push( parts.push(
"\n--- LISTING MODES ---\n\ "\n--- DATA SOURCE ---\n\
There are three listing modes that control which property data is shown:\n\ The data is historical property sales from the Land Registry.\n\
- \"historical\": Historical sales from Land Registry (default). Uses features like \
\"Last known price\", \"Estimated current price\", \"Price per sqm\".\n\
- \"buy\": Properties currently listed for sale. Uses features like \"Asking price\", \
\"Asking price per sqm\".\n\
- \"rent\": Properties currently listed for rent. Uses features like \
\"Asking rent (monthly)\".\n\
\n\ \n\
When the user mentions buying, purchasing, for-sale properties, or asking prices, \ Use these features for price queries:\n\
set listing_type to \"buy\".\n\ - For purchase price: use \"Estimated current price\" or \"Last known price\"\n\
When the user mentions renting, letting, rental properties, or monthly rent, \ - For price per sqm: use \"Est. price per sqm\"\n\
set listing_type to \"rent\".\n\ - For rent: use \"Estimated monthly rent\"\n\
When the user doesn't specify or mentions historical prices/past sales, \
omit listing_type to keep the current mode.\n\
\n\ \n\
Features marked with [mode] below are only available in that mode. \ Features marked with [historical] below are available. \
Features without a mode annotation work in all modes. \ Features marked with [buy] or [rent] are NOT available do not use them.\n\
ONLY use features valid for the chosen listing_type.\n\ ONLY use features marked [historical] or unmarked."
If the user mentions price and the mode is \"buy\", use \"Asking price\" (not \"Last known price\").\n\
If the user mentions rent/price and the mode is \"rent\", use \"Asking rent (monthly)\".\n\
\n\
Feature equivalences across modes:\n\
- \"Estimated current price\" (historical) ↔ \"Asking price\" (buy)\n\
- \"Est. price per sqm\" (historical) ↔ \"Asking price per sqm\" (buy)\n\
- \"Estimated monthly rent\" (historical) ↔ \"Asking rent (monthly)\" (rent)"
.to_string(), .to_string(),
); );
@ -412,7 +393,7 @@ pub fn build_system_prompt(
description, description,
.. ..
} => { } => {
// Skip Listing status — handled via listing_type field // Skip Listing status — auto-injected as "Historical sale"
if name == "Listing status" { if name == "Listing status" {
continue; continue;
} }
@ -499,11 +480,11 @@ pub fn build_system_prompt(
.to_string(), .to_string(),
); );
// Examples showing listing mode switching // Examples showing rent and price features
parts.push( parts.push(
"\nUser: \"2 bed flat to rent under £1500/month\"\n\ "\nUser: \"2 bed flat with rent under £1500/month\"\n\
Output: {\"listing_type\": \"rent\", \ Output: {\
\"numeric_filters\": [{\"name\": \"Asking rent (monthly)\", \"bound\": \"max\", \"value\": 1500}], \ \"numeric_filters\": [{\"name\": \"Estimated monthly rent\", \"bound\": \"max\", \"value\": 1500}], \
\"enum_filters\": [{\"name\": \"Property type\", \"values\": [\"Flats/Maisonettes\"]}], \ \"enum_filters\": [{\"name\": \"Property type\", \"values\": [\"Flats/Maisonettes\"]}], \
\"travel_time_filters\": [], \ \"travel_time_filters\": [], \
\"notes\": \"\"}" \"notes\": \"\"}"
@ -511,9 +492,9 @@ pub fn build_system_prompt(
); );
parts.push( parts.push(
"\nUser: \"3 bed house to buy under 500k with good schools\"\n\ "\nUser: \"3 bed house under 500k with good schools\"\n\
Output: {\"listing_type\": \"buy\", \ Output: {\
\"numeric_filters\": [{\"name\": \"Asking price\", \"bound\": \"max\", \"value\": 500000}, \ \"numeric_filters\": [{\"name\": \"Estimated current price\", \"bound\": \"max\", \"value\": 500000}, \
{\"name\": \"Good+ primary schools within 2km\", \"bound\": \"min\", \"value\": 2}], \ {\"name\": \"Good+ primary schools within 2km\", \"bound\": \"min\", \"value\": 2}], \
\"enum_filters\": [{\"name\": \"Property type\", \ \"enum_filters\": [{\"name\": \"Property type\", \
\"values\": [\"Detached\", \"Semi-Detached\", \"Terraced\"]}], \ \"values\": [\"Detached\", \"Semi-Detached\", \"Terraced\"]}], \
@ -525,11 +506,9 @@ pub fn build_system_prompt(
// Output format reminder // Output format reminder
parts.push( parts.push(
"\n--- OUTPUT FORMAT ---\n\ "\n--- OUTPUT FORMAT ---\n\
{\"listing_type\": \"buy\"|\"rent\" (OPTIONAL — only when switching mode), \ {\"numeric_filters\": [...], \"enum_filters\": [...], \
\"numeric_filters\": [...], \"enum_filters\": [...], \
\"travel_time_filters\": [{\"mode\": \"...\", \"slug\": \"...\", \"label\": \"...\", \ \"travel_time_filters\": [{\"mode\": \"...\", \"slug\": \"...\", \"label\": \"...\", \
\"bound\": \"min\"|\"max\", \"value\": N}, ...], \"notes\": \"...\"}\n\ \"bound\": \"min\"|\"max\", \"value\": N}, ...], \"notes\": \"...\"}\n\
- listing_type: include only when the user explicitly wants to buy or rent. Omit to keep current mode.\n\
- travel_time_filters: use ONLY slugs returned by search_destinations. If a place isn't found, mention it in notes.\n\ - travel_time_filters: use ONLY slugs returned by search_destinations. If a place isn't found, mention it in notes.\n\
Respond with ONLY the JSON object. No explanation." Respond with ONLY the JSON object. No explanation."
.to_string(), .to_string(),
@ -779,17 +758,9 @@ pub async fn post_ai_filters(
let tools = build_tool_declarations(&state); let tools = build_tool_declarations(&state);
// Resolve current listing mode from request // Build user message with optional context for conversational refinement
let current_mode = req.listing_type.as_deref().unwrap_or("historical");
let current_mode = match current_mode {
"historical" | "buy" | "rent" => current_mode,
_ => "historical",
};
// Build user message with listing mode and optional context for conversational refinement
let user_text = if let Some(ref ctx) = req.context { let user_text = if let Some(ref ctx) = req.context {
let mut msg = String::new(); let mut msg = String::new();
msg.push_str(&format!("Current listing mode: {}\n", current_mode));
msg.push_str("Currently active filters:\n"); msg.push_str("Currently active filters:\n");
msg.push_str(&serde_json::to_string(&ctx.filters).unwrap_or_default()); msg.push_str(&serde_json::to_string(&ctx.filters).unwrap_or_default());
if !ctx.travel_time.is_empty() { if !ctx.travel_time.is_empty() {
@ -807,10 +778,7 @@ pub async fn post_ai_filters(
msg.push_str(&format!("\nUser request: {}", req.query)); msg.push_str(&format!("\nUser request: {}", req.query));
msg msg
} else { } else {
format!( req.query.clone()
"Current listing mode: {}\nUser request: {}",
current_mode, req.query
)
}; };
let mut contents = vec![json!({ let mut contents = vec![json!({
@ -967,17 +935,8 @@ pub async fn post_ai_filters(
} }
}; };
// Resolve listing_type: LLM output > request > "historical" // Only historical mode is supported — validate features accordingly
let listing_type = raw let mut filters = validate_and_convert(&raw, &state.features_response, "historical");
.get("listing_type")
.and_then(|val| val.as_str())
.unwrap_or(current_mode);
let listing_type = match listing_type {
"historical" | "buy" | "rent" => listing_type,
_ => current_mode,
};
let mut filters = validate_and_convert(&raw, &state.features_response, listing_type);
let travel_time_filters = validate_travel_time_filters(&raw, &state); let travel_time_filters = validate_travel_time_filters(&raw, &state);
let notes = raw let notes = raw
.get("notes") .get("notes")
@ -985,14 +944,12 @@ pub async fn post_ai_filters(
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
// Auto-inject Listing status filter for the chosen mode // Auto-inject Listing status filter for historical mode
let listing_value = match listing_type {
"buy" => "For sale",
"rent" => "For rent",
_ => "Historical sale",
};
if let Value::Object(ref mut map) = filters { if let Value::Object(ref mut map) = filters {
map.insert("Listing status".to_string(), json!([listing_value])); map.insert(
"Listing status".to_string(),
json!(["Historical sale"]),
);
} }
// Count matching properties and refine if too restrictive // Count matching properties and refine if too restrictive
@ -1031,7 +988,6 @@ pub async fn post_ai_filters(
filters, filters,
travel_time_filters, travel_time_filters,
notes, notes,
listing_type: listing_type.to_string(),
match_count: 0, match_count: 0,
})); }));
} }
@ -1073,7 +1029,7 @@ pub async fn post_ai_filters(
let log_state = state.clone(); let log_state = state.clone();
let log_user_id = user.id.clone(); let log_user_id = user.id.clone();
let log_query = req.query.clone(); let log_query = req.query.clone();
let log_listing_type = listing_type.to_string(); let log_listing_type = "historical".to_string();
let log_notes = notes.clone(); let log_notes = notes.clone();
let log_rounds = (round + 1) as u64; let log_rounds = (round + 1) as u64;
tokio::spawn(async move { tokio::spawn(async move {
@ -1094,7 +1050,6 @@ pub async fn post_ai_filters(
filters, filters,
travel_time_filters, travel_time_filters,
notes, notes,
listing_type: listing_type.to_string(),
match_count, match_count,
})); }));
} }

View file

@ -0,0 +1,203 @@
use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::consts::NAN_U16;
use crate::data::travel_time::TravelData;
use crate::parsing::{parse_filters, require_bounds};
use crate::routes::travel_time::parse_optional_travel;
use crate::state::SharedState;
#[derive(Deserialize)]
pub struct FilterCountsParams {
bounds: Option<String>,
filters: Option<String>,
travel: Option<String>,
}
#[derive(Serialize)]
pub struct FilterCountsResponse {
total: u32,
impacts: FxHashMap<String, u32>,
}
pub async fn get_filter_counts(
State(shared): State<Arc<SharedState>>,
Query(params): Query<FilterCountsParams>,
) -> Result<Json<FilterCountsResponse>, axum::response::Response> {
let state = shared.load_state();
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
let quant = state.data.quant_ref();
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.feature_name_to_index,
&state.data.enum_values,
&quant,
)
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let travel_entries = parse_optional_travel(params.travel.as_deref())
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_regular = parsed_filters.len() + parsed_enum_filters.len();
// Only travel entries with a filter range count as filters for impact tracking
let travel_filter_indices: Vec<usize> = travel_entries
.iter()
.enumerate()
.filter(|(_, e)| e.filter_min.is_some())
.map(|(i, _)| i)
.collect();
let num_total_filters = num_regular + travel_filter_indices.len();
if num_total_filters == 0 {
return Ok(Json(FilterCountsResponse {
total: 0,
impacts: FxHashMap::default(),
}));
}
let filters_str = params.filters;
let response = tokio::task::spawn_blocking(move || -> Result<FilterCountsResponse, String> {
let t0 = std::time::Instant::now();
let num_features = state.data.num_features;
let feature_data = &state.data.feature_data;
// Load travel time data
let travel_data: Vec<TravelData> = travel_entries
.iter()
.map(|entry| {
state
.travel_time_store
.get(&entry.mode, &entry.slug)
.map_err(|err| format!("Failed to load travel data: {}", err))
})
.collect::<Result<Vec<_>, _>>()?;
let has_travel = !travel_entries.is_empty();
let (pc_interner, pc_keys) = state.data.postcode_parts();
let rows = state.grid.query(south, west, north, east);
let row_count = rows.len();
let mut total_passing: u32 = 0;
let mut impacts = vec![0u32; num_total_filters];
for row_idx in rows {
let row = row_idx as usize;
let base = row * num_features;
let mut fail_count: u32 = 0;
let mut fail_index: usize = 0;
// Test numeric filters
for (i, f) in parsed_filters.iter().enumerate() {
let raw = feature_data[base + f.feat_idx];
if raw == NAN_U16 || raw < f.min_u16 || raw > f.max_u16 {
fail_count += 1;
fail_index = i;
if fail_count > 1 {
break;
}
}
}
// Test enum filters
if fail_count <= 1 {
for (i, f) in parsed_enum_filters.iter().enumerate() {
let raw = feature_data[base + f.feat_idx];
if raw == NAN_U16 || !f.allowed.contains(&raw) {
fail_count += 1;
fail_index = parsed_filters.len() + i;
if fail_count > 1 {
break;
}
}
}
}
// Test travel time filters
if fail_count <= 1 && has_travel {
let postcode = pc_interner.resolve(&pc_keys[row]);
for (slot, &ti) in travel_filter_indices.iter().enumerate() {
let entry = &travel_entries[ti];
let minutes = travel_data[ti].get(postcode).map(|r| {
if entry.use_best {
r.best_minutes.unwrap_or(r.minutes)
} else {
r.minutes
}
});
let passes = match (minutes, entry.filter_min, entry.filter_max) {
(Some(mins), Some(fmin), Some(fmax)) => {
(mins as f32) >= fmin && (mins as f32) <= fmax
}
(None, Some(_), Some(_)) => false,
_ => true,
};
if !passes {
fail_count += 1;
fail_index = num_regular + slot;
if fail_count > 1 {
break;
}
}
}
}
match fail_count {
0 => total_passing += 1,
1 => impacts[fail_index] += 1,
_ => {}
}
}
// Map filter indices back to feature/travel names
let mut impact_map: FxHashMap<String, u32> = FxHashMap::default();
for (i, &count) in impacts.iter().enumerate() {
if count == 0 {
continue;
}
let name = if i < parsed_filters.len() {
state.data.feature_names[parsed_filters[i].feat_idx].clone()
} else if i < num_regular {
let ei = i - parsed_filters.len();
state.data.feature_names[parsed_enum_filters[ei].feat_idx].clone()
} else {
let slot = i - num_regular;
let ti = travel_filter_indices[slot];
let e = &travel_entries[ti];
format!("tt_{}_{}", e.mode, e.slug)
};
impact_map.insert(name, count);
}
let elapsed = t0.elapsed();
info!(
rows = row_count,
filters = num_total_filters,
travel = travel_filter_indices.len(),
total = total_passing,
filters_raw = filters_str.as_deref().unwrap_or("-"),
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
"GET /api/filter-counts"
);
Ok(FilterCountsResponse {
total: total_passing,
impacts: impact_map,
})
})
.await
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response())?
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err).into_response())?;
Ok(Json(response))
}

View file

@ -144,6 +144,7 @@ fn rebuild_data(shared: &SharedState, start: Instant) -> anyhow::Result<(usize,
poi_grid: Arc::clone(&old.poi_grid), poi_grid: Arc::clone(&old.poi_grid),
place_data: Arc::clone(&old.place_data), place_data: Arc::clone(&old.place_data),
postcode_data: Arc::clone(&old.postcode_data), postcode_data: Arc::clone(&old.postcode_data),
outcode_data: Arc::clone(&old.outcode_data),
poi_category_groups: Arc::clone(&old.poi_category_groups), poi_category_groups: Arc::clone(&old.poi_category_groups),
travel_time_store: Arc::clone(&old.travel_time_store), travel_time_store: Arc::clone(&old.travel_time_store),
token_cache: Arc::clone(&old.token_cache), token_cache: Arc::clone(&old.token_cache),