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

View file

@ -12,3 +12,4 @@ analyses/
property-data
manual-data
!property-data/arcgis_data.parquet
Dockerfile

View file

@ -32,7 +32,7 @@ PRICE_INDEX := $(DATA_DIR)/price_index.parquet
PRICES_STAMP := $(DATA_DIR)/.prices_done
EPC := $(MANUAL_DATA)/domestic-csv.zip
ETHNICITY := $(DATA_DIR)/ethnicity_by_la.parquet
CRIME_DIR := $(MANUAL_DATA)/crime
CRIME_DIR := $(DATA_DIR)/crime
CRIME := $(DATA_DIR)/crime_by_lsoa.parquet
CRIME_STAMP := $(CRIME_DIR)/.downloaded
NOISE := $(DATA_DIR)/road_noise.parquet
@ -44,12 +44,13 @@ RENTAL := $(DATA_DIR)/rental_prices.parquet
INSPIRE_DIR := $(DATA_DIR)/inspire
OA_BOUNDARIES := $(DATA_DIR)/oa_boundaries.gpkg
UPRN_LOOKUP := $(DATA_DIR)/uprn_lookup.parquet
PC_BOUNDARIES := $(MANUAL_DATA)/postcode_boundaries
PC_BOUNDARIES := $(DATA_DIR)/postcode_boundaries
TRANSIT_DIR := $(DATA_DIR)/transit
TRANSIT_STAMP := $(TRANSIT_DIR)/.done
GREENSPACE := $(DATA_DIR)/greenspace_water.parquet
OS_GREENSPACE := $(DATA_DIR)/os_greenspace.parquet
PBF := $(DATA_DIR)/england-latest.osm.pbf
FR_TOW := $(DATA_DIR)/FR_TOW_V1_ALL.zip
PLACES := $(DATA_DIR)/places.parquet
LSOA_POP := $(DATA_DIR)/lsoa_population.parquet
MEDIAN_AGE := $(DATA_DIR)/median_age.parquet
@ -70,13 +71,13 @@ PMTILES_VERSION := 1.22.3
download-arcgis download-price-paid download-deprivation download-ethnicity \
download-naptan download-pois download-grocery-retail-points download-ofsted download-broadband download-rental-prices \
download-postcodes download-noise download-inspire download-crime \
download-oa-boundaries download-uprn-lookup download-transit-network download-greenspace download-os-greenspace download-pbf download-places download-lsoa-population download-median-age download-england-boundary download-rightmove-outcodes \
download-oa-boundaries download-uprn-lookup download-transit-network download-greenspace download-os-greenspace download-pbf download-fr-tow download-places download-lsoa-population download-median-age download-england-boundary download-rightmove-outcodes \
download-map-assets \
transform-pois transform-epc-pp transform-crime transform-poi-proximity \
transform-school-proximity transform-postcode-boundaries \
generate-postcode-boundaries
transform-school-proximity \
generate-postcode-boundaries generate-travel-times
prepare: $(PRICES_STAMP)
prepare: $(PRICES_STAMP) download-places tiles generate-postcode-boundaries download-map-assets
merge: $(MERGE_STAMP)
tiles: $(TILES)
download-arcgis: $(ARCGIS)
@ -99,6 +100,7 @@ download-transit-network: $(TRANSIT_STAMP)
download-greenspace: $(GREENSPACE)
download-os-greenspace: $(OS_GREENSPACE)
download-pbf: $(PBF)
download-fr-tow: $(FR_TOW)
download-places: $(PLACES)
download-lsoa-population: $(LSOA_POP)
download-median-age: $(MEDIAN_AGE)
@ -111,13 +113,14 @@ transform-epc-pp: $(EPC_PP)
transform-crime: $(CRIME)
transform-poi-proximity: $(POI_PROXIMITY)
transform-school-proximity: $(SCHOOL_PROX)
transform-postcode-boundaries: $(PC_BOUNDARIES)
generate-postcode-boundaries: $(OA_BOUNDARIES) $(INSPIRE_STAMP) $(UPRN_LOOKUP)
uv run python -m pipeline.transform.postcode_boundaries \
--uprn $(UPRN_LOOKUP) \
--oa-boundaries $(OA_BOUNDARIES) \
--inspire $(INSPIRE_DIR) \
--output $(PC_BOUNDARIES)
generate-travel-times: $(ARCGIS) $(PLACES) $(PBF) $(TRANSIT_STAMP)
./r5-java/run.sh
# ── Downloads ─────────────────────────────────────────────────────────────────
@ -159,6 +162,11 @@ $(PBF):
curl -L -o $@.tmp https://download.geofabrik.de/europe/united-kingdom/england-latest.osm.pbf
mv $@.tmp $@
$(FR_TOW):
@mkdir -p $(DATA_DIR)
curl -L -A "Mozilla/5.0" -o $@.tmp "https://www.mediafire.com/file_premium/p5fve6wswwwjqrq/FR_TOW_V1_ALL.zip/file"
mv $@.tmp $@
$(POIS_RAW): $(PBF) $(ENGLAND_BOUNDARY)
uv run python -m pipeline.download.pois --output $@ --pbf $(PBF) --boundary $(ENGLAND_BOUNDARY)

View file

@ -93,8 +93,7 @@ The running server expects the same structure under
Travel times are built separately because they are expensive:
```bash
make -f Makefile.data download-transit-network
./r5-java/run.sh --threads 8 --heap 40g
make -f Makefile.data generate-travel-times
```
For a quick R5 smoke test:
@ -200,9 +199,3 @@ docker build -t property-map .
The container entrypoint runs `property-map-server` with the expected data paths
under `/app/data` and serves `frontend/dist` when `--dist` is present.
make -f Makefile.data tiles prepare generate-postcode-boundaries download-places
make -f Makefile.data download-map-assets
./r5-java/run.sh --paths

View file

@ -6,6 +6,8 @@ services:
server:
image: rust:1.84
init: true
tty: true
stdin_open: true
working_dir: /app/server-rs
command: >
bash -c "

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

After

Width:  |  Height:  |  Size: 377 KiB

Before After
Before After

Binary file not shown.

View file

@ -9,19 +9,23 @@
// with the same multiset, in the translated string.
// 4. descriptions.ts and details.ts: the union of feature-name keys across
// languages is treated as canonical; every language must cover all of them.
// 5. The lazy locale loader map covers every non-English supported language.
// 6. Selected visible UI strings that previously slipped through are not
// hardcoded outside the i18n files.
//
// The script parses the TypeScript source with the compiler API and walks the
// AST — no runtime import, no transpilation, no temp files. Run it with:
// node frontend/scripts/check-translations.mjs
import { readFileSync, readdirSync } from 'fs';
import { dirname, join } from 'path';
import { dirname, join, relative } from 'path';
import { fileURLToPath } from 'url';
import ts from 'typescript';
const __dirname = dirname(fileURLToPath(import.meta.url));
const I18N_DIR = join(__dirname, '..', 'src', 'i18n');
const LOCALES_DIR = join(I18N_DIR, 'locales');
const SRC_DIR = join(__dirname, '..', 'src');
const PLACEHOLDER_RE = /\{\{\s*[a-zA-Z_][\w]*\s*\}\}/g;
const HTML_TAG_RE = /<\/?[a-zA-Z][\w]*\b[^>]*>/g;
@ -31,6 +35,71 @@ const warnings = [];
const fail = (msg) => errors.push(msg);
const warn = (msg) => warnings.push(msg);
const SAME_AS_EN_PATH_ALLOWLIST = new Set([
'streetView.title',
'home.showcaseMinutes',
'home.showcaseStep2Sources',
'format.lessThanMin',
'format.moreThanMin',
'learnPage.attrOglLink',
'learnPage.attrOsmContrib',
'learnPage.attrOsmLicenseLink',
'home.showcaseTopThree',
'learnPage.dsTravelOrigin',
'learnPage.dsParkOrigin',
'learnPage.dsCrimeOrigin',
'learnPage.dsDemographicsOrigin',
'learnPage.dsElectionOrigin',
'learnPage.dsPoiOrigin',
'learnPage.dsEthnicityOrigin',
'learnPage.dsBroadbandOrigin',
]);
const SAME_AS_EN_PATH_ALLOWLIST_RE = [/^learnPage\.ds[A-Za-z0-9]+Origin$/];
const SAME_AS_EN_VALUE_ALLOWLIST = new Set([
'Perfect Postcode',
'Land Registry',
'HM Land Registry',
'ONS',
'OpenStreetMap',
'Ofsted',
'Rightmove',
'Zoopla',
'Google',
'Excel',
'UK',
'Reform UK',
'Labour',
'Conservative',
'Liberal Democrat',
]);
const FORBIDDEN_VISIBLE_STRINGS = [
['without this filter', 'filters.withoutThisFilter'],
['Connecting to server...', 'common.connectingToServer'],
['Property saved!', 'toasts.propertySaved'],
['View saved', 'toasts.viewSaved'],
["Don't show again", 'toasts.dontShowAgain'],
['Close pane', 'common.closePane'],
['Points of interest', 'poiPane.pointsOfInterest'],
['No data', 'common.noData'],
['All low', 'common.allLow'],
['School type', 'filters.schoolType'],
['School rating', 'filters.schoolRating'],
['School distance', 'filters.schoolDistance'],
['Crime type', 'filters.crimeType'],
['POI type', 'filters.poiType'],
['Matching homes', 'home.showcaseMatchingHomesLabel'],
['Journey routes', 'home.showcaseJourneyRoutes'],
['...and lots more', 'home.showcaseLotsMore'],
['Send the shortlist', 'home.showcaseSendShortlist'],
['Download .xlsx', 'home.showcaseDownloadXlsx'],
['Product demo', 'home.productDemoLabel'],
['Play product demo', 'home.playProductDemo'],
['Scroll to product demo', 'home.scrollToProductDemo'],
];
function parseFile(path) {
const src = readFileSync(path, 'utf8');
return ts.createSourceFile(path, src, ts.ScriptTarget.Latest, true);
@ -44,6 +113,9 @@ function literalToJs(node) {
if (ts.isAsExpression(node) || ts.isParenthesizedExpression(node)) {
return literalToJs(node.expression);
}
if (ts.isSatisfiesExpression?.(node)) {
return literalToJs(node.expression);
}
if (ts.isObjectLiteralExpression(node)) {
const out = {};
for (const prop of node.properties) {
@ -79,6 +151,31 @@ function findVarInitializer(sourceFile, name) {
return result;
}
function propertyNameText(name) {
if (ts.isIdentifier(name)) return name.text;
if (ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) return name.text;
return undefined;
}
function objectLiteralKeys(node) {
if (!node) return undefined;
if (ts.isAsExpression(node) || ts.isParenthesizedExpression(node)) {
return objectLiteralKeys(node.expression);
}
if (ts.isSatisfiesExpression?.(node)) {
return objectLiteralKeys(node.expression);
}
if (!ts.isObjectLiteralExpression(node)) return undefined;
const keys = [];
for (const prop of node.properties) {
if (ts.isPropertyAssignment(prop)) {
const key = propertyNameText(prop.name);
if (key) keys.push(key);
}
}
return keys;
}
function readSupportedLanguages() {
const sf = parseFile(join(I18N_DIR, 'index.ts'));
const init = findVarInitializer(sf, 'SUPPORTED_LANGUAGES');
@ -88,6 +185,15 @@ function readSupportedLanguages() {
return arr.map((entry) => entry.code);
}
function readLocaleLoaderCodes() {
const sf = parseFile(join(I18N_DIR, 'index.ts'));
const init = findVarInitializer(sf, 'localeLoaders');
if (!init) throw new Error('Could not find localeLoaders in index.ts');
const keys = objectLiteralKeys(init);
if (!keys) throw new Error('localeLoaders is not an object literal');
return keys;
}
function readLocale(code) {
const path = join(LOCALES_DIR, `${code}.ts`);
const sf = parseFile(path);
@ -140,6 +246,9 @@ function checkLeafConsistency(path, enValue, trValue, lang) {
fail(`[${lang}] ${path}: empty translation`);
return;
}
if (isSuspiciousSameAsEnglish(path, enValue, trValue)) {
warn(`[${lang}] ${path}: same as English; verify this is intentional`);
}
for (const [re, label] of [
[PLACEHOLDER_RE, 'placeholder'],
[HTML_TAG_RE, 'HTML tag'],
@ -155,6 +264,47 @@ function checkLeafConsistency(path, enValue, trValue, lang) {
}
}
function isSuspiciousSameAsEnglish(path, enValue, trValue) {
if (typeof enValue !== 'string' || typeof trValue !== 'string') return false;
if (enValue.trim() !== trValue.trim()) return false;
if (SAME_AS_EN_PATH_ALLOWLIST.has(path)) return false;
if (SAME_AS_EN_PATH_ALLOWLIST_RE.some((re) => re.test(path))) return false;
if (SAME_AS_EN_VALUE_ALLOWLIST.has(enValue.trim())) return false;
if (path.startsWith('server.')) return false;
const text = enValue.trim();
if (text.length < 8) return false;
if (!/[A-Za-z]/.test(text) || !/[a-z]/.test(text)) return false;
if (!/\s/.test(text)) return false;
if (/^https?:\/\//i.test(text)) return false;
if (/^[A-Z0-9 .&/()%+-]+$/.test(text)) return false;
return true;
}
function checkLocaleLoaders(supportedCodes) {
let loaderCodes;
try {
loaderCodes = readLocaleLoaderCodes();
} catch (e) {
fail(e.message);
return;
}
const expected = supportedCodes.filter((code) => code !== 'en');
for (const code of expected) {
if (!loaderCodes.includes(code)) {
fail(`localeLoaders is missing non-English supported language "${code}"`);
}
}
for (const code of loaderCodes) {
if (code === 'en') {
fail('localeLoaders should not include "en"; English is imported eagerly');
} else if (!expected.includes(code)) {
fail(`localeLoaders includes "${code}" but it is not in SUPPORTED_LANGUAGES`);
}
}
}
function checkLocales(supportedCodes) {
const localeFiles = readdirSync(LOCALES_DIR)
.filter((f) => f.endsWith('.ts'))
@ -230,6 +380,57 @@ function checkRecordCoverage(file, varName, supportedCodes, serverKeys) {
}
}
function collectSourceFiles(dir, out = []) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
const rel = relative(SRC_DIR, path).replace(/\\/g, '/');
if (entry.isDirectory()) {
if (rel === 'i18n' || rel.includes('/__tests__') || entry.name === '__tests__') continue;
collectSourceFiles(path, out);
continue;
}
if (!entry.isFile()) continue;
if (!/\.(ts|tsx)$/.test(entry.name)) continue;
if (/\.d\.ts$/.test(entry.name) || /\.(test|spec)\.(ts|tsx)$/.test(entry.name)) continue;
out.push(path);
}
return out;
}
function lineNumberAt(src, index) {
return src.slice(0, index).split(/\r\n|\r|\n/).length;
}
function checkForbiddenVisibleStrings() {
for (const file of collectSourceFiles(SRC_DIR)) {
const rel = relative(join(__dirname, '..'), file).replace(/\\/g, '/');
const src = readFileSync(file, 'utf8');
const sf = ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
function checkText(textNode, value) {
for (const [text, key] of FORBIDDEN_VISIBLE_STRINGS) {
if (!value.includes(text)) continue;
fail(
`${rel}:${lineNumberAt(src, textNode.getStart(sf))}: hardcoded visible string ` +
`"${text}" should use "${key}"`
);
}
}
function visit(node) {
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
checkText(node, node.text);
} else if (ts.isJsxText(node)) {
checkText(node, node.getText(sf));
} else if (ts.isTemplateHead(node) || ts.isTemplateMiddle(node) || ts.isTemplateTail(node)) {
checkText(node, node.text);
}
ts.forEachChild(node, visit);
}
visit(sf);
}
}
function main() {
let supportedCodes;
try {
@ -240,10 +441,12 @@ function main() {
}
checkLocales(supportedCodes);
checkLocaleLoaders(supportedCodes);
const en = readLocale('en');
const serverKeys = new Set(Object.keys(en.server ?? {}));
checkRecordCoverage('descriptions.ts', 'descriptions', supportedCodes, serverKeys);
checkRecordCoverage('details.ts', 'details', supportedCodes, serverKeys);
checkForbiddenVisibleStrings();
for (const w of warnings) console.warn(`warn: ${w}`);
if (errors.length > 0) {
@ -251,9 +454,7 @@ function main() {
console.error(`\n${errors.length} translation error(s).`);
process.exit(1);
}
console.log(
`i18n OK — ${supportedCodes.length} languages, ${warnings.length} warning(s).`
);
console.log(`i18n OK — ${supportedCodes.length} languages, ${warnings.length} warning(s).`);
}
main();

View file

@ -333,7 +333,7 @@ function SavedSearchesTab({
<button
onClick={() => setDeleteConfirmId(search.id)}
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Delete"
title={t('common.delete')}
>
<TrashIcon className="w-4 h-4" />
</button>
@ -441,7 +441,7 @@ function SavedPropertiesTab({
<button
onClick={() => setDeleteConfirmId(prop.id)}
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Delete"
title={t('common.delete')}
>
<TrashIcon className="w-4 h-4" />
</button>

View file

@ -17,7 +17,7 @@ const HOME_BODY_CLASS = 'text-base leading-relaxed text-warm-600 dark:text-warm-
const HOME_PRIMARY_BUTTON_CLASS =
'bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25 text-center';
const PRODUCT_DEMO_VIDEO_SRC = '/video/recording.mp4';
const PRODUCT_DEMO_POSTER_SRC = '/video/poster.jpg';
const PRODUCT_DEMO_POSTER_SRC = '/video/recording.jpg';
const PRODUCT_DEMO_SECTION_ID = 'product-demo-video';
function highlightBrandText(text: string) {
@ -37,6 +37,7 @@ function highlightBrandText(text: string) {
}
function ProductDemoVideo() {
const { t } = useTranslation();
const sectionRef = useRef<HTMLDivElement | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const [shouldLoadVideo, setShouldLoadVideo] = useState(false);
@ -94,7 +95,7 @@ function ProductDemoVideo() {
playsInline
preload={shouldLoadVideo ? 'metadata' : 'none'}
className="block aspect-video w-full bg-navy-950 object-contain"
aria-label="Perfect Postcode product demo"
aria-label={t('home.productDemoLabel')}
onPlay={() => setIsVideoPlaying(true)}
onPause={() => setIsVideoPlaying(false)}
onEnded={() => setIsVideoPlaying(false)}
@ -105,7 +106,7 @@ function ProductDemoVideo() {
type="button"
onClick={playVideo}
className="pointer-events-auto group flex h-20 w-20 items-center justify-center rounded-full bg-white/95 text-coral-500 shadow-2xl shadow-navy-950/40 ring-1 ring-white/60 transition-transform hover:scale-105 focus:outline-none focus-visible:scale-105 focus-visible:ring-4 focus-visible:ring-teal-300/75 md:h-24 md:w-24"
aria-label="Play Perfect Postcode product demo"
aria-label={t('home.playProductDemo')}
>
<PlayIcon className="h-11 w-11 -translate-x-0.5 md:h-14 md:w-14" />
</button>
@ -327,7 +328,7 @@ export default function HomePage({
<button
type="button"
className="hero-scroll-chevron absolute bottom-4 left-1/2 z-20 -translate-x-1/2 items-center justify-center rounded-full text-white transition-colors hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-white/50"
aria-label="Scroll to product demo"
aria-label={t('home.scrollToProductDemo')}
onClick={() => {
trackEvent('CTA Click', { location: 'hero_chevron', label: 'scroll_down' });
scrollToProductDemoVideo();

View file

@ -6,6 +6,7 @@ import {
type ComponentType,
type MutableRefObject,
} from 'react';
import type { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next';
import { cellToLatLng, polygonToCells } from 'h3-js';
import ProductMap from '../map/Map';
@ -373,8 +374,9 @@ function FilterPreviewRow({
<span
className={`w-fit shrink-0 rounded-md px-2.5 py-1 text-xs font-bold leading-none ${style.chip}`}
>
+<span className="font-mono tabular-nums">{withoutCount.toLocaleString()}</span>
{' without this filter'}
{t('filters.withoutThisFilter', {
value: withoutCount.toLocaleString(),
})}
</span>
</div>
<div className="mt-3 px-2 pl-4 sm:mt-5">
@ -395,7 +397,7 @@ function formatCompactCurrency(value: number): string {
return `£${Math.round(value / 1000)}k`;
}
function formatDemoRange(feature: FeatureMeta, value: [number, number]): string {
function formatDemoRange(feature: FeatureMeta, value: [number, number], t: TFunction): string {
if (feature.name === 'Estimated price') {
return `${formatCompactCurrency(value[0])} - ${formatCompactCurrency(value[1])}`;
}
@ -403,10 +405,10 @@ function formatDemoRange(feature: FeatureMeta, value: [number, number]): string
return `${Math.round(value[0])} - ${Math.round(value[1])} dB`;
}
if (feature.name === 'Good+ primary schools within 2km') {
return `${Math.round(value[0])}+ good primaries nearby`;
return t('home.showcaseGoodPrimariesNearby', { count: Math.round(value[0]) });
}
if (feature.name === 'Travel time to nearest train or tube station (min)') {
return `Within ${Math.round(value[1])} min of rail`;
return t('home.showcaseWithinRail', { count: Math.round(value[1]) });
}
return `${value[0]} - ${value[1]}`;
}
@ -439,6 +441,7 @@ function interpolateRangePath(ranges: [number, number][], progress: number): [nu
}
function FilterOnlyScreen({ isActive }: { isActive: boolean }) {
const { t } = useTranslation();
const [hasUserAdjusted, setHasUserAdjusted] = useState(false);
const rows = useMemo(
@ -540,7 +543,7 @@ function FilterOnlyScreen({ isActive }: { isActive: boolean }) {
<FilterPreviewRow
feature={row.feature}
value={filterState[index].value}
rangeLabel={formatDemoRange(row.feature, filterState[index].value)}
rangeLabel={formatDemoRange(row.feature, filterState[index].value, t)}
withoutCount={filterState[index].without}
index={index}
isTightened
@ -554,6 +557,7 @@ function FilterOnlyScreen({ isActive }: { isActive: boolean }) {
}
function EnglandHexMapScreen({ isActive }: { isActive: boolean }) {
const { t } = useTranslation();
const [viewState, setViewState] = useState<ViewState>(SHOWCASE_MAP_START_VIEW);
const elapsedRef = useRef(0);
const lastFrameRef = useRef<number | null>(null);
@ -608,13 +612,15 @@ function EnglandHexMapScreen({ isActive }: { isActive: boolean }) {
theme="dark"
screenshotMode
hideLegend
densityLabel="Matching homes"
densityLabel={t('home.showcaseMatchingHomesLabel')}
totalCount={SHOWCASE_MAP_TOTAL_COUNT}
/>
<div className="pointer-events-none absolute bottom-4 left-4 max-w-[16rem] rounded-md border border-white/15 bg-navy-950/95 px-4 py-3 text-white shadow-2xl shadow-navy-950/35">
<div className="text-sm font-black leading-none">Birmingham</div>
<div className="mt-1 text-xs font-bold leading-tight text-warm-200">
{SHOWCASE_MAP_TOTAL_COUNT.toLocaleString()} matching homes
{t('home.showcaseMatchingHomes', {
value: SHOWCASE_MAP_TOTAL_COUNT.toLocaleString(),
})}
</div>
</div>
</div>
@ -687,7 +693,7 @@ function RightPaneOnlyScreen({
<div className="mb-1 flex items-center justify-between gap-3">
<FeatureLabel feature={DEMO_FEATURES[0]} size="sm" />
<span className="shrink-0 text-xs font-black text-teal-700 dark:text-teal-300">
£492k median
{t('home.showcaseMedianPrice', { value: '£492k' })}
</span>
</div>
<PriceHistoryChart points={PRICE_POINTS} />
@ -695,7 +701,7 @@ function RightPaneOnlyScreen({
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950/50 sm:p-4">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-warm-700 dark:text-warm-300">
<RouteIcon className="h-4 w-4 text-teal-600 dark:text-teal-400" />
<span>Journey routes</span>
<span>{t('home.showcaseJourneyRoutes')}</span>
</div>
<JourneyInstructions
postcode="SW5 9AA"
@ -710,7 +716,9 @@ function RightPaneOnlyScreen({
<div className="mb-2 flex items-center justify-between gap-3">
<FeatureLabel feature={DEMO_FEATURES[2]} size="sm" />
<span className="shrink-0 text-xs font-black text-teal-700 dark:text-teal-300">
{formatValue(SCHOOL_NEARBY_COUNT, DEMO_FEATURES[2])} nearby
{t('home.showcaseNearby', {
value: formatValue(SCHOOL_NEARBY_COUNT, DEMO_FEATURES[2]),
})}
</span>
</div>
<DualHistogram
@ -737,7 +745,7 @@ function RightPaneOnlyScreen({
<div className="mb-2 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold text-warm-700 dark:text-warm-300">
<ChartBarIcon className="h-4 w-4 shrink-0 text-teal-600 dark:text-teal-400" />
<span className="truncate">Political vote share</span>
<span className="truncate">{t('home.showcasePoliticalVoteShare')}</span>
</div>
<span className="shrink-0 text-xs font-black text-teal-700 dark:text-teal-300">
2024 GE
@ -750,7 +758,7 @@ function RightPaneOnlyScreen({
/>
</div>
<div className="rounded-lg border border-dashed border-warm-200 bg-white/80 px-4 py-3 text-center text-sm font-black text-warm-500 dark:border-navy-700 dark:bg-navy-950/70 dark:text-warm-400">
...and lots more
{t('home.showcaseLotsMore')}
</div>
</div>
</div>
@ -762,9 +770,24 @@ function ScoutScreen({ isActive }: { isActive: boolean }) {
const { t } = useTranslation();
const [isTableRevealed, setIsTableRevealed] = useState(false);
const scoutRows = [
{ postcode: 'SW5 9AA', score: '94%', commute: '23 min', price: '£492k' },
{ postcode: 'SE22 8EF', score: '91%', commute: '28 min', price: '£518k' },
{ postcode: 'N4 2AB', score: '88%', commute: '31 min', price: '£476k' },
{
postcode: 'SW5 9AA',
score: '94%',
commute: t('home.showcaseMinutes', { count: 23 }),
price: '£492k',
},
{
postcode: 'SE22 8EF',
score: '91%',
commute: t('home.showcaseMinutes', { count: 28 }),
price: '£518k',
},
{
postcode: 'N4 2AB',
score: '88%',
commute: t('home.showcaseMinutes', { count: 31 }),
price: '£476k',
},
];
useEffect(() => {
@ -802,10 +825,10 @@ function ScoutScreen({ isActive }: { isActive: boolean }) {
</span>
<div className="min-w-0">
<div className="text-sm font-black text-navy-950 dark:text-warm-100 sm:text-base">
Share
{t('common.share')}
</div>
<div className="mt-0.5 hidden truncate text-xs font-semibold text-warm-500 dark:text-warm-400 sm:block">
Send the shortlist
{t('home.showcaseSendShortlist')}
</div>
</div>
</div>
@ -817,9 +840,9 @@ function ScoutScreen({ isActive }: { isActive: boolean }) {
<DownloadIcon className="h-4 w-4 sm:h-5 sm:w-5" />
</span>
<div className="min-w-0">
<div className="text-sm font-black sm:text-base">Export</div>
<div className="text-sm font-black sm:text-base">{t('header.exportLabel')}</div>
<div className="mt-0.5 hidden truncate text-xs font-semibold text-teal-50 dark:text-navy-800 sm:block">
Download .xlsx
{t('home.showcaseDownloadXlsx')}
</div>
</div>
<span className="scout-export-check ml-auto hidden h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-teal-700 shadow-sm dark:bg-navy-950 dark:text-teal-300 sm:flex">
@ -847,7 +870,7 @@ function ScoutScreen({ isActive }: { isActive: boolean }) {
</span>
</div>
<span className="shrink-0 rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[10px] font-black text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-200 sm:text-xs">
Top 3
{t('home.showcaseTopThree')}
</span>
</div>
<div className="grid grid-cols-[1.25fr_0.85fr_0.9fr_1fr] border-b border-warm-200 bg-warm-50 text-[10px] font-black uppercase text-warm-500 dark:border-navy-700 dark:bg-navy-950/45 dark:text-warm-400 sm:text-xs">
@ -908,9 +931,9 @@ function ScoutScreen({ isActive }: { isActive: boolean }) {
</div>
<div className="mt-3 grid gap-1.5 text-xs font-medium leading-relaxed text-warm-300 sm:gap-2">
{[
'Walk the streets before the listing search narrows your options.',
'Test the commute from a real front door, not a borough name.',
'Compare viewings with evidence already in hand.',
t('home.showcaseScoutBullet1'),
t('home.showcaseScoutBullet2'),
t('home.showcaseScoutBullet3'),
].map((item) => (
<div key={item} className="flex gap-2">
<CheckIcon className="mt-0.5 h-4 w-4 shrink-0 text-teal-300" />

View file

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { CheckIcon } from '../ui/icons/CheckIcon';
import {
SEO_CONTENT_PAGES,
@ -90,6 +91,7 @@ export default function SeoContentPage({
pageKey: SeoContentKey;
onOpenDashboard: () => void;
}) {
const { t } = useTranslation();
const page = SEO_CONTENT_PAGES[pageKey];
const url = `${PUBLIC_URL}${page.path}`;
@ -119,9 +121,9 @@ export default function SeoContentPage({
<section className="bg-navy-950 text-white">
<div className="mx-auto max-w-5xl px-6 py-16 md:px-10 md:py-20">
<nav className="mb-6 text-sm font-medium text-warm-400" aria-label="Breadcrumb">
<nav className="mb-6 text-sm font-medium text-warm-400" aria-label={t('seo.breadcrumb')}>
<a href="/" className="hover:text-teal-200">
Home
{t('mobileMenu.home')}
</a>
<span className="mx-2">/</span>
<span className="text-warm-200">{page.eyebrow}</span>
@ -149,7 +151,7 @@ export default function SeoContentPage({
{page.faq.length > 0 && (
<section className="bg-white py-14 dark:bg-navy-900">
<div className="mx-auto max-w-5xl px-6 md:px-10">
<h2 className="text-2xl font-bold md:text-3xl">Frequently asked questions</h2>
<h2 className="text-2xl font-bold md:text-3xl">{t('seo.frequentlyAskedQuestions')}</h2>
<div className="mt-7 grid gap-4">
{page.faq.map((item) => (
<details
@ -168,10 +170,9 @@ export default function SeoContentPage({
)}
<section className="mx-auto max-w-5xl px-6 py-14 md:px-10">
<h2 className="text-2xl font-bold md:text-3xl">Related pages</h2>
<h2 className="text-2xl font-bold md:text-3xl">{t('seo.relatedPages')}</h2>
<p className="mt-3 max-w-3xl leading-relaxed text-warm-600 dark:text-warm-300">
Follow these internal links to compare the same property-search workflow from another
angle.
{t('seo.relatedPagesDesc')}
</p>
<div className="mt-7">
<RelatedLinks links={page.relatedLinks} />

View file

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { CheckIcon } from '../ui/icons/CheckIcon';
import { LogoIcon } from '../ui/icons/LogoIcon';
import {
@ -90,6 +91,7 @@ export default function SeoLandingPage({
pageKey: SeoLandingKey;
onOpenDashboard: () => void;
}) {
const { t } = useTranslation();
const page = SEO_LANDING_PAGES[pageKey];
const url = `${PUBLIC_URL}${page.path}`;
@ -119,9 +121,9 @@ export default function SeoLandingPage({
<section className="bg-navy-950 text-white">
<div className="mx-auto max-w-6xl px-6 py-16 md:px-10 md:py-20">
<nav className="mb-6 text-sm font-medium text-warm-400" aria-label="Breadcrumb">
<nav className="mb-6 text-sm font-medium text-warm-400" aria-label={t('seo.breadcrumb')}>
<a href="/" className="hover:text-teal-200">
Home
{t('mobileMenu.home')}
</a>
<span className="mx-2">/</span>
<span className="text-warm-200">{page.eyebrow}</span>
@ -144,7 +146,7 @@ export default function SeoLandingPage({
href="/data-sources"
className="rounded-lg border-2 border-teal-400 px-6 py-[10px] text-center font-semibold text-teal-300 transition-colors hover:bg-teal-400/10"
>
Review the data sources
{t('seo.reviewDataSources')}
</a>
</div>
</div>
@ -156,10 +158,9 @@ export default function SeoLandingPage({
<LogoIcon className="h-4 w-4" />
Perfect Postcode
</div>
<h2 className="mt-5 text-2xl font-bold md:text-3xl">What you can compare</h2>
<h2 className="mt-5 text-2xl font-bold md:text-3xl">{t('seo.whatYouCanCompare')}</h2>
<p className="mt-3 leading-relaxed text-warm-600 dark:text-warm-300">
Each page is built around real shortlisting work: removing impossible places, comparing
the remaining postcodes, and deciding what to validate next.
{t('seo.whatYouCanCompareDesc')}
</p>
</div>
<div className="grid gap-3">
@ -177,10 +178,9 @@ export default function SeoLandingPage({
<section className="mx-auto max-w-6xl px-6 pb-16 md:px-10">
<div className="mb-7 max-w-3xl">
<h2 className="text-2xl font-bold md:text-3xl">How to use it</h2>
<h2 className="text-2xl font-bold md:text-3xl">{t('seo.howToUseIt')}</h2>
<p className="mt-3 leading-relaxed text-warm-600 dark:text-warm-300">
Use these workflows to make the page useful before you open a listing portal or book a
viewing.
{t('seo.howToUseItDesc')}
</p>
</div>
<SectionGrid sections={page.workflows} />
@ -193,10 +193,9 @@ export default function SeoLandingPage({
<section className="bg-white py-14 dark:bg-navy-900">
<div className="mx-auto max-w-6xl px-6 md:px-10">
<div className="mb-7 max-w-3xl">
<h2 className="text-2xl font-bold md:text-3xl">Method and limitations</h2>
<h2 className="text-2xl font-bold md:text-3xl">{t('seo.methodAndLimitations')}</h2>
<p className="mt-3 leading-relaxed text-warm-600 dark:text-warm-300">
The data is designed for comparison and shortlisting. Important decisions still need
current listings, professional checks, and direct local validation.
{t('seo.methodAndLimitationsDesc')}
</p>
</div>
<SectionGrid sections={page.methodology} />
@ -205,7 +204,7 @@ export default function SeoLandingPage({
<section className="mx-auto max-w-6xl px-6 py-14 md:px-10">
<div className="mb-7 max-w-3xl">
<h2 className="text-2xl font-bold md:text-3xl">Questions buyers ask</h2>
<h2 className="text-2xl font-bold md:text-3xl">{t('seo.questionsBuyersAsk')}</h2>
</div>
<div className="grid gap-4">
{page.faq.map((item) => (
@ -222,9 +221,9 @@ export default function SeoLandingPage({
<section className="mx-auto max-w-6xl px-6 pb-16 md:px-10">
<div className="mb-7 max-w-3xl">
<h2 className="text-2xl font-bold md:text-3xl">Related guides</h2>
<h2 className="text-2xl font-bold md:text-3xl">{t('seo.relatedGuides')}</h2>
<p className="mt-3 leading-relaxed text-warm-600 dark:text-warm-300">
Continue through the indexed public pages using canonical internal links.
{t('seo.relatedGuidesDesc')}
</p>
</div>
<LinkGrid links={page.relatedLinks} />

View file

@ -1,12 +1,7 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ts } from '../../i18n/server';
import type {
FeatureFilters,
FeatureMeta,
HexagonStatsResponse,
PostcodeFeature,
} from '../../types';
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse } from '../../types';
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
import type { HexagonLocation } from '../../lib/external-search';
import {
@ -28,7 +23,7 @@ import StackedBarChart from './StackedBarChart';
import StackedEnumChart from './StackedEnumChart';
import PriceHistoryChart from './PriceHistoryChart';
import ExternalSearchLinks from './ExternalSearchLinks';
import { FilterIcon, InfoIcon } from '../ui/icons';
import { InfoIcon } from '../ui/icons';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { EmptyState } from '../ui/EmptyState';
@ -43,12 +38,12 @@ interface AreaPaneProps {
loading: boolean;
hexagonId: string | null;
isPostcode?: boolean;
postcodeData?: PostcodeFeature | null;
onViewProperties: () => void;
onClearFilters?: () => void;
hexagonLocation: HexagonLocation | null;
filters: FeatureFilters;
unfilteredCount?: number | null;
statsUseFilters: boolean;
onStatsUseFiltersChange: (useFilters: boolean) => void;
onNavigateToSource?: (slug: string, featureName: string) => void;
travelTimeEntries?: TravelTimeEntry[];
isGroupExpanded: (name: string) => boolean;
@ -71,21 +66,24 @@ export default function AreaPane({
loading,
hexagonId,
isPostcode = false,
postcodeData,
onViewProperties,
onClearFilters,
hexagonLocation,
filters,
unfilteredCount,
statsUseFilters,
onStatsUseFiltersChange,
onNavigateToSource,
travelTimeEntries,
isGroupExpanded,
onToggleGroup,
}: AreaPaneProps) {
const { t } = useTranslation();
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
const propertyCount = stats?.count;
const activeFilterCount = Object.keys(filters).length + (travelTimeEntries?.length ?? 0);
const hasFilteredOutArea = activeFilterCount > 0 && stats?.count === 0;
const filtersActive = activeFilterCount > 0;
const filteredStatsEmpty = filtersActive && statsUseFilters && stats?.count === 0;
const showFlipToggleCallout = filteredStatsEmpty && unfilteredCount !== 0;
const canViewProperties = stats && stats.count > 0 && (statsUseFilters || !filtersActive);
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
@ -148,37 +146,66 @@ export default function AreaPane({
</div>
</div>
<div className="flex gap-2 border-l-2 border-teal-500 bg-warm-50 px-2.5 py-2 text-xs leading-snug text-warm-700 dark:bg-navy-900 dark:text-warm-300">
<FilterIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-teal-700 dark:text-teal-300" />
<p>
{activeFilterCount > 0
? t('areaPane.filtersAffectStats', { count: activeFilterCount })
<div className="rounded border border-warm-200 bg-warm-50 px-2.5 py-2 dark:border-navy-700 dark:bg-navy-900">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-warm-700 dark:text-warm-200">
{t('areaPane.statsBasis')}
</span>
<div className="inline-flex shrink-0 rounded-md bg-warm-200 p-0.5 dark:bg-navy-800">
<button
type="button"
disabled={!filtersActive}
aria-pressed={statsUseFilters && filtersActive}
onClick={() => onStatsUseFiltersChange(true)}
className={`rounded px-2 py-1 text-xs font-medium ${
statsUseFilters && filtersActive
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
: 'text-warm-600 hover:text-warm-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-warm-400 dark:hover:text-warm-100'
}`}
>
{t('areaPane.matchingFiltersOption')}
</button>
<button
type="button"
aria-pressed={!statsUseFilters || !filtersActive}
onClick={() => onStatsUseFiltersChange(false)}
className={`rounded px-2 py-1 text-xs font-medium ${
!statsUseFilters || !filtersActive
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
}`}
>
{t('areaPane.allPropertiesOption')}
</button>
</div>
</div>
<p className="mt-1.5 text-xs leading-snug text-warm-500 dark:text-warm-400">
{filtersActive
? statsUseFilters
? t('areaPane.filtersAffectStats', { count: activeFilterCount })
: t('areaPane.filtersIgnoredForStats')
: t('areaPane.noFiltersAffectStats')}
</p>
</div>
{hasFilteredOutArea && (
{showFlipToggleCallout && (
<div className="mt-2 rounded border border-amber-200 bg-amber-50 px-2.5 py-2 text-xs leading-snug text-amber-900 dark:border-amber-800/70 dark:bg-amber-950/40 dark:text-amber-100">
<p className="font-semibold">{t('areaPane.noFilteredMatches')}</p>
<p className="font-semibold">{t('areaPane.filteredStatsEmpty')}</p>
<p className="mt-1">
{unfilteredCount != null && unfilteredCount > 0
? t('areaPane.unfilteredAreaCount', { count: unfilteredCount })
: unfilteredCount === 0
? t('areaPane.noUnfilteredAreaProperties')
: t('areaPane.relaxFiltersHint')}
{unfilteredCount != null
? t('areaPane.showAllStatsHint', { count: unfilteredCount })
: t('areaPane.showAllStatsFallback')}
</p>
{onClearFilters && (
<button
type="button"
onClick={onClearFilters}
className="mt-2 rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
>
{t('filters.clearAll')}
</button>
)}
<button
type="button"
onClick={() => onStatsUseFiltersChange(false)}
className="mt-2 rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
>
{t('areaPane.showAllStats')}
</button>
</div>
)}
{stats && stats.count > 0 && (
{canViewProperties && (
<button
onClick={onViewProperties}
className="w-full text-sm py-1.5 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium"

View file

@ -1,3 +1,5 @@
import { useTranslation } from 'react-i18next';
function downsampleBars(counts: number[], targetBars: number): number[] {
const step = Math.max(1, Math.floor(counts.length / targetBars));
const bars: number[] = [];
@ -34,7 +36,7 @@ export function DualHistogram({
p1,
p99,
globalMean,
meanLabel = 'National avg',
meanLabel,
formatLabel,
}: {
localCounts: number[];
@ -45,6 +47,7 @@ export function DualHistogram({
meanLabel?: string;
formatLabel?: (value: number) => string;
}) {
const { t } = useTranslation();
const targetBars = 25;
const localBars = downsampleBars(localCounts, targetBars);
const globalBars = downsampleBars(globalCounts, targetBars);
@ -124,7 +127,7 @@ export function DualHistogram({
className="absolute top-0 max-w-[7rem] truncate rounded-sm border border-warm-300 bg-white px-1 py-0.5 text-[9px] font-medium leading-none text-warm-600 shadow-sm dark:border-warm-600 dark:bg-navy-900 dark:text-warm-300"
style={meanLabelStyle}
>
{meanLabel}
{meanLabel ?? t('areaPane.nationalAvg')}
</div>
<div className="absolute bottom-0 top-5 w-px border-l border-dashed border-warm-400 dark:border-warm-500" />
</div>

View file

@ -1,3 +1,4 @@
import { ts } from '../../i18n/server';
import { getEnumValueColor } from '../../lib/consts';
export default function EnumBarChart({
@ -46,7 +47,7 @@ export default function EnumBarChart({
return (
<div key={label} className="flex items-center gap-2 text-xs">
<span className="w-16 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
{label}
{ts(label)}
</span>
<div className="flex-1 h-3 bg-warm-100 dark:bg-navy-700 rounded overflow-hidden relative">
{hasGlobal && (

View file

@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { FeatureFilters } from '../../types';
import {
buildRightmoveExactPostcodeRedirectUrl,
buildPropertySearchUrls,
H3_RADIUS_MILES,
type HexagonLocation,
@ -30,6 +31,11 @@ export default function ExternalSearchLinks({
() => buildPropertySearchUrls({ location, filters, rightmoveLocationId }),
[location, filters, rightmoveLocationId]
);
const rightmoveHref = useMemo(() => {
if (!urls?.rightmove) return null;
if (!location.isPostcode || !location.postcode) return urls.rightmove;
return buildRightmoveExactPostcodeRedirectUrl(location.postcode, urls.rightmove);
}, [location.isPostcode, location.postcode, urls?.rightmove]);
const radiusMiles = location.isPostcode ? 0 : (H3_RADIUS_MILES[location.resolution] ?? 1);
const label = radiusMiles === 0 ? t('externalSearch.exact') : `${radiusMiles}mi radius`;
@ -46,8 +52,8 @@ export default function ExternalSearchLinks({
{t('externalSearch.searchOn', { radius: label })}
</h3>
<div className="flex flex-wrap gap-2">
{urls.rightmove ? (
<a href={urls.rightmove} target="_blank" rel="noopener noreferrer" className={linkClass}>
{rightmoveHref ? (
<a href={rightmoveHref} target="_blank" rel="noopener noreferrer" className={linkClass}>
Rightmove
</a>
) : (

View file

@ -19,6 +19,14 @@ import {
isSpecificCrimeFeatureName,
isSpecificCrimeFilterName,
} from '../../lib/crime-filter';
import {
ELECTION_VOTE_SHARE_FILTER_NAME,
getDefaultElectionVoteShareFeatureName,
getElectionVoteShareFeatureName,
getElectionVoteShareFilterMeta,
isElectionVoteShareFeatureName,
isElectionVoteShareFilterName,
} from '../../lib/election-filter';
import {
ETHNICITIES_FILTER_NAME,
getDefaultEthnicityFeatureName,
@ -148,6 +156,11 @@ export default memo(function Filters({
[features]
);
const specificCrimeMeta = useMemo(() => getSpecificCrimeFilterMeta(features), [features]);
const defaultElectionVoteShareFeatureName = useMemo(
() => getDefaultElectionVoteShareFeatureName(features),
[features]
);
const electionVoteShareMeta = useMemo(() => getElectionVoteShareFilterMeta(features), [features]);
const defaultEthnicityFeatureName = useMemo(
() => getDefaultEthnicityFeatureName(features),
[features]
@ -212,6 +225,17 @@ export default memo(function Filters({
return { ...(backendFeature ?? specificCrimeMeta), name, group: 'Crime' };
});
}, [filters, features, specificCrimeMeta]);
const electionVoteShareFilterItems = useMemo(() => {
return Object.keys(filters)
.filter(isElectionVoteShareFilterName)
.map((name) => {
const backendName = getElectionVoteShareFeatureName(name);
const backendFeature = backendName
? features.find((feature) => feature.name === backendName)
: undefined;
return { ...(backendFeature ?? electionVoteShareMeta), name, group: 'Neighbours' };
});
}, [filters, features, electionVoteShareMeta]);
const ethnicityFilterItems = useMemo(() => {
return Object.keys(filters)
.filter(isEthnicityFilterName)
@ -220,7 +244,7 @@ export default memo(function Filters({
const backendFeature = backendName
? features.find((feature) => feature.name === backendName)
: undefined;
return { ...(backendFeature ?? ethnicityMeta), name, group: 'Demographics' };
return { ...(backendFeature ?? ethnicityMeta), name, group: 'Neighbours' };
});
}, [filters, features, ethnicityMeta]);
const poiDistanceFilterItems = useMemo(() => {
@ -239,6 +263,7 @@ export default memo(function Filters({
const result: FeatureMeta[] = [];
let insertedSchoolFilter = false;
let insertedSpecificCrimeFilter = false;
let insertedElectionVoteShareFilter = false;
let insertedEthnicityFilter = false;
const insertedPoiFilters = new Set<PoiFilterName>();
@ -257,6 +282,13 @@ export default memo(function Filters({
}
continue;
}
if (isElectionVoteShareFeatureName(feature.name)) {
if (defaultElectionVoteShareFeatureName && !insertedElectionVoteShareFilter) {
result.push(electionVoteShareMeta);
insertedElectionVoteShareFilter = true;
}
continue;
}
if (isEthnicityFeatureName(feature.name)) {
if (defaultEthnicityFeatureName && !insertedEthnicityFilter) {
result.push(ethnicityMeta);
@ -287,6 +319,8 @@ export default memo(function Filters({
schoolMeta,
defaultSpecificCrimeFeatureName,
specificCrimeMeta,
defaultElectionVoteShareFeatureName,
electionVoteShareMeta,
defaultEthnicityFeatureName,
ethnicityMeta,
defaultPoiFilterFeatureNames,
@ -296,6 +330,7 @@ export default memo(function Filters({
const result: FeatureMeta[] = [];
let insertedSchoolFilter = false;
let insertedSpecificCrimeFilters = false;
let insertedElectionVoteShareFilters = false;
let insertedEthnicityFilters = false;
let insertedPoiDistanceFilters = false;
@ -314,6 +349,13 @@ export default memo(function Filters({
}
continue;
}
if (isElectionVoteShareFeatureName(feature.name)) {
if (!insertedElectionVoteShareFilters) {
result.push(...electionVoteShareFilterItems);
insertedElectionVoteShareFilters = true;
}
continue;
}
if (isEthnicityFeatureName(feature.name)) {
if (!insertedEthnicityFilters) {
result.push(...ethnicityFilterItems);
@ -337,6 +379,7 @@ export default memo(function Filters({
enabledFeatures,
schoolFilterItems,
specificCrimeFilterItems,
electionVoteShareFilterItems,
ethnicityFilterItems,
poiDistanceFilterItems,
]);
@ -350,6 +393,7 @@ export default memo(function Filters({
const activeEntryCount = travelTimeEntries.length;
const pendingScrollRef = useRef<string | null>(null);
const highlightTimeoutRef = useRef<number | null>(null);
const handleAddAndScroll = useCallback(
(name: string) => {
@ -365,6 +409,12 @@ export default memo(function Filters({
onAddFilter(SPECIFIC_CRIMES_FILTER_NAME);
return;
}
if (name === ELECTION_VOTE_SHARE_FILTER_NAME) {
if (!defaultElectionVoteShareFeatureName) return;
pendingScrollRef.current = ELECTION_VOTE_SHARE_FILTER_NAME;
onAddFilter(ELECTION_VOTE_SHARE_FILTER_NAME);
return;
}
if (name === ETHNICITIES_FILTER_NAME) {
if (!defaultEthnicityFeatureName) return;
pendingScrollRef.current = ETHNICITIES_FILTER_NAME;
@ -385,6 +435,7 @@ export default memo(function Filters({
[
defaultSchoolFeatureName,
defaultSpecificCrimeFeatureName,
defaultElectionVoteShareFeatureName,
defaultEthnicityFeatureName,
defaultPoiFilterFeatureNames,
onAddFilter,
@ -403,10 +454,26 @@ export default memo(function Filters({
const name = pendingScrollRef.current;
if (!name) return;
pendingScrollRef.current = null;
const el = scrollRef.current?.querySelector(`[data-filter-name="${CSS.escape(name)}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
const el = scrollRef.current?.querySelector<HTMLElement>(
`[data-filter-name="${CSS.escape(name)}"]`
);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
if (highlightTimeoutRef.current !== null) {
window.clearTimeout(highlightTimeoutRef.current);
document.querySelectorAll('.filter-highlight-flash').forEach((node) => {
node.classList.remove('filter-highlight-flash');
});
}
// Restart the animation if the same element is re-highlighted.
el.classList.remove('filter-highlight-flash');
void el.offsetWidth;
el.classList.add('filter-highlight-flash');
highlightTimeoutRef.current = window.setTimeout(() => {
el.classList.remove('filter-highlight-flash');
highlightTimeoutRef.current = null;
}, 2000);
}, [enabledFeatureList, travelTimeEntries]);
const percentileScales = useMemo(() => {
const scales = new Map<string, PercentileScale>();
@ -466,6 +533,7 @@ export default memo(function Filters({
<ActiveFiltersPanel
scrollRef={scrollRef}
collapsed={activeFilterCollapsed}
addFiltersExpanded={!addFilterCollapsed}
badgeCount={badgeCount}
activeEntryCount={activeEntryCount}
features={features}
@ -512,6 +580,7 @@ export default memo(function Filters({
...features,
schoolMeta,
specificCrimeMeta,
electionVoteShareMeta,
ethnicityMeta,
poiDistanceMeta,
poiCount2KmMeta,
@ -520,6 +589,7 @@ export default memo(function Filters({
pinnedFeature={pinnedFeature}
defaultSchoolFeatureName={defaultSchoolFeatureName}
defaultSpecificCrimeFeatureName={defaultSpecificCrimeFeatureName}
defaultElectionVoteShareFeatureName={defaultElectionVoteShareFeatureName}
defaultEthnicityFeatureName={defaultEthnicityFeatureName}
defaultPoiFilterFeatureNames={defaultPoiFilterFeatureNames}
openInfoFeature={openInfoFeature}

View file

@ -5,6 +5,7 @@ import { formatValue } from '../../lib/format';
import { ts } from '../../i18n/server';
import { SCHOOL_FILTER_NAME, getSchoolBackendFeatureName } from '../../lib/school-filter';
import { getSpecificCrimeFeatureName } from '../../lib/crime-filter';
import { getElectionVoteShareFeatureName } from '../../lib/election-filter';
import { getEthnicityFeatureName } from '../../lib/ethnicity-filter';
import { POI_DISTANCE_FILTER_NAME, getPoiDistanceFeatureName } from '../../lib/poi-distance-filter';
@ -47,11 +48,13 @@ export default memo(function HoverCard({
for (const name of activeFilterNames.slice(0, 4)) {
const schoolBackendName = getSchoolBackendFeatureName(name);
const specificCrimeFeatureName = getSpecificCrimeFeatureName(name);
const electionVoteShareFeatureName = getElectionVoteShareFeatureName(name);
const ethnicityFeatureName = getEthnicityFeatureName(name);
const poiDistanceFeatureName = getPoiDistanceFeatureName(name);
const backendName =
schoolBackendName ??
specificCrimeFeatureName ??
electionVoteShareFeatureName ??
ethnicityFeatureName ??
poiDistanceFeatureName ??
name;

View file

@ -458,7 +458,7 @@ export default memo(function Map({
className="font-bold text-white whitespace-nowrap"
style={{ fontSize: '5rem' }}
>
Your perfect postcode
{t('map.ogTitle')}
</span>
</div>
</div>
@ -467,23 +467,23 @@ export default memo(function Map({
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-between px-10 py-4 bg-white">
<div className="flex items-center gap-6">
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
Property prices
{t('map.ogPropertyPrices')}
</span>
<span className="text-warm-300">|</span>
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
Energy ratings
{t('map.ogEnergyRatings')}
</span>
<span className="text-warm-300">|</span>
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
Schools
{t('map.ogSchools')}
</span>
<span className="text-warm-300">|</span>
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
Crime stats
{t('map.ogCrimeStats')}
</span>
<span className="text-warm-300">|</span>
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
Transport
{t('map.ogTransport')}
</span>
</div>
<span className="text-teal-600 font-semibold" style={{ fontSize: '1rem' }}>
@ -583,7 +583,9 @@ export default memo(function Map({
<div className="text-lg font-bold text-teal-600 dark:text-teal-400">
{popupInfo.clusterCount}
</div>
<div className="text-warm-500 dark:text-warm-400 text-xs">places</div>
<div className="text-warm-500 dark:text-warm-400 text-xs">
{t('common.places')}
</div>
</div>
) : (
<div className="px-3 py-2">
@ -610,7 +612,7 @@ export default memo(function Map({
backgroundColor: `rgb(${getPoiGroupColor(popupInfo.group).join(',')})`,
}}
/>
{popupInfo.category}
{ts(popupInfo.category)}
</div>
</div>
</div>

View file

@ -271,6 +271,8 @@ export default function MapPage({
areaStats,
loadingAreaStats,
unfilteredAreaCount,
areaStatsUseFilters,
setAreaStatsUseFilters,
hoveredHexagon,
rightPaneTab,
setRightPaneTab,
@ -456,18 +458,12 @@ export default function MapPage({
loading={loadingAreaStats}
hexagonId={selectedHexagon?.id || null}
isPostcode={selectedHexagon?.type === 'postcode'}
postcodeData={
selectedHexagon?.type === 'postcode'
? mapData.postcodeData.find(
(feature) => feature.properties.postcode === selectedHexagon?.id
) || null
: null
}
onViewProperties={handleViewPropertiesFromArea}
onClearFilters={hasActiveFilters ? handleClearAll : undefined}
hexagonLocation={hexagonLocation}
filters={filters}
unfilteredCount={unfilteredAreaCount}
statsUseFilters={areaStatsUseFilters}
onStatsUseFiltersChange={setAreaStatsUseFilters}
travelTimeEntries={activeEntries}
isGroupExpanded={isAreaGroupExpanded}
onToggleGroup={toggleAreaGroup}

View file

@ -1,4 +1,5 @@
import type { PointerEvent, ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { TabButton } from '../ui/TabButton';
import { CloseIcon } from '../ui/icons/CloseIcon';
@ -30,6 +31,8 @@ export function MapPageSelectionPane({
renderAreaPane,
renderPropertiesPane,
}: MapPageSelectionPaneProps) {
const { t } = useTranslation();
return (
<div
data-tutorial="right-pane"
@ -48,16 +51,16 @@ export function MapPageSelectionPane({
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
<TabButton label="Area" isActive={tab === 'area'} onClick={onAreaTabClick} />
<TabButton label={t('common.area')} isActive={tab === 'area'} onClick={onAreaTabClick} />
<TabButton
label="Properties"
label={t('common.properties')}
isActive={tab === 'properties'}
onClick={onPropertiesTabClick}
/>
<button
onClick={onClose}
className="px-2 flex items-center text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Close pane"
title={t('common.closePane')}
>
<CloseIcon className="w-4 h-4" />
</button>

View file

@ -1,4 +1,6 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ts } from '../../i18n/server';
import { formatValue, roundedPercentages } from '../../lib/format';
interface Segment {
@ -27,6 +29,7 @@ function shortenLabel(name: string): string {
}
export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) {
const { t } = useTranslation();
const sortedSegments = useMemo(() => [...segments].sort((a, b) => b.value - a.value), [segments]);
const roundedPcts = useMemo(
() =>
@ -39,7 +42,9 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
);
if (total === 0) {
return <div className="text-xs text-warm-400 dark:text-warm-500 italic">No data</div>;
return (
<div className="text-xs text-warm-400 dark:text-warm-500 italic">{t('common.noData')}</div>
);
}
const colorFor = (segmentName: string): string => {
@ -57,6 +62,7 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
{sortedSegments.map((segment, i) => {
const pct = (segment.value / total) * 100;
if (pct < 0.5) return null;
const label = shortenLabel(ts(segment.name));
return (
<div
key={segment.name}
@ -65,7 +71,7 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
width: `${pct}%`,
backgroundColor: colorFor(segment.name),
}}
title={`${shortenLabel(segment.name)}: ${formatValue(segment.value)} (${roundedPcts[i].toFixed(1)}%)`}
title={`${label}: ${formatValue(segment.value)} (${roundedPcts[i].toFixed(1)}%)`}
/>
);
})}
@ -73,22 +79,23 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
{/* Legend */}
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
{sortedSegments.map((segment) => (
<div key={segment.name} className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-sm shrink-0"
style={{
backgroundColor: colorFor(segment.name),
}}
/>
<span className="text-[10px] text-warm-600 dark:text-warm-400">
{shortenLabel(segment.name)}
</span>
<span className="text-[10px] text-warm-400 dark:text-warm-500">
{formatValue(segment.value)}
</span>
</div>
))}
{sortedSegments.map((segment) => {
const label = shortenLabel(ts(segment.name));
return (
<div key={segment.name} className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-sm shrink-0"
style={{
backgroundColor: colorFor(segment.name),
}}
/>
<span className="text-[10px] text-warm-600 dark:text-warm-400">{label}</span>
<span className="text-[10px] text-warm-400 dark:text-warm-500">
{formatValue(segment.value)}
</span>
</div>
);
})}
</div>
</div>
);

View file

@ -1,3 +1,5 @@
import { useTranslation } from 'react-i18next';
import { ts } from '../../i18n/server';
import type { EnumFeatureStats } from '../../types';
import { roundedPercentages } from '../../lib/format';
@ -17,6 +19,7 @@ export default function StackedEnumChart({
valueOrder,
valueColors,
}: StackedEnumChartProps) {
const { t } = useTranslation();
const visibleRows = components.filter(({ stats }) => {
const total = Object.values(stats.counts).reduce((a, b) => a + b, 0);
if (total === 0) return false;
@ -25,7 +28,11 @@ export default function StackedEnumChart({
});
if (visibleRows.length === 0) {
return <div className="text-xs text-warm-400 dark:text-warm-500 italic mt-1">All low</div>;
return (
<div className="text-xs text-warm-400 dark:text-warm-500 italic mt-1">
{t('common.allLow')}
</div>
);
}
return (
@ -38,7 +45,7 @@ export default function StackedEnumChart({
return (
<div key={label} className="flex items-center gap-2 text-xs">
<span className="w-24 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
{shortenLabel(label)}
{shortenLabel(ts(label))}
</span>
<div className="flex-1 flex h-3.5 rounded overflow-hidden bg-warm-200 dark:bg-warm-700">
{valueOrder.map((value, i) => {
@ -53,7 +60,7 @@ export default function StackedEnumChart({
width: `${pct}%`,
backgroundColor: valueColors[i],
}}
title={`${value}: ${count} (${roundedPcts[i]}%)`}
title={`${ts(value)}: ${count} (${roundedPcts[i]}%)`}
/>
);
})}
@ -70,7 +77,7 @@ export default function StackedEnumChart({
className="w-2 h-2 rounded-sm shrink-0"
style={{ backgroundColor: valueColors[i] }}
/>
<span className="text-[10px] text-warm-600 dark:text-warm-400">{value}</span>
<span className="text-[10px] text-warm-600 dark:text-warm-400">{ts(value)}</span>
</div>
))}
</div>

View file

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

View file

@ -3,6 +3,10 @@ import { Fragment } from 'react';
import type { FeatureFilters, FeatureMeta } from '../../../types';
import type { PercentileScale } from '../../../lib/format';
import { getSpecificCrimeFeatureName, isSpecificCrimeFilterName } from '../../../lib/crime-filter';
import {
getElectionVoteShareFeatureName,
isElectionVoteShareFilterName,
} from '../../../lib/election-filter';
import { getEthnicityFeatureName, isEthnicityFilterName } from '../../../lib/ethnicity-filter';
import { getSchoolBackendFeatureName, isSchoolFilterName } from '../../../lib/school-filter';
import {
@ -14,6 +18,7 @@ import { EthnicityFilterCard } from './EthnicityFilterCard';
import { PoiDistanceFilterCard } from './PoiDistanceFilterCard';
import { SchoolFilterCard } from './SchoolFilterCard';
import { SpecificCrimeFilterCard } from './SpecificCrimeFilterCard';
import { ElectionVoteShareFilterCard } from './ElectionVoteShareFilterCard';
import { EnumFeatureFilterCard } from './EnumFeatureFilterCard';
import { NumericFeatureFilterCard } from './NumericFeatureFilterCard';
import { TravelTimeFilterCards } from './TravelTimeFilterCards';
@ -156,6 +161,40 @@ export function ActiveFilterList({
);
}
if (isElectionVoteShareFilterName(feature.name)) {
const electionVoteShareBackendName = getElectionVoteShareFeatureName(feature.name);
return (
<Fragment key={feature.name}>
{insertTravelCards && travelCards}
<ElectionVoteShareFilterCard
features={features}
voteShareFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={
electionVoteShareBackendName
? filterImpacts?.[electionVoteShareBackendName]
: undefined
}
percentileScale={
electionVoteShareBackendName
? percentileScales.get(electionVoteShareBackendName)
: undefined
}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={() => onRemoveFilter(feature.name)}
/>
</Fragment>
);
}
if (isEthnicityFilterName(feature.name)) {
const ethnicityBackendName = getEthnicityFeatureName(feature.name);
return (

View file

@ -12,6 +12,7 @@ import { ActiveFilterList } from './ActiveFilterList';
interface ActiveFiltersPanelProps {
scrollRef: RefObject<HTMLDivElement | null>;
collapsed: boolean;
addFiltersExpanded: boolean;
badgeCount: number;
activeEntryCount: number;
features: FeatureMeta[];
@ -59,6 +60,7 @@ interface ActiveFiltersPanelProps {
export function ActiveFiltersPanel({
scrollRef,
collapsed,
addFiltersExpanded,
badgeCount,
activeEntryCount,
features,
@ -102,7 +104,7 @@ export function ActiveFiltersPanel({
<div
className={`flex flex-col md:min-h-0 ${
collapsed ? 'md:[flex:0_0_auto]' : 'md:[flex:0_1_auto]'
}`}
} ${!collapsed && addFiltersExpanded ? 'md:max-h-[60%]' : ''}`}
>
<button
onClick={onToggleCollapsed}
@ -146,7 +148,10 @@ export function ActiveFiltersPanel({
</button>
{!collapsed && (
<div ref={scrollRef} className="md:min-h-0 md:overflow-y-auto overflow-x-hidden">
<div
ref={scrollRef}
className="md:min-h-0 md:flex-1 md:overflow-y-auto overflow-x-hidden"
>
<AiFilterInput
loading={aiFilterLoading}
error={aiFilterError}

View file

@ -5,6 +5,10 @@ import type { FeatureMeta } from '../../../types';
import { ChevronIcon } from '../../ui/icons';
import FeatureBrowser from '../FeatureBrowser';
import { SPECIFIC_CRIMES_FILTER_NAME, isSpecificCrimeFilterName } from '../../../lib/crime-filter';
import {
ELECTION_VOTE_SHARE_FILTER_NAME,
isElectionVoteShareFilterName,
} from '../../../lib/election-filter';
import { ETHNICITIES_FILTER_NAME, isEthnicityFilterName } from '../../../lib/ethnicity-filter';
import { SCHOOL_FILTER_NAME, isSchoolFilterName } from '../../../lib/school-filter';
import {
@ -23,6 +27,7 @@ interface AddFilterPanelProps {
pinnedFeature: string | null;
defaultSchoolFeatureName: string | null;
defaultSpecificCrimeFeatureName: string | null;
defaultElectionVoteShareFeatureName: string | null;
defaultEthnicityFeatureName: string | null;
defaultPoiFilterFeatureNames: Record<PoiFilterName, string | null>;
openInfoFeature?: string | null;
@ -44,6 +49,7 @@ export function AddFilterPanel({
pinnedFeature,
defaultSchoolFeatureName,
defaultSpecificCrimeFeatureName,
defaultElectionVoteShareFeatureName,
defaultEthnicityFeatureName,
defaultPoiFilterFeatureNames,
openInfoFeature,
@ -63,11 +69,13 @@ export function AddFilterPanel({
? SCHOOL_FILTER_NAME
: pinnedFeature && isSpecificCrimeFilterName(pinnedFeature)
? SPECIFIC_CRIMES_FILTER_NAME
: pinnedFeature && isEthnicityFilterName(pinnedFeature)
? ETHNICITIES_FILTER_NAME
: pinnedFeature && isPoiDistanceFilterName(pinnedFeature)
? (getPoiFilterName(pinnedFeature) ?? POI_DISTANCE_FILTER_NAME)
: pinnedFeature;
: pinnedFeature && isElectionVoteShareFilterName(pinnedFeature)
? ELECTION_VOTE_SHARE_FILTER_NAME
: pinnedFeature && isEthnicityFilterName(pinnedFeature)
? ETHNICITIES_FILTER_NAME
: pinnedFeature && isPoiDistanceFilterName(pinnedFeature)
? (getPoiFilterName(pinnedFeature) ?? POI_DISTANCE_FILTER_NAME)
: pinnedFeature;
const handleTogglePin = (name: string) => {
if (name === SCHOOL_FILTER_NAME) {
@ -78,6 +86,10 @@ export function AddFilterPanel({
if (defaultSpecificCrimeFeatureName) onTogglePin(defaultSpecificCrimeFeatureName);
return;
}
if (name === ELECTION_VOTE_SHARE_FILTER_NAME) {
if (defaultElectionVoteShareFeatureName) onTogglePin(defaultElectionVoteShareFeatureName);
return;
}
if (name === ETHNICITIES_FILTER_NAME) {
if (defaultEthnicityFeatureName) onTogglePin(defaultEthnicityFeatureName);
return;

View file

@ -0,0 +1,229 @@
import { useTranslation } from 'react-i18next';
import { ts } from '../../../i18n/server';
import { Slider } from '../../ui/Slider';
import { ChevronIcon } from '../../ui/icons';
import { FeatureActions } from '../../ui/FeatureIcons';
import { FeatureLabel } from '../../ui/FeatureLabel';
import type { FeatureFilters, FeatureMeta } from '../../../types';
import { formatNumber, type PercentileScale } from '../../../lib/format';
import { getFeatureIcon } from '../../../lib/feature-icons';
import { getGroupIcon } from '../../../lib/group-icons';
import {
ELECTION_VOTE_SHARE_FEATURE_NAMES,
ELECTION_VOTE_SHARE_FILTER_NAME,
clampElectionVoteShareRange,
getDefaultElectionVoteShareFeatureName,
getElectionVoteShareFeatureName,
getElectionVoteShareFilterMeta,
replaceElectionVoteShareFilterKeySelection,
} from '../../../lib/election-filter';
import { SliderLabels } from './SliderLabels';
export function ElectionVoteShareFilterCard({
features,
voteShareFeature,
filters,
activeFeature,
dragValue,
pinnedFeature,
filterImpact,
percentileScale,
onFilterChange,
onDragStart,
onDragChange,
onDragEnd,
onTogglePin,
onShowInfo,
onRemove,
}: {
features: FeatureMeta[];
voteShareFeature: FeatureMeta;
filters: FeatureFilters;
activeFeature: string | null;
dragValue: [number, number] | null;
pinnedFeature: string | null;
filterImpact?: number;
percentileScale?: PercentileScale;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
onDragStart: (name: string) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
onShowInfo: (feature: FeatureMeta) => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const voteShareMeta = getElectionVoteShareFilterMeta(features);
const voteShareOptions = ELECTION_VOTE_SHARE_FEATURE_NAMES.map((name) =>
features.find((feature) => feature.name === name)
).filter((feature): feature is FeatureMeta => Boolean(feature));
const selectedFeatureName =
getElectionVoteShareFeatureName(voteShareFeature.name) ??
getDefaultElectionVoteShareFeatureName(features);
const selectedFeature = selectedFeatureName
? features.find((feature) => feature.name === selectedFeatureName)
: undefined;
if (!selectedFeature || voteShareOptions.length === 0 || !selectedFeatureName) return null;
const isActive = activeFeature === voteShareFeature.name;
const isPinned = pinnedFeature === voteShareFeature.name;
const hist = selectedFeature.histogram;
const dataMin = hist?.min ?? selectedFeature.min ?? 0;
const dataMax = hist?.max ?? selectedFeature.max ?? 100;
const displayValue =
isActive && dragValue
? dragValue
: (filters[voteShareFeature.name] as [number, number]) || [dataMin, dataMax];
const scale = percentileScale;
const clampMin = displayValue[0] <= dataMin;
const clampMax = displayValue[1] >= dataMax;
const isAtMin = displayValue[0] === dataMin;
const isAtMax = displayValue[1] === dataMax;
const sliderValue: [number, number] = scale
? [
clampMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
clampMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
]
: [
clampMin ? (selectedFeature.min ?? dataMin) : displayValue[0],
clampMax ? (selectedFeature.max ?? dataMax) : displayValue[1],
];
const replaceVoteShareFeature = (nextFeatureName: string) => {
const nextName = replaceElectionVoteShareFilterKeySelection(
voteShareFeature.name,
nextFeatureName
);
if (nextName === voteShareFeature.name) return;
const nextFeature = features.find((feature) => feature.name === nextFeatureName);
const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0;
const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax);
const nextRange = clampElectionVoteShareRange(
[
displayValue[0] <= dataMin ? nextDataMin : displayValue[0],
displayValue[1] >= dataMax ? nextDataMax : displayValue[1],
],
nextFeature
);
onFilterChange(nextName, nextRange);
if (isPinned) onTogglePin(nextName);
};
const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0';
const mobileIcon =
getFeatureIcon(selectedFeature.name, mobileIconClass) ||
(() => {
const G = selectedFeature.group ? getGroupIcon(selectedFeature.group) : null;
return G ? <G className={mobileIconClass} /> : null;
})();
return (
<div
data-filter-name={ELECTION_VOTE_SHARE_FILTER_NAME}
className={`space-y-2 rounded-lg border border-warm-200 bg-white px-2 py-2 shadow-sm dark:border-warm-700 dark:bg-warm-800 ${
isActive
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
: isPinned
? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20'
: ''
}`}
>
<div className="relative z-10 flex items-center justify-between gap-1">
<FeatureLabel
feature={voteShareMeta}
size="sm"
className="min-w-0 shrink"
hideIconOnMobile
/>
<FeatureActions
feature={selectedFeature}
actionName={voteShareFeature.name}
isPinned={isPinned}
isPreviewing={isActive}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={onRemove}
/>
</div>
<div>
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
{t('filters.party')}
</label>
<div className="relative">
<select
value={selectedFeatureName}
onChange={(e) => replaceVoteShareFeature(e.target.value)}
className="w-full appearance-none rounded-md border border-warm-200 bg-warm-50 px-2 py-1.5 pr-8 text-sm font-medium text-navy-950 shadow-inner outline-none transition-colors hover:bg-white focus:border-teal-400 focus:ring-2 focus:ring-teal-200 dark:border-warm-700 dark:bg-navy-900 dark:text-warm-100 dark:hover:bg-navy-800 dark:focus:ring-teal-900/50"
>
{voteShareOptions.map((option) => (
<option key={option.name} value={option.name}>
{ts(option.name)}
</option>
))}
</select>
<ChevronIcon
direction="down"
className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-warm-400 dark:text-warm-500"
/>
</div>
</div>
<div className="flex items-start gap-1.5 md:block">
{mobileIcon && <div className="shrink-0 pt-0.5 md:hidden">{mobileIcon}</div>}
<div className="min-w-0 flex-1">
<Slider
min={scale ? 0 : (selectedFeature.min ?? dataMin)}
max={scale ? 100 : (selectedFeature.max ?? dataMax)}
step={
scale
? 1
: (selectedFeature.step ??
((selectedFeature.max ?? dataMax) - (selectedFeature.min ?? dataMin)) / 100)
}
value={sliderValue}
onValueChange={
scale
? ([pMin, pMax]) => {
const step = selectedFeature.step ?? 1;
const snap = (v: number) => Math.round(v / step) * step;
onDragChange([
pMin <= 0 ? dataMin : snap(scale.toValue(pMin)),
pMax >= 100 ? dataMax : snap(scale.toValue(pMax)),
]);
}
: ([min, max]) =>
onDragChange([
min <= (selectedFeature.min ?? dataMin) ? dataMin : min,
max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
])
}
onPointerDown={() => onDragStart(voteShareFeature.name)}
onPointerUp={() => onDragEnd()}
/>
<SliderLabels
min={scale ? 0 : (selectedFeature.min ?? dataMin)}
max={scale ? 100 : (selectedFeature.max ?? dataMax)}
value={sliderValue}
displayValues={displayValue}
isAtMin={isAtMin}
isAtMax={isAtMax}
raw={selectedFeature.raw}
feature={selectedFeature}
onValueChange={(v) =>
onFilterChange(voteShareFeature.name, clampElectionVoteShareRange(v, selectedFeature))
}
/>
{filterImpact != null && filterImpact > 0 && (
<p className="-mt-1 ml-2.5 text-[10px] text-warm-400 dark:text-warm-500">
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
</p>
)}
</div>
</div>
</div>
);
}

View file

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { ts } from '../../../i18n/server';
import type { FeatureFilters, FeatureMeta } from '../../../types';
import { formatNumber } from '../../../lib/format';
@ -27,6 +28,7 @@ export function EnumFeatureFilterCard({
onShowInfo,
onRemoveFilter,
}: EnumFeatureFilterCardProps) {
const { t } = useTranslation();
const selectedValues = (filters[feature.name] as string[]) || [];
const allValues = feature.values || [];
@ -63,7 +65,7 @@ export function EnumFeatureFilterCard({
</PillGroup>
{filterImpact != null && filterImpact > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 mt-0.5">
+{formatNumber(filterImpact)} without this filter
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
</p>
)}
</div>

View file

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { ts } from '../../../i18n/server';
import { Slider } from '../../ui/Slider';
import { ChevronIcon } from '../../ui/icons';
@ -51,6 +52,7 @@ export function EthnicityFilterCard({
onShowInfo: (feature: FeatureMeta) => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const ethnicityMeta = getEthnicityFilterMeta(features);
const ethnicityOptions = ETHNICITY_FEATURE_NAMES.map((name) =>
features.find((feature) => feature.name === name)
@ -145,7 +147,7 @@ export function EthnicityFilterCard({
<div>
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
Ethnicity
{t('filters.ethnicity')}
</label>
<div className="relative">
<select
@ -213,7 +215,7 @@ export function EthnicityFilterCard({
/>
{filterImpact != null && filterImpact > 0 && (
<p className="-mt-1 ml-2.5 text-[10px] text-warm-400 dark:text-warm-500">
+{formatNumber(filterImpact)} without this filter
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
</p>
)}
</div>

View file

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import type { FeatureFilters, FeatureMeta } from '../../../types';
import { formatNumber, type PercentileScale } from '../../../lib/format';
import { getFeatureIcon } from '../../../lib/feature-icons';
@ -40,6 +41,7 @@ export function NumericFeatureFilterCard({
onShowInfo,
onRemoveFilter,
}: NumericFeatureFilterCardProps) {
const { t } = useTranslation();
const isActive = activeFeature === feature.name;
const isPinned = pinnedFeature === feature.name;
const hist = feature.histogram;
@ -128,7 +130,7 @@ export function NumericFeatureFilterCard({
/>
{filterImpact != null && filterImpact > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
+{formatNumber(filterImpact)} without this filter
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
</p>
)}
</div>

View file

@ -1,6 +1,5 @@
import { ts } from '../../../i18n/server';
import { useTranslation } from 'react-i18next';
import { Slider } from '../../ui/Slider';
import { ChevronIcon } from '../../ui/icons';
import { FeatureActions } from '../../ui/FeatureIcons';
import { FeatureLabel } from '../../ui/FeatureLabel';
import type { FeatureFilters, FeatureMeta } from '../../../types';
@ -11,13 +10,13 @@ import {
POI_DISTANCE_FILTER_NAME,
clampPoiFilterRange,
getDefaultPoiFilterFeatureName,
getPoiFeatureCategory,
getPoiDistanceFeatureName,
getPoiFilterFeatureOptions,
getPoiFilterMeta,
getPoiFilterName,
replacePoiFilterKeySelection,
} from '../../../lib/poi-distance-filter';
import { PoiTypeDropdown } from './PoiTypeDropdown';
import { SliderLabels } from './SliderLabels';
export function PoiDistanceFilterCard({
@ -53,6 +52,7 @@ export function PoiDistanceFilterCard({
onShowInfo: (feature: FeatureMeta) => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const filterName = getPoiFilterName(poiFeature.name) ?? POI_DISTANCE_FILTER_NAME;
const poiMeta = getPoiFilterMeta(features, filterName);
const poiOptions = getPoiFilterFeatureOptions(features, filterName);
@ -142,25 +142,13 @@ export function PoiDistanceFilterCard({
<div>
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
POI type
{t('filters.poiType')}
</label>
<div className="relative">
<select
value={selectedFeatureName}
onChange={(e) => replacePoiFeature(e.target.value)}
className="w-full appearance-none rounded-md border border-warm-200 bg-warm-50 px-2 py-1.5 pr-8 text-sm font-medium text-navy-950 shadow-inner outline-none transition-colors hover:bg-white focus:border-teal-400 focus:ring-2 focus:ring-teal-200 dark:border-warm-700 dark:bg-navy-900 dark:text-warm-100 dark:hover:bg-navy-800 dark:focus:ring-teal-900/50"
>
{poiOptions.map((option) => (
<option key={option.name} value={option.name}>
{ts(getPoiFeatureCategory(option.name) ?? option.name)}
</option>
))}
</select>
<ChevronIcon
direction="down"
className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-warm-400 dark:text-warm-500"
/>
</div>
<PoiTypeDropdown
options={poiOptions}
value={selectedFeatureName}
onChange={replacePoiFeature}
/>
</div>
<div className="flex items-start gap-1.5 md:block">
@ -210,7 +198,7 @@ export function PoiDistanceFilterCard({
/>
{filterImpact != null && filterImpact > 0 && (
<p className="-mt-1 ml-2.5 text-[10px] text-warm-400 dark:text-warm-500">
+{formatNumber(filterImpact)} without this filter
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
</p>
)}
</div>

View file

@ -0,0 +1,200 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
import { ts } from '../../../i18n/server';
import { dropdownPositionStyle, useDropdownPosition } from '../../../hooks/useDropdownPosition';
import { ChevronIcon } from '../../ui/icons/ChevronIcon';
import { SearchIcon } from '../../ui/icons/SearchIcon';
import { getFeatureIcon } from '../../../lib/feature-icons';
import { getGroupIcon } from '../../../lib/group-icons';
import { getPoiFeatureCategory } from '../../../lib/poi-distance-filter';
import type { FeatureMeta } from '../../../types';
interface PoiTypeDropdownProps {
options: FeatureMeta[];
value: string;
onChange: (featureName: string) => void;
}
function optionLabel(option: FeatureMeta): string {
return ts(getPoiFeatureCategory(option.name) ?? option.name);
}
function optionIcon(option: FeatureMeta, className: string) {
const icon = getFeatureIcon(option.name, className);
if (icon) return icon;
const G = option.group ? getGroupIcon(option.group) : null;
return G ? <G className={className} /> : null;
}
export function PoiTypeDropdown({ options, value, onChange }: PoiTypeDropdownProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState('');
const [activeIndex, setActiveIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const pos = useDropdownPosition(containerRef, open);
const selected = options.find((option) => option.name === value);
const filtered = useMemo(() => {
if (!filter.trim()) return options;
const lower = filter.trim().toLowerCase();
return options.filter((option) => optionLabel(option).toLowerCase().includes(lower));
}, [options, filter]);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node) &&
!dropdownRef.current?.contains(e.target as Node)
) {
setOpen(false);
setFilter('');
setActiveIndex(-1);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
useEffect(() => {
if (activeIndex < 0 || !listRef.current) return;
const item = listRef.current.children[activeIndex] as HTMLElement | undefined;
item?.scrollIntoView({ block: 'nearest' });
}, [activeIndex]);
const handleSelect = useCallback(
(option: FeatureMeta) => {
onChange(option.name);
setOpen(false);
setFilter('');
setActiveIndex(-1);
},
[onChange]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : prev));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((prev) => (prev > 0 ? prev - 1 : -1));
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0 && activeIndex < filtered.length) {
handleSelect(filtered[activeIndex]);
}
} else if (e.key === 'Escape') {
setOpen(false);
setFilter('');
setActiveIndex(-1);
}
},
[filtered, activeIndex, handleSelect]
);
const handleOpen = () => {
setOpen(true);
setFilter('');
setActiveIndex(-1);
requestAnimationFrame(() => inputRef.current?.focus({ preventScroll: true }));
};
const dropdown = open && (
<div
ref={dropdownRef}
className="flex flex-col overflow-hidden rounded-md border border-warm-200 bg-white shadow-lg dark:border-warm-700 dark:bg-warm-800"
style={pos ? dropdownPositionStyle(pos) : undefined}
>
<div className="shrink-0 border-b border-warm-100 p-1.5 dark:border-warm-700">
<div className="relative">
<SearchIcon className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-warm-400 dark:text-warm-500" />
<input
ref={inputRef}
type="text"
value={filter}
onChange={(e) => {
setFilter(e.target.value);
setActiveIndex(-1);
}}
onKeyDown={handleKeyDown}
placeholder={t('poiPane.searchCategories')}
className="w-full rounded border border-warm-200 bg-warm-50 py-1 pl-7 pr-2 text-xs text-navy-950 outline-none placeholder:text-warm-400 focus:ring-1 focus:ring-teal-400 dark:border-warm-600 dark:bg-warm-900 dark:text-warm-200 dark:placeholder:text-warm-500"
/>
</div>
</div>
<div ref={listRef} className="min-h-0 flex-1 overflow-y-auto">
{filtered.length === 0 ? (
<div className="px-2 py-3 text-center text-xs text-warm-400 dark:text-warm-500">
{t('filters.noMatchingFeatures')}
</div>
) : (
filtered.map((option, idx) => {
const isSelected = option.name === value;
const isActive = idx === activeIndex;
return (
<button
key={option.name}
type="button"
className={`flex w-full cursor-pointer items-center gap-1.5 px-2 py-1.5 text-left text-sm ${
isActive
? 'bg-teal-50 dark:bg-teal-900/30'
: isSelected
? 'bg-warm-50 dark:bg-warm-700/50'
: 'hover:bg-warm-50 dark:hover:bg-warm-700'
}`}
onMouseEnter={() => setActiveIndex(idx)}
onMouseDown={(e) => {
e.preventDefault();
handleSelect(option);
}}
>
{optionIcon(option, 'h-3.5 w-3.5 shrink-0 text-teal-600 dark:text-teal-400')}
<span
className={`truncate ${
isSelected
? 'font-medium text-navy-950 dark:text-warm-100'
: 'text-warm-700 dark:text-warm-200'
}`}
>
{optionLabel(option)}
</span>
</button>
);
})
)}
</div>
</div>
);
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => (open ? setOpen(false) : handleOpen())}
className="flex w-full items-center gap-1.5 rounded-md border border-warm-200 bg-warm-50 px-2 py-1.5 pr-8 text-left text-sm font-medium text-navy-950 shadow-inner outline-none transition-colors hover:bg-white focus:border-teal-400 focus:ring-2 focus:ring-teal-200 dark:border-warm-700 dark:bg-navy-900 dark:text-warm-100 dark:hover:bg-navy-800 dark:focus:ring-teal-900/50"
>
{selected &&
optionIcon(selected, 'h-4 w-4 shrink-0 text-teal-600 dark:text-teal-400')}
<span className="min-w-0 flex-1 truncate">
{selected ? optionLabel(selected) : ''}
</span>
<ChevronIcon
direction={open ? 'up' : 'down'}
className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-warm-400 dark:text-warm-500"
/>
</button>
{open && createPortal(dropdown, document.body)}
</div>
);
}

View file

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { Slider } from '../../ui/Slider';
import { FeatureActions } from '../../ui/FeatureIcons';
import { FeatureLabel } from '../../ui/FeatureLabel';
@ -47,6 +48,7 @@ export function SchoolFilterCard({
onShowInfo: (feature: FeatureMeta) => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const config = getSchoolFilterConfig(schoolFeature.name);
const schoolMeta = getSchoolFilterMeta(features);
const backendFeature = config
@ -124,9 +126,9 @@ export function SchoolFilterCard({
<div className="space-y-1.5">
<div>
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
School type
{t('filters.schoolType')}
</div>
<div className={segmentedClass} role="radiogroup" aria-label="School type">
<div className={segmentedClass} role="radiogroup" aria-label={t('filters.schoolType')}>
<button
type="button"
role="radio"
@ -134,7 +136,7 @@ export function SchoolFilterCard({
onClick={() => replaceSchoolFeature({ phase: 'primary' })}
className={optionClass(config.phase === 'primary')}
>
Primary
{t('filters.primary')}
</button>
<button
type="button"
@ -143,15 +145,15 @@ export function SchoolFilterCard({
onClick={() => replaceSchoolFeature({ phase: 'secondary' })}
className={optionClass(config.phase === 'secondary')}
>
Secondary
{t('filters.secondary')}
</button>
</div>
</div>
<div>
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
Rating
{t('filters.rating')}
</div>
<div className={segmentedClass} role="radiogroup" aria-label="School rating">
<div className={segmentedClass} role="radiogroup" aria-label={t('filters.schoolRating')}>
<button
type="button"
role="radio"
@ -159,7 +161,7 @@ export function SchoolFilterCard({
onClick={() => replaceSchoolFeature({ rating: 'good' })}
className={optionClass(config.rating === 'good')}
>
Good+
{t('filters.goodPlus')}
</button>
<button
type="button"
@ -168,15 +170,19 @@ export function SchoolFilterCard({
onClick={() => replaceSchoolFeature({ rating: 'outstanding' })}
className={optionClass(config.rating === 'outstanding')}
>
Outstanding
{t('filters.outstanding')}
</button>
</div>
</div>
<div>
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
Distance
{t('filters.distance')}
</div>
<div className={segmentedClass} role="radiogroup" aria-label="School distance">
<div
className={segmentedClass}
role="radiogroup"
aria-label={t('filters.schoolDistance')}
>
<button
type="button"
role="radio"
@ -226,7 +232,7 @@ export function SchoolFilterCard({
/>
{filterImpact != null && filterImpact > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
+{formatNumber(filterImpact)} without this filter
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
</p>
)}
</div>

View file

@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import type React from 'react';
import { useTranslation } from 'react-i18next';
import type { FeatureMeta } from '../../../types';
import { formatFilterValue, parseInputValue } from '../../../lib/format';
@ -92,22 +93,23 @@ export function SliderLabels({
feature?: FeatureMeta;
onValueChange?: (v: [number, number]) => void;
}) {
const { t } = useTranslation();
const range = max - min || 1;
const leftPct = Math.max(0, Math.min(100, ((value[0] - min) / range) * 100));
const rightPct = Math.max(0, Math.min(100, ((value[1] - min) / range) * 100));
const labels = displayValues || value;
const labelFormat = feature?.suffix === '%' ? { raw, suffix: feature.suffix } : raw;
const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], labelFormat);
const maxLabel = isAtMax ? 'max' : formatFilterValue(labels[1], labelFormat);
const minLabel = isAtMin ? t('common.min') : formatFilterValue(labels[0], labelFormat);
const maxLabel = isAtMax ? t('common.max') : formatFilterValue(labels[1], labelFormat);
// Smoothly spread labels apart as thumbs get close to prevent overlap.
// t=1 (centered) when far apart, t=0 (split) when touching.
// gapRatio=1 (centered) when far apart, gapRatio=0 (split) when touching.
const SPREAD_THRESHOLD = 20; // percentage gap below which labels start separating
const gapPct = rightPct - leftPct;
const t = Math.min(1, Math.max(0, gapPct / SPREAD_THRESHOLD));
const leftTranslate = `translateX(${-100 + t * 50}%)`;
const rightTranslate = `translateX(${-t * 50}%)`;
const gapRatio = Math.min(1, Math.max(0, gapPct / SPREAD_THRESHOLD));
const leftTranslate = `translateX(${-100 + gapRatio * 50}%)`;
const rightTranslate = `translateX(${-gapRatio * 50}%)`;
if (feature && onValueChange) {
return (

View file

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { ts } from '../../../i18n/server';
import { Slider } from '../../ui/Slider';
import { ChevronIcon } from '../../ui/icons';
@ -51,6 +52,7 @@ export function SpecificCrimeFilterCard({
onShowInfo: (feature: FeatureMeta) => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const specificCrimeMeta = getSpecificCrimeFilterMeta(features);
const crimeOptions = SPECIFIC_CRIME_FEATURE_NAMES.map((name) =>
features.find((feature) => feature.name === name)
@ -145,7 +147,7 @@ export function SpecificCrimeFilterCard({
<div>
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
Crime type
{t('filters.crimeType')}
</label>
<div className="relative">
<select
@ -213,7 +215,7 @@ export function SpecificCrimeFilterCard({
/>
{filterImpact != null && filterImpact > 0 && (
<p className="-mt-1 ml-2.5 text-[10px] text-warm-400 dark:text-warm-500">
+{formatNumber(filterImpact)} without this filter
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
</p>
)}
</div>

View file

@ -1,4 +1,5 @@
import { Suspense, type MutableRefObject, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import type { FeatureFilters, FeatureMeta, POI, PostcodeGeometry, ViewState } from '../../../types';
import type { useMapData } from '../../../hooks/useMapData';
@ -106,6 +107,8 @@ export function DesktopMapPage({
toasts,
upgradeModal,
}: DesktopMapPageProps) {
const { t } = useTranslation();
return (
<div className="flex-1 flex overflow-hidden relative">
<LoadingOverlay show={initialLoading} />
@ -124,7 +127,7 @@ export function DesktopMapPage({
showProgress: true,
skipScroll: true,
}}
locale={{ last: 'Finish' }}
locale={{ last: t('common.finish') }}
/>
</Suspense>
)}
@ -194,7 +197,7 @@ export function DesktopMapPage({
className={`absolute bottom-4 right-4 z-10 px-3 py-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 flex items-center gap-2 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
>
<MapPinIcon className="w-5 h-5" />
<span className="text-sm font-medium">Points of interest</span>
<span className="text-sm font-medium">{t('poiPane.pointsOfInterest')}</span>
</button>
{poiPaneOpen && (
<div className="absolute bottom-14 right-4 z-10 flex h-[60vh] min-h-0 w-80 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">

View file

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
interface LoadingOverlayProps {
@ -5,6 +6,8 @@ interface LoadingOverlayProps {
}
export function LoadingOverlay({ show }: LoadingOverlayProps) {
const { t } = useTranslation();
if (!show) return null;
return (
@ -12,7 +15,7 @@ export function LoadingOverlay({ show }: LoadingOverlayProps) {
<div className="flex flex-col items-center gap-4">
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">
Connecting to server...
{t('common.connectingToServer')}
</p>
</div>
</div>

View file

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import type { ExportNotice } from './types';
import { BookmarkIcon } from '../../ui/icons/BookmarkIcon';
import { CheckIcon } from '../../ui/icons/CheckIcon';
@ -11,23 +12,25 @@ interface BookmarkToastProps {
}
export function BookmarkToast({ show, onViewSaved, onDismissForever }: BookmarkToastProps) {
const { t } = useTranslation();
if (!show) return null;
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-3 px-4 py-3 rounded-lg bg-navy-900 text-white text-sm shadow-lg animate-fade-in">
<BookmarkIcon className="w-4 h-4 text-teal-400 shrink-0" filled />
<span>Property saved!</span>
<span>{t('toasts.propertySaved')}</span>
<button
onClick={onViewSaved}
className="px-3 py-1 rounded bg-teal-600 hover:bg-teal-500 text-white text-xs font-medium whitespace-nowrap"
>
View saved
{t('toasts.viewSaved')}
</button>
<button
onClick={onDismissForever}
className="text-warm-400 hover:text-warm-200 text-xs whitespace-nowrap"
>
Don&apos;t show again
{t('toasts.dontShowAgain')}
</button>
</div>
);

View file

@ -6,6 +6,7 @@ import type { HexagonLocation } from '../../../lib/external-search';
import type { useMapData } from '../../../hooks/useMapData';
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import { getSpecificCrimeFeatureName } from '../../../lib/crime-filter';
import { getElectionVoteShareFeatureName } from '../../../lib/election-filter';
import { getEthnicityFeatureName } from '../../../lib/ethnicity-filter';
import { getPoiDistanceFeatureName } from '../../../lib/poi-distance-filter';
import { getSchoolBackendFeatureName } from '../../../lib/school-filter';
@ -22,6 +23,7 @@ export function getMapPageBackendFeatureName(featureName: string): string {
return (
getSchoolBackendFeatureName(featureName) ??
getSpecificCrimeFeatureName(featureName) ??
getElectionVoteShareFeatureName(featureName) ??
getEthnicityFeatureName(featureName) ??
getPoiDistanceFeatureName(featureName) ??
featureName
@ -57,6 +59,7 @@ export function useMobileDensityRange(mapData: MapData): [number, number] {
let max = -Infinity;
for (const item of items) {
const count = 'count' in item ? item.count : item.properties.count;
if (count <= 0) continue;
if (count < min) min = count;
if (count > max) max = count;
}

View file

@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
import type { Destination } from '../../hooks/useTravelDestinations';
import { useDropdownPosition } from '../../hooks/useDropdownPosition';
import { dropdownPositionStyle, useDropdownPosition } from '../../hooks/useDropdownPosition';
import { MapPinIcon } from './icons/MapPinIcon';
import { ChevronIcon } from './icons/ChevronIcon';
import { CloseIcon } from './icons/CloseIcon';
@ -102,28 +102,16 @@ export function DestinationDropdown({
const handleOpen = useCallback(() => {
setOpen(true);
setActiveIndex(-1);
// Focus input after opening
requestAnimationFrame(() => inputRef.current?.focus());
requestAnimationFrame(() => inputRef.current?.focus({ preventScroll: true }));
}, []);
const dropdown = open && (
<div
ref={dropdownRef}
className="bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 overflow-hidden"
style={
pos
? {
position: 'fixed',
top: pos.top,
left: pos.left,
width: pos.width,
zIndex: 50,
}
: undefined
}
className="flex flex-col bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 overflow-hidden"
style={pos ? dropdownPositionStyle(pos) : undefined}
>
{/* Filter input */}
<div className="p-1.5 border-b border-warm-100 dark:border-warm-700">
<div className="shrink-0 p-1.5 border-b border-warm-100 dark:border-warm-700">
<input
ref={inputRef}
type="text"
@ -138,8 +126,7 @@ export function DestinationDropdown({
/>
</div>
{/* Results list */}
<div ref={listRef} className="max-h-48 overflow-y-auto">
<div ref={listRef} className="min-h-0 flex-1 overflow-y-auto">
{filtered.length === 0 ? (
<div className="px-2 py-2 text-xs text-warm-400 dark:text-warm-500 text-center">
{loading ? t('common.loading') : t('travel.noDestinations')}

View file

@ -7,7 +7,7 @@ import {
} from '../../i18n';
export default function LanguageDropdown() {
const { i18n } = useTranslation();
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@ -34,7 +34,7 @@ export default function LanguageDropdown() {
<button
onClick={() => setOpen((v) => !v)}
className="flex cursor-pointer items-center gap-1 px-2 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
aria-label="Language"
aria-label={t('common.language')}
>
<span className="text-base leading-none">{current.flag}</span>
<svg

View file

@ -1,3 +1,5 @@
import { useTranslation } from 'react-i18next';
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
@ -5,18 +7,15 @@ interface SearchInputProps {
className?: string;
}
export function SearchInput({
value,
onChange,
placeholder = 'Search...',
className = '',
}: SearchInputProps) {
export function SearchInput({ value, onChange, placeholder, className = '' }: SearchInputProps) {
const { t } = useTranslation();
return (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
placeholder={placeholder ?? t('common.search')}
className={`w-full px-2 py-1 text-sm border rounded bg-white dark:bg-navy-800 dark:text-warm-200 border-warm-200 dark:border-navy-700 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-1 focus:ring-teal-400 ${className}`}
/>
);

View file

@ -159,9 +159,10 @@ export function useDeckLayers({
continue;
}
const c = d.count as number;
total += c;
if (c <= 0) continue;
if (c < min) min = c;
if (c > max) max = c;
total += c;
}
if (min === Infinity) return { min: 0, max: 1, total: 0 };
if (min === max) return { min, max: min + 1, total };
@ -188,9 +189,10 @@ export function useDeckLayers({
continue;
}
const c = d.properties.count;
total += c;
if (c <= 0) continue;
if (c < min) min = c;
if (c > max) max = c;
total += c;
}
if (min === Infinity) return { min: 0, max: 1, total: 0 };
if (min === max) return { min, max: min + 1, total };
@ -296,6 +298,9 @@ export function useDeckLayers({
data,
getHexagon: (d) => d.h3,
getFillColor: (d) => {
if ((d.count as number) <= 0) {
return [0, 0, 0, 0] as [number, number, number, number];
}
const dark = isDarkRef.current;
const vf = viewFeatureRef.current;
const clr = colorRangeRef.current;
@ -400,6 +405,9 @@ export function useDeckLayers({
data: postcodeData as PostcodeFeature[],
getFillColor: (f) => {
const d = f.properties;
if (d.count <= 0) {
return [0, 0, 0, 0] as [number, number, number, number];
}
const dark = isDarkRef.current;
const vf = viewFeatureRef.current;
const clr = colorRangeRef.current;
@ -477,6 +485,7 @@ export function useDeckLayers({
const dark = isDarkRef.current;
if (pc === hoveredPostcodeRef.current)
return [29, 228, 195, 200] as [number, number, number, number];
if (f.properties.count <= 0) return [0, 0, 0, 0] as [number, number, number, number];
return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [
number,
number,
@ -487,6 +496,7 @@ export function useDeckLayers({
getLineWidth: (f) => {
const pc = f.properties.postcode;
if (pc === hoveredPostcodeRef.current) return 2;
if (f.properties.count <= 0) return 0;
return 1;
},
lineWidthUnits: 'pixels',
@ -504,11 +514,16 @@ export function useDeckLayers({
});
}, [postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]);
const labeledPostcodeData = useMemo(
() => postcodeData.filter((feature) => feature.properties.count > 0),
[postcodeData]
);
const postcodeLabelsLayer = useMemo(
() =>
new TextLayer<PostcodeFeature>({
id: 'postcode-labels',
data: postcodeData,
data: labeledPostcodeData,
getPosition: (f) => f.properties.centroid,
getText: (f) => f.properties.postcode,
getSize: 12,
@ -525,7 +540,7 @@ export function useDeckLayers({
billboard: false,
pickable: false,
}),
[postcodeData, theme]
[labeledPostcodeData, theme]
);
// Marching ants highlight layer for selected hexagon or postcode

View file

@ -1,21 +1,88 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import type React from 'react';
export type DropdownPosition = {
left: number;
width: number;
maxHeight: number;
placement: 'above' | 'below';
top?: number;
bottom?: number;
};
const GAP = 4;
const SAFETY = 8;
const MIN_BELOW = 200;
const MIN_HEIGHT = 120;
const MAX_VIEWPORT_FRACTION = 0.6;
function compute(anchor: HTMLElement): DropdownPosition {
const rect = anchor.getBoundingClientRect();
const vv = window.visualViewport;
const visibleTop = vv?.offsetTop ?? 0;
const visibleHeight = vv?.height ?? window.innerHeight;
const visibleBottom = visibleTop + visibleHeight;
const layoutHeight = window.innerHeight;
const cap = Math.max(MIN_HEIGHT, visibleHeight * MAX_VIEWPORT_FRACTION);
const spaceBelow = visibleBottom - rect.bottom - GAP - SAFETY;
const spaceAbove = rect.top - visibleTop - GAP - SAFETY;
const placeBelow = spaceBelow >= MIN_BELOW || spaceBelow >= spaceAbove;
if (placeBelow) {
// Pin top to the visible viewport so the panel can't slide off when the
// page auto-scrolls to reveal a focused input.
const top = Math.max(rect.bottom + GAP, visibleTop + SAFETY);
const available = visibleBottom - top - SAFETY;
return {
placement: 'below',
top,
left: rect.left,
width: rect.width,
maxHeight: Math.min(Math.max(available, MIN_HEIGHT), cap),
};
}
// Pin the bottom edge to the visible viewport for the same reason.
const bottomY = Math.min(rect.top - GAP, visibleBottom - SAFETY);
const available = bottomY - visibleTop - SAFETY;
return {
placement: 'above',
bottom: layoutHeight - bottomY,
left: rect.left,
width: rect.width,
maxHeight: Math.min(Math.max(available, MIN_HEIGHT), cap),
};
}
export function dropdownPositionStyle(pos: DropdownPosition): React.CSSProperties {
return {
position: 'fixed',
left: pos.left,
width: pos.width,
maxHeight: pos.maxHeight,
...(pos.placement === 'below' ? { top: pos.top } : { bottom: pos.bottom }),
zIndex: 50,
};
}
export function useDropdownPosition(anchorRef: React.RefObject<HTMLElement | null>, open: boolean) {
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);
const [pos, setPos] = useState<DropdownPosition | null>(null);
const posRef = useRef(pos);
posRef.current = pos;
const update = useCallback(() => {
if (!anchorRef.current) return;
const rect = anchorRef.current.getBoundingClientRect();
const next = { top: rect.bottom + 4, left: rect.left, width: rect.width };
const next = compute(anchorRef.current);
const prev = posRef.current;
if (
prev &&
Math.abs(prev.top - next.top) < 0.5 &&
prev.placement === next.placement &&
Math.abs((prev.top ?? 0) - (next.top ?? 0)) < 0.5 &&
Math.abs((prev.bottom ?? 0) - (next.bottom ?? 0)) < 0.5 &&
Math.abs(prev.left - next.left) < 0.5 &&
Math.abs(prev.width - next.width) < 0.5
Math.abs(prev.width - next.width) < 0.5 &&
Math.abs(prev.maxHeight - next.maxHeight) < 0.5
) {
return;
}

View file

@ -17,6 +17,14 @@ import {
getSpecificCrimeFilterKeyId,
normalizeSpecificCrimeFilters,
} from '../lib/crime-filter';
import {
ELECTION_VOTE_SHARE_FILTER_NAME,
createElectionVoteShareFilterKey,
getDefaultElectionVoteShareFeatureName,
getElectionVoteShareFeatureName,
getElectionVoteShareFilterKeyId,
normalizeElectionVoteShareFilters,
} from '../lib/election-filter';
import {
ETHNICITIES_FILTER_NAME,
createEthnicityFilterKey,
@ -42,7 +50,11 @@ interface UseFiltersOptions {
function normalizeFilters(filters: FeatureFilters): FeatureFilters {
return normalizePoiDistanceFilters(
normalizeEthnicityFilters(normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters)))
normalizeEthnicityFilters(
normalizeElectionVoteShareFilters(
normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters))
)
)
);
}
@ -50,6 +62,7 @@ function getBackendFeatureName(name: string): string {
return (
getSchoolBackendFeatureName(name) ??
getSpecificCrimeFeatureName(name) ??
getElectionVoteShareFeatureName(name) ??
getEthnicityFeatureName(name) ??
getPoiDistanceFeatureName(name) ??
name
@ -110,6 +123,9 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const specificCrimeFilterIdRef = useRef(
getNextNumericKeyId(initialFiltersRef.current!, getSpecificCrimeFilterKeyId)
);
const electionVoteShareFilterIdRef = useRef(
getNextNumericKeyId(initialFiltersRef.current!, getElectionVoteShareFilterKeyId)
);
const ethnicityFilterIdRef = useRef(
getNextNumericKeyId(initialFiltersRef.current!, getEthnicityFilterKeyId)
);
@ -151,6 +167,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
if (
name !== SCHOOL_FILTER_NAME &&
name !== SPECIFIC_CRIMES_FILTER_NAME &&
name !== ELECTION_VOTE_SHARE_FILTER_NAME &&
name !== ETHNICITIES_FILTER_NAME &&
!POI_FILTER_NAMES.includes(name as PoiFilterName) &&
!meta
@ -198,6 +215,24 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
],
};
}
if (name === ELECTION_VOTE_SHARE_FILTER_NAME) {
const defaultVoteShareFeatureName = getDefaultElectionVoteShareFeatureName(features);
const defaultVoteShareFeature = defaultVoteShareFeatureName
? features.find((feature) => feature.name === defaultVoteShareFeatureName)
: undefined;
if (!defaultVoteShareFeatureName) return prev;
return {
...prev,
[createElectionVoteShareFilterKey(
defaultVoteShareFeatureName,
electionVoteShareFilterIdRef.current++
)]: [
defaultVoteShareFeature?.histogram?.min ?? defaultVoteShareFeature?.min ?? 0,
defaultVoteShareFeature?.histogram?.max ?? defaultVoteShareFeature?.max ?? 100,
],
};
}
if (name === ETHNICITIES_FILTER_NAME) {
const defaultEthnicityFeatureName = getDefaultEthnicityFeatureName(features);
const defaultEthnicityFeature = defaultEthnicityFeatureName
@ -326,6 +361,23 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
if (replaced) return normalizeFilters(next);
}
const electionVoteShareKeyId = getElectionVoteShareFilterKeyId(name);
if (electionVoteShareKeyId != null) {
let replaced = false;
const next: FeatureFilters = {};
for (const [existingName, existingValue] of Object.entries(prev)) {
if (getElectionVoteShareFilterKeyId(existingName) === electionVoteShareKeyId) {
if (!replaced) {
next[name] = value;
replaced = true;
}
continue;
}
next[existingName] = existingValue;
}
if (replaced) return normalizeFilters(next);
}
const poiDistanceKeyId = getPoiDistanceFilterKeyId(name);
if (poiDistanceKeyId != null) {
let replaced = false;

View file

@ -0,0 +1,134 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useHexagonSelection } from './useHexagonSelection';
import type { FeatureMeta, HexagonStatsResponse, PostcodeGeometry } from '../types';
vi.mock('../lib/pocketbase', () => ({
default: { authStore: { isValid: false, token: '' } },
}));
vi.mock('../lib/analytics', () => ({
trackEvent: vi.fn(),
}));
const postcodeGeometry: PostcodeGeometry = {
type: 'Polygon',
coordinates: [
[
[-0.12, 51.5],
[-0.11, 51.5],
[-0.11, 51.51],
[-0.12, 51.51],
[-0.12, 51.5],
],
],
};
function stats(count: number): HexagonStatsResponse {
return {
count,
numeric_features: [],
enum_features: [],
central_postcode: 'SW1A 1AA',
};
}
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
describe('useHexagonSelection', () => {
const requests: string[] = [];
const features: FeatureMeta[] = [{ name: 'Price', type: 'numeric', min: 0, max: 100 }];
beforeEach(() => {
requests.length = 0;
vi.stubGlobal(
'fetch',
vi.fn((input: string | URL | Request) => {
const url = new URL(String(input), 'http://localhost');
requests.push(`${url.pathname}${url.search}`);
if (url.pathname === '/api/postcode-stats') {
return Promise.resolve(jsonResponse(stats(url.searchParams.has('filters') ? 0 : 4)));
}
if (url.pathname === '/api/hexagon-stats') {
return Promise.resolve(jsonResponse(stats(12)));
}
return Promise.resolve(new Response(null, { status: 404 }));
})
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('keeps a postcode search selected when filters exclude its properties', async () => {
const { result } = renderHook(() =>
useHexagonSelection({
filters: { Price: [0, 50] },
features,
hexagonData: [],
resolution: 9,
usePostcodeView: true,
travelTimeEntries: [],
})
);
act(() => {
result.current.handleLocationSearch('SW1A 1AA', postcodeGeometry, 51.505, -0.115);
});
await waitFor(() => {
expect(result.current.selectedHexagon).toEqual({
id: 'SW1A 1AA',
type: 'postcode',
resolution: 9,
});
});
expect(result.current.selectedPostcodeGeometry).toBe(postcodeGeometry);
expect(result.current.areaStats?.count).toBe(0);
expect(result.current.unfilteredAreaCount).toBe(4);
expect(requests.some((url) => url.startsWith('/api/hexagon-stats'))).toBe(false);
});
it('keeps a postcode search selected when stats are based on all properties', async () => {
const { result } = renderHook(() =>
useHexagonSelection({
filters: { Price: [0, 50] },
features,
hexagonData: [],
resolution: 9,
usePostcodeView: true,
travelTimeEntries: [],
})
);
act(() => {
result.current.setAreaStatsUseFilters(false);
});
act(() => {
result.current.handleLocationSearch('SW1A 1AA', postcodeGeometry, 51.505, -0.115);
});
await waitFor(() => {
expect(result.current.selectedHexagon).toEqual({
id: 'SW1A 1AA',
type: 'postcode',
resolution: 9,
});
});
expect(result.current.areaStats?.count).toBe(4);
expect(result.current.unfilteredAreaCount).toBeNull();
expect(requests.some((url) => url.startsWith('/api/hexagon-stats'))).toBe(false);
});
});

View file

@ -11,7 +11,7 @@ import type {
HexagonStatsResponse,
} from '../types';
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api';
import { findOverlappingMatchingHexagon } from '../lib/h3-selection';
import { findOverlappingSelectableHexagon } from '../lib/h3-selection';
import { SMALLEST_VISIBLE_HEXAGON_RESOLUTION } from '../lib/consts';
import type { TravelTimeEntry } from './useTravelTime';
@ -66,6 +66,7 @@ export function useHexagonSelection({
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
const [rightPaneTab, setRightPaneTab] = useState<'properties' | 'area'>('area');
const [areaStatsUseFilters, setAreaStatsUseFilters] = useState(true);
const [selectedPostcodeGeometry, setSelectedPostcodeGeometry] = useState<PostcodeGeometry | null>(
null
);
@ -149,14 +150,23 @@ export function useHexagonSelection({
);
const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]);
const selectionQueryKey = useMemo(
() => [filterStr, travelParam, shareCode ?? ''].join('|'),
[filterStr, shareCode, travelParam]
const hasStatsFilters = filterStr.length > 0 || travelParam.length > 0;
const journeyKey = journeyDest ? `${journeyDest.mode}:${journeyDest.slug}` : '';
const areaStatsQueryKey = useMemo(
() =>
[
areaStatsUseFilters ? 'filtered' : 'all',
areaStatsUseFilters ? filterStr : '',
areaStatsUseFilters ? travelParam : '',
journeyKey,
shareCode ?? '',
].join('|'),
[areaStatsUseFilters, filterStr, journeyKey, shareCode, travelParam]
);
const fetchUnfilteredAreaCount = useCallback(
async (selection: SelectedHexagon, signal?: AbortSignal) => {
if (!filterStr) {
if (!hasStatsFilters) {
setUnfilteredAreaCount(null);
return;
}
@ -167,12 +177,17 @@ export function useHexagonSelection({
: await fetchHexagonStats(selection.id, selection.resolution, signal, undefined, false);
setUnfilteredAreaCount(stats.count);
},
[filterStr, fetchHexagonStats, fetchPostcodeStats]
[fetchHexagonStats, fetchPostcodeStats, hasStatsFilters]
);
const refreshUnfilteredAreaCount = useCallback(
(selection: SelectedHexagon, filteredCount: number, signal?: AbortSignal) => {
if (!filterStr || filteredCount > 0) {
(
selection: SelectedHexagon,
statsCount: number,
includeFilters: boolean,
signal?: AbortSignal
) => {
if (!includeFilters || !hasStatsFilters || statsCount > 0) {
setUnfilteredAreaCount(null);
return;
}
@ -181,7 +196,7 @@ export function useHexagonSelection({
logNonAbortError('Failed to fetch unfiltered area count', error)
);
},
[filterStr, fetchUnfilteredAreaCount]
[fetchUnfilteredAreaCount, hasStatsFilters]
);
const fetchPostcodeLookup = useCallback(async (postcode: string, signal?: AbortSignal) => {
@ -311,11 +326,11 @@ export function useHexagonSelection({
if (isPostcode) {
setLoadingAreaStats(true);
fetchPostcodeStats(id)
fetchPostcodeStats(id, undefined, areaStatsUseFilters)
.then((stats) => {
if (!isCurrentAreaRequest(requestId)) return;
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
})
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
.finally(() => {
@ -323,11 +338,11 @@ export function useHexagonSelection({
});
} else {
setLoadingAreaStats(true);
fetchHexagonStats(id, resolution)
fetchHexagonStats(id, resolution, undefined, undefined, areaStatsUseFilters)
.then((stats) => {
if (!isCurrentAreaRequest(requestId)) return;
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
})
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
.finally(() => {
@ -339,6 +354,7 @@ export function useHexagonSelection({
[
selectedHexagon,
resolution,
areaStatsUseFilters,
fetchHexagonStats,
fetchPostcodeStats,
invalidateAreaRequests,
@ -423,7 +439,7 @@ export function useHexagonSelection({
!selection.lockedResolution &&
selection.resolution < resolution;
const overlappingHexagon = zoomingIntoHexagon
? findOverlappingMatchingHexagon(selection.id, hexagonData, resolution)
? findOverlappingSelectableHexagon(selection.id, hexagonData, resolution)
: null;
if (zoomingIntoHexagon && !overlappingHexagon) return;
@ -451,12 +467,22 @@ export function useHexagonSelection({
const lookup = await fetchPostcodeLookup(areaStats.central_postcode, controller.signal);
nextSelection = { id: lookup.postcode, type: 'postcode', resolution };
nextGeometry = lookup.geometry;
nextStats = await fetchPostcodeStats(lookup.postcode, controller.signal);
nextStats = await fetchPostcodeStats(
lookup.postcode,
controller.signal,
areaStatsUseFilters
);
} else if (!usePostcodeView && selection.type === 'postcode') {
const lookup = await fetchPostcodeLookup(selection.id, controller.signal);
const nextId = latLngToCell(lookup.latitude, lookup.longitude, resolution);
nextSelection = { id: nextId, type: 'hexagon', resolution };
nextStats = await fetchHexagonStats(nextId, resolution, controller.signal);
nextStats = await fetchHexagonStats(
nextId,
resolution,
controller.signal,
undefined,
areaStatsUseFilters
);
} else if (
!usePostcodeView &&
selection.type === 'hexagon' &&
@ -469,7 +495,13 @@ export function useHexagonSelection({
: overlappingHexagon?.h3;
if (!nextId) return;
nextSelection = { id: nextId, type: 'hexagon', resolution };
nextStats = await fetchHexagonStats(nextId, resolution, controller.signal);
nextStats = await fetchHexagonStats(
nextId,
resolution,
controller.signal,
undefined,
areaStatsUseFilters
);
} else {
return;
}
@ -478,7 +510,12 @@ export function useHexagonSelection({
setSelectedHexagon(nextSelection);
setSelectedPostcodeGeometry(nextGeometry);
setAreaStats(nextStats);
refreshUnfilteredAreaCount(nextSelection, nextStats.count, controller.signal);
refreshUnfilteredAreaCount(
nextSelection,
nextStats.count,
areaStatsUseFilters,
controller.signal
);
refreshProperties(nextSelection);
}
@ -505,6 +542,7 @@ export function useHexagonSelection({
hexagonData,
resolution,
usePostcodeView,
areaStatsUseFilters,
areaStats?.central_postcode,
fetchHexagonStats,
fetchPostcodeStats,
@ -519,11 +557,11 @@ export function useHexagonSelection({
]);
// Re-fetch stats when filters or travel constraints change while an area is selected
const prevSelectionQueryKey = useRef(selectionQueryKey);
const prevAreaStatsQueryKey = useRef(areaStatsQueryKey);
useEffect(() => {
if (prevSelectionQueryKey.current === selectionQueryKey) return;
prevSelectionQueryKey.current = selectionQueryKey;
if (prevAreaStatsQueryKey.current === areaStatsQueryKey) return;
prevAreaStatsQueryKey.current = areaStatsQueryKey;
if (!selectedHexagon) return;
@ -538,16 +576,22 @@ export function useHexagonSelection({
const fetchStats =
selectedHexagon.type === 'postcode'
? fetchPostcodeStats(selectedHexagon.id)
: fetchHexagonStats(selectedHexagon.id, selectedHexagon.resolution);
? fetchPostcodeStats(selectedHexagon.id, undefined, areaStatsUseFilters)
: fetchHexagonStats(
selectedHexagon.id,
selectedHexagon.resolution,
undefined,
undefined,
areaStatsUseFilters
);
fetchStats
.then((stats) => {
if (cancelled || !isCurrentAreaRequest(requestId)) return;
setAreaStats(stats);
refreshUnfilteredAreaCount(selectedHexagon, stats.count);
refreshUnfilteredAreaCount(selectedHexagon, stats.count, areaStatsUseFilters);
// Re-fetch properties if the properties tab is active and the filtered area still has matches.
if (rightPaneTab === 'properties' && stats.count > 0) {
if (areaStatsUseFilters && rightPaneTab === 'properties' && stats.count > 0) {
if (selectedHexagon.type === 'postcode') {
fetchPostcodeProperties(selectedHexagon.id, 0);
} else {
@ -567,10 +611,11 @@ export function useHexagonSelection({
cancelled = true;
};
}, [
selectionQueryKey,
areaStatsQueryKey,
selectedHexagon,
fetchHexagonStats,
fetchPostcodeStats,
areaStatsUseFilters,
rightPaneTab,
fetchHexagonProperties,
fetchPostcodeProperties,
@ -598,8 +643,9 @@ export function useHexagonSelection({
setRightPaneTab(openProperties ? 'properties' : 'area');
setLoadingAreaStats(true);
// First try the postcode; if it has no properties, fall back to hexagons
fetchPostcodeStats(postcode)
// First try the postcode; if it only has no matches because of active filters,
// keep the searched postcode selected instead of widening to nearby hexagons.
fetchPostcodeStats(postcode, undefined, areaStatsUseFilters)
.then(async (stats) => {
if (!isCurrentAreaRequest(requestId)) return;
if (stats.count > 0) {
@ -607,13 +653,27 @@ export function useHexagonSelection({
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(geometry);
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
if (openProperties) {
fetchPostcodeProperties(postcode, 0, focusAddress);
}
return;
}
if (areaStatsUseFilters && hasStatsFilters) {
const unfilteredStats = await fetchPostcodeStats(postcode, undefined, false);
if (!isCurrentAreaRequest(requestId)) return;
if (unfilteredStats.count > 0) {
const selection = { id: postcode, type: 'postcode' as const, resolution };
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(geometry);
setAreaStats(stats);
setUnfilteredAreaCount(unfilteredStats.count);
setRightPaneTab(openProperties ? 'properties' : 'area');
return;
}
}
// No properties in this postcode — fall back to hexagons
if (lat == null || lng == null) {
// No coordinates available, show empty postcode anyway
@ -621,7 +681,7 @@ export function useHexagonSelection({
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(geometry);
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
setRightPaneTab('area');
return;
}
@ -630,14 +690,20 @@ export function useHexagonSelection({
const resolutions = [9, 8, 7, 6, 5];
for (const res of resolutions) {
const h3 = latLngToCell(lat, lng, res);
const hexStats = await fetchHexagonStats(h3, res);
const hexStats = await fetchHexagonStats(
h3,
res,
undefined,
undefined,
areaStatsUseFilters
);
if (!isCurrentAreaRequest(requestId)) return;
if (hexStats.count > 1) {
const selection = { id: h3, type: 'hexagon' as const, resolution: res };
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(null);
setAreaStats(hexStats);
refreshUnfilteredAreaCount(selection, hexStats.count);
refreshUnfilteredAreaCount(selection, hexStats.count, areaStatsUseFilters);
setRightPaneTab('area');
return;
}
@ -645,13 +711,19 @@ export function useHexagonSelection({
// Even the coarsest hexagon has ≤1 property — show whatever the finest has
const h3 = latLngToCell(lat, lng, 9);
const fallbackStats = await fetchHexagonStats(h3, 9);
const fallbackStats = await fetchHexagonStats(
h3,
9,
undefined,
undefined,
areaStatsUseFilters
);
if (!isCurrentAreaRequest(requestId)) return;
const selection = { id: h3, type: 'hexagon' as const, resolution: 9 };
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(null);
setAreaStats(fallbackStats);
refreshUnfilteredAreaCount(selection, fallbackStats.count);
refreshUnfilteredAreaCount(selection, fallbackStats.count, areaStatsUseFilters);
setRightPaneTab('area');
})
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
@ -661,6 +733,8 @@ export function useHexagonSelection({
},
[
resolution,
areaStatsUseFilters,
hasStatsFilters,
fetchPostcodeStats,
fetchHexagonStats,
fetchPostcodeProperties,
@ -693,11 +767,17 @@ export function useHexagonSelection({
setRightPaneTab('area');
setLoadingAreaStats(true);
fetchHexagonStats(h3, SMALLEST_VISIBLE_HEXAGON_RESOLUTION)
fetchHexagonStats(
h3,
SMALLEST_VISIBLE_HEXAGON_RESOLUTION,
undefined,
undefined,
areaStatsUseFilters
)
.then((stats) => {
if (!isCurrentAreaRequest(requestId)) return;
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
})
.catch((error) => logNonAbortError('Failed to fetch current location hex stats', error))
.finally(() => {
@ -705,6 +785,7 @@ export function useHexagonSelection({
});
},
[
areaStatsUseFilters,
fetchHexagonStats,
invalidateAreaRequests,
invalidatePropertyRequests,
@ -721,6 +802,8 @@ export function useHexagonSelection({
areaStats,
loadingAreaStats,
unfilteredAreaCount,
areaStatsUseFilters,
setAreaStatsUseFilters,
hoveredHexagon,
rightPaneTab,
setRightPaneTab,

View file

@ -18,6 +18,7 @@ import {
} from '../lib/api';
import { getSchoolBackendFeatureName } from '../lib/school-filter';
import { getSpecificCrimeFeatureName } from '../lib/crime-filter';
import { getElectionVoteShareFeatureName } from '../lib/election-filter';
import { getEthnicityFeatureName } from '../lib/ethnicity-filter';
import { getPoiDistanceFeatureName } from '../lib/poi-distance-filter';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
@ -90,6 +91,7 @@ export function useMapData({
(name: string) =>
getSchoolBackendFeatureName(name) ??
getSpecificCrimeFeatureName(name) ??
getElectionVoteShareFeatureName(name) ??
getEthnicityFeatureName(name) ??
getPoiDistanceFeatureName(name) ??
name,

View file

@ -29,8 +29,6 @@ const descriptions: Record<string, Record<string, string>> = {
'Potential energy rating':
'Classement EPC potentiel si toutes les améliorations recommandées étaient réalisées',
'Interior height (m)': 'Hauteur moyenne détage selon le diagnostic EPC',
'Distance to nearest train or tube station (km)':
'Distance à la gare ou station de métro la plus proche',
'Good+ primary schools within 2km':
'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 2 km',
'Good+ secondary schools within 2km':
@ -97,11 +95,15 @@ const descriptions: Record<string, Record<string, string>> = {
'% Other parties': 'Part des voix combinée de tous les autres partis et indépendants',
'Distance to nearest park (km)': 'Distance au parc ou espace vert le plus proche',
'Number of parks within 1km': 'Nombre de parcs et espaces verts à moins de 1 km',
'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épiceries et supermarchés à moins de 2 km',
'Noise (dB)': 'Niveau de bruit routier au code postal en décibels (Lden)',
'Max available download speed (Mbps)': 'Débit descendant maximal disponible au code postal',
Schools: 'Écoles primaires et secondaires notées à proximité',
'Specific crimes': 'Filtrer une catégorie de criminalité de rue à la fois',
Ethnicities: 'Pourcentage de population par groupe ethnique',
'POI distance': 'Distance aux points dintérêt proches',
'POIs within 2km': 'Nombre de points dintérêt proches dans un rayon de 2 km',
'POIs within 5km': 'Nombre de points dintérêt proches dans un rayon de 5 km',
'Political vote share': 'Part des voix par parti aux élections générales de 2024',
},
de: {
'Property type':
@ -120,8 +122,6 @@ const descriptions: Record<string, Record<string, string>> = {
'Current energy rating': 'Aktuelle EPC-Energieeffizienzklasse (A = beste, G = schlechteste)',
'Potential energy rating': 'Potenzielle EPC-Klasse bei Umsetzung aller empfohlenen Maßnahmen',
'Interior height (m)': 'Durchschnittliche Geschosshöhe laut EPC-Gutachten',
'Distance to nearest train or tube station (km)':
'Entfernung zum nächsten Bahn- oder U-Bahnhof',
'Good+ primary schools within 2km':
'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 2 km',
'Good+ secondary schools within 2km':
@ -190,12 +190,16 @@ const descriptions: Record<string, Record<string, string>> = {
'% Other parties': 'Kombinierter Stimmenanteil aller anderen Parteien und Unabhängigen',
'Distance to nearest park (km)': 'Entfernung zum nächsten Park oder Grünfläche',
'Number of parks within 1km': 'Anzahl Parks und Grünflächen im Umkreis von 1 km',
'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äfte und Supermärkte im Umkreis von 2 km',
'Noise (dB)': 'Straßenlärmpegel an der Postleitzahl in Dezibel (Lden)',
'Max available download speed (Mbps)':
'Maximal verfügbare Breitband-Downloadgeschwindigkeit an der Postleitzahl',
Schools: 'Bewertete Grundschulen und weiterführende Schulen in der Nähe',
'Specific crimes': 'Jeweils eine Straßenkriminalitätskategorie filtern',
Ethnicities: 'Bevölkerungsanteil nach ethnischer Gruppe',
'POI distance': 'Entfernung zu nahe gelegenen Points of Interest',
'POIs within 2km': 'Anzahl nahe gelegener Points of Interest im Umkreis von 2 km',
'POIs within 5km': 'Anzahl nahe gelegener Points of Interest im Umkreis von 5 km',
'Political vote share': 'Stimmenanteil nach Partei bei der Parlamentswahl 2024',
},
zh: {
'Property type': '房产类型:独立式、半独立式、联排、公寓或其他',
@ -213,7 +217,6 @@ const descriptions: Record<string, Record<string, string>> = {
'Current energy rating': '当前EPC能效评级A = 最佳G = 最差)',
'Potential energy rating': '实施所有建议改进后的潜在EPC评级',
'Interior height (m)': 'EPC评估的平均层高',
'Distance to nearest train or tube station (km)': '到最近火车或地铁站的距离',
'Good+ primary schools within 2km': 'Ofsted评为良好或优秀的2公里内小学',
'Good+ secondary schools within 2km': 'Ofsted评为良好或优秀的2公里内中学',
'Good+ primary schools within 5km': 'Ofsted评为良好或优秀的5公里内小学',
@ -262,10 +265,15 @@ const descriptions: Record<string, Record<string, string>> = {
'% Other parties': '所有其他政党和独立候选人的综合得票率',
'Distance to nearest park (km)': '到最近公园或绿地的距离',
'Number of parks within 1km': '1公里内公园和绿地数量',
'Number of restaurants within 2km': '2公里内餐厅和咖啡馆数量',
'Number of grocery shops and supermarkets within 2km': '2公里内食品店和超市数量',
'Noise (dB)': '该邮编的道路噪音水平分贝Lden',
'Max available download speed (Mbps)': '该邮编可用的最大宽带下载速度',
Schools: '附近有评级的小学和中学',
'Specific crimes': '一次筛选一种街面犯罪类别',
Ethnicities: '按族裔群体划分的人口比例',
'POI distance': '到附近兴趣点的距离',
'POIs within 2km': '2公里内附近兴趣点数量',
'POIs within 5km': '5公里内附近兴趣点数量',
'Political vote share': '2024年大选中各政党的得票份额',
},
hi: {
'Property type': 'संपत्ति प्रकार: अलग, अर्ध-स्वतंत्र, कतारबद्ध, फ्लैट या अन्य',
@ -283,25 +291,30 @@ const descriptions: Record<string, Record<string, string>> = {
'Current energy rating': 'मौजूदा EPC ऊर्जा रेटिंग (A = सबसे अच्छी, G = सबसे खराब)',
'Potential energy rating': 'सभी सुझाए गए सुधार होने पर संभावित EPC रेटिंग',
'Interior height (m)': 'EPC सर्वेक्षण के अनुसार औसत अंदरूनी ऊंचाई',
'Distance to nearest train or tube station (km)': 'निकटतम ट्रेन या ट्यूब स्टेशन तक दूरी',
'Good+ primary schools within 2km': '2 किमी के भीतर Ofsted Good या Outstanding प्राइमरी स्कूल',
'Good+ primary schools within 2km':
'2 किमी के भीतर Ofsted से अच्छी या उत्कृष्ट रेटिंग वाले प्राइमरी स्कूल',
'Good+ secondary schools within 2km':
'2 किमी के भीतर Ofsted Good या Outstanding सेकेंडरी स्कूल',
'Good+ primary schools within 5km': '5 किमी के भीतर Ofsted Good या Outstanding प्राइमरी स्कूल',
'2 किमी के भीतर Ofsted से अच्छी या उत्कृष्ट रेटिंग वाले सेकेंडरी स्कूल',
'Good+ primary schools within 5km':
'5 किमी के भीतर Ofsted से अच्छी या उत्कृष्ट रेटिंग वाले प्राइमरी स्कूल',
'Good+ secondary schools within 5km':
'5 किमी के भीतर Ofsted Good या Outstanding सेकेंडरी स्कूल',
'Outstanding primary schools within 2km': '2 किमी के भीतर Ofsted Outstanding प्राइमरी स्कूल',
'Outstanding secondary schools within 2km': '2 किमी के भीतर Ofsted Outstanding सेकेंडरी स्कूल',
'Outstanding primary schools within 5km': '5 किमी के भीतर Ofsted Outstanding प्राइमरी स्कूल',
'Outstanding secondary schools within 5km': '5 किमी के भीतर Ofsted Outstanding सेकेंडरी स्कूल',
'Education, Skills and Training Score': 'शिक्षा और कौशल वंचना percentile (अधिक = कम वंचना)',
'Income Score': 'आय वंचना percentile (अधिक = कम वंचना)',
'Employment Score': 'रोजगार वंचना percentile (अधिक = कम वंचना)',
'5 किमी के भीतर Ofsted से अच्छी या उत्कृष्ट रेटिंग वाले सेकेंडरी स्कूल',
'Outstanding primary schools within 2km':
'2 किमी के भीतर Ofsted से उत्कृष्ट रेटिंग वाले प्राइमरी स्कूल',
'Outstanding secondary schools within 2km':
'2 किमी के भीतर Ofsted से उत्कृष्ट रेटिंग वाले सेकेंडरी स्कूल',
'Outstanding primary schools within 5km':
'5 किमी के भीतर Ofsted से उत्कृष्ट रेटिंग वाले प्राइमरी स्कूल',
'Outstanding secondary schools within 5km':
'5 किमी के भीतर Ofsted से उत्कृष्ट रेटिंग वाले सेकेंडरी स्कूल',
'Education, Skills and Training Score': 'शिक्षा और कौशल वंचना प्रतिशतक (अधिक = कम वंचना)',
'Income Score': 'आय वंचना प्रतिशतक (अधिक = कम वंचना)',
'Employment Score': 'रोजगार वंचना प्रतिशतक (अधिक = कम वंचना)',
'Health Deprivation and Disability Score':
'स्वास्थ्य और विकलांगता वंचना percentile (अधिक = बेहतर परिणाम)',
'Housing Conditions Score': 'आवास स्थिति percentile (अधिक = बेहतर स्थिति)',
'स्वास्थ्य और विकलांगता वंचना प्रतिशतक (अधिक = बेहतर परिणाम)',
'Housing Conditions Score': 'आवास स्थिति प्रतिशतक (अधिक = बेहतर स्थिति)',
'Air Quality and Road Safety Score':
'हवा की गुणवत्ता और सड़क सुरक्षा percentile (अधिक = बेहतर स्थिति)',
'हवा की गुणवत्ता और सड़क सुरक्षा प्रतिशतक (अधिक = बेहतर स्थिति)',
'Serious crime per 1k residents (avg/yr)': 'प्रति 1,000 निवासियों सालाना गंभीर अपराध दर',
'Minor crime per 1k residents (avg/yr)': 'प्रति 1,000 निवासियों सालाना मामूली अपराध दर',
'Serious crime (avg/yr)': 'गंभीर अपराध श्रेणियों का सालाना कुल',
@ -320,7 +333,7 @@ const descriptions: Record<string, Record<string, string>> = {
'Possession of weapons (avg/yr)': 'क्षेत्र में हथियार रखने के अपराधों का सालाना औसत',
'Public order (avg/yr)': 'क्षेत्र में सार्वजनिक व्यवस्था अपराधों का सालाना औसत',
'Other crime (avg/yr)': 'क्षेत्र में अन्य अपराधों का सालाना औसत',
'Median age': 'स्थानीय आबादी की मीडियन आयु',
'Median age': 'स्थानीय आबादी की मध्य आयु',
'% White': 'श्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत',
'% South Asian': 'दक्षिण एशियाई के रूप में पहचान करने वाली आबादी का प्रतिशत',
'% Black': 'अश्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत',
@ -336,11 +349,15 @@ const descriptions: Record<string, Record<string, string>> = {
'% Other parties': 'बाकी सभी पार्टियों और निर्दलीयों का संयुक्त मत-प्रतिशत',
'Distance to nearest park (km)': 'निकटतम पार्क या हरित क्षेत्र तक दूरी',
'Number of parks within 1km': '1 किमी के भीतर पार्कों और हरित क्षेत्रों की संख्या',
'Number of restaurants within 2km': '2 किमी के भीतर रेस्तरां और कैफे की संख्या',
'Number of grocery shops and supermarkets within 2km':
'2 किमी के भीतर किराना दुकानों और सुपरमार्केट की संख्या',
'Noise (dB)': 'पोस्टकोड पर सड़क शोर स्तर, डेसीबल (Lden) में',
'Max available download speed (Mbps)': 'पोस्टकोड पर उपलब्ध अधिकतम डाउनलोड स्पीड',
'Max available download speed (Mbps)': 'पोस्टकोड पर उपलब्ध अधिकतम डाउनलोड गति',
Schools: 'पास के रेटेड प्राइमरी और सेकेंडरी स्कूल',
'Specific crimes': 'एक समय में एक सड़क-स्तर अपराध श्रेणी से फिल्टर करें',
Ethnicities: 'जातीय समूह के अनुसार आबादी का प्रतिशत',
'POI distance': 'पास के रुचि-स्थलों तक दूरी',
'POIs within 2km': '2 किमी के अंदर पास के रुचि-स्थलों की संख्या',
'POIs within 5km': '5 किमी के अंदर पास के रुचि-स्थलों की संख्या',
'Political vote share': '2024 आम चुनाव में पार्टी के अनुसार मत-प्रतिशत',
},
hu: {
'Property type': 'Ingatlantípus: különálló, ikerház, sorház, lakás vagy egyéb',
@ -359,8 +376,6 @@ const descriptions: Record<string, Record<string, string>> = {
'Potential energy rating':
'Potenciális EPC besorolás az összes javasolt fejlesztés elvégzése után',
'Interior height (m)': 'Átlagos belmagasság az EPC felmérés alapján',
'Distance to nearest train or tube station (km)':
'Távolság a legközelebbi vasút- vagy metróállomásig',
'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+ secondary schools within 2km':
@ -423,12 +438,16 @@ const descriptions: Record<string, Record<string, string>> = {
'% Other parties': 'Az összes többi párt és független jelölt összesített szavazataránya',
'Distance to nearest park (km)': 'Távolság a legközelebbi parkig vagy zöldterületig',
'Number of parks within 1km': 'Parkok és zöldterületek száma 1 km-en belül',
'Number of restaurants within 2km': 'Éttermek és kávézók száma 2 km-en belül',
'Number of grocery shops and supermarkets within 2km':
'Élelmiszerboltok és szupermarketek száma 2 km-en belül',
'Noise (dB)': 'Közúti zajszint az irányítószámnál decibelben (Lden)',
'Max available download speed (Mbps)':
'Az irányítószámnál elérhető maximális szélessávú letöltési sebesség',
Schools: 'Közeli minősített általános és középiskolák',
'Specific crimes': 'Egy-egy utcai bűncselekmény-kategória szűrése',
Ethnicities: 'Népességi arány etnikai csoport szerint',
'POI distance': 'Távolság a közeli érdekes pontokig',
'POIs within 2km': 'Közeli érdekes pontok száma 2 km-en belül',
'POIs within 5km': 'Közeli érdekes pontok száma 5 km-en belül',
'Political vote share': 'Pártonkénti szavazatarány a 2024-es parlamenti választáson',
},
};

View file

@ -16,7 +16,7 @@ export const details: Record<string, Record<string, string>> = {
'Price per sqm':
'Calculé en divisant le dernier prix de vente connu par la surface habitable totale indiquée dans le certificat EPC. Utile pour comparer la valeur entre des biens de tailles différentes. Disponible uniquement lorsque les données de prix et de surface existent toutes les deux.',
'Est. price per sqm':
'Calculé en divisant le prix actuel estimé par le modèle par la surface habitable totale indiquée dans le certificat EPC. Fournit une comparaison prix/superficie plus actualisée que le prix au sqm basé sur le prix de vente historique.',
'Calculé en divisant le prix actuel estimé par le modèle par la surface habitable totale indiquée dans le certificat EPC. Fournit une comparaison prix/superficie plus actualisée que le prix au mètre carré basé sur le prix de vente historique.',
'Estimated monthly rent':
"Prix moyen mensuel de location provenant de l'indice des loyers privés de l'ONS (PIPR), correspondant à l'autorité locale et au nombre de chambres.",
'Total floor area (sqm)':
@ -35,8 +35,6 @@ export const details: Record<string, Record<string, string>> = {
"La note d'efficacité énergétique potentielle issue du certificat de performance énergétique (EPC), si toutes les améliorations rentables recommandées dans le rapport EPC étaient réalisées. Va de A (plus efficace) à G (moins efficace).",
'Interior height (m)':
"Hauteur intérieure moyenne (sol au plafond) en mètres telle qu'enregistrée lors de l'évaluation du certificat de performance énergétique (EPC). Calculée en divisant le volume intérieur total par la surface habitable totale.",
'Distance to nearest train or tube station (km)':
"Distance à vol d'oiseau en kilomètres depuis le code postal jusqu'à la gare ferroviaire ou la station de métro/tram la plus proche.",
'Good+ primary schools within 2km':
"Écoles primaires financées par l'État dans un rayon de 2km ayant une note Ofsted actuelle de Bon ou Exceptionnel. Les écoles n'ayant pas encore été inspectées sont exclues.",
'Good+ secondary schools within 2km':
@ -133,14 +131,24 @@ export const details: Record<string, Record<string, string>> = {
"Distance à vol d'oiseau en kilomètres depuis le code postal jusqu'à l'entrée du parc la plus proche. Couvre les parcs publics, jardins, terrains de jeux et espaces de loisirs. Utilise les emplacements des points d'accès issus du jeu de données OS Open Greenspace, de sorte que les propriétés bordant un grand parc affichent correctement une courte distance.",
'Number of parks within 1km':
'Nombre de parcs publics, jardins, terrains de jeux et espaces de loisirs dont au moins une entrée se trouve dans un rayon de 1 km du centroïde du code postal de la propriété. Dérivé du jeu de données OS Open Greenspace (Ordnance Survey), utilisant les emplacements des entrées de parcs pour une correspondance de proximité précise.',
'Number of restaurants within 2km':
'Restaurants, cafés et établissements de restauration dans un rayon de 2km du code postal. Source : OpenStreetMap.',
'Number of grocery shops and supermarkets within 2km':
"Nombre de supermarchés, épiceries et autres commerces alimentaires dans un rayon de 2km du centroïde du code postal de la propriété. Dérivé des données POI d'OpenStreetMap.",
'Noise (dB)':
"Niveau de bruit routier en décibels (Lden, moyenne pondérée sur 24 heures) provenant de la cartographie stratégique du bruit de Defra, 4e cycle (2022). Modélisé à 4m au-dessus du sol sur une grille de 10m. Au-dessus d'environ 55 dB, le bruit est généralement perceptible ; au-dessus d'environ 70 dB, il est considéré comme nocif par l'OMS.",
'Max available download speed (Mbps)':
"Vitesse de téléchargement fixe maximale disponible auprès de n'importe quel fournisseur, provenant d'Ofcom Connected Nations 2025. Représente le maximum théorique, et non les vitesses réellement atteintes. 10 Mbps = basique, 30 = superrapide, 100+ = ultra-rapide, 1000 = gigabit.",
Schools:
"Filtre les écoles primaires ou secondaires financées par l'État à proximité, selon la note Ofsted et la distance choisies. Les seuils disponibles couvrent généralement les écoles Bonnes ou Exceptionnelles, ou uniquement Exceptionnelles, dans un rayon de 2 km ou 5 km.",
'Specific crimes':
"Filtre une catégorie de criminalité de rue à la fois, en utilisant la moyenne annuelle des infractions dans le LSOA. Les valeurs proviennent des données police.uk 2023-2025 et permettent d'isoler des catégories comme cambriolage, véhicules ou comportement antisocial.",
Ethnicities:
"Filtre le pourcentage de population appartenant à un groupe ethnique sélectionné, d'après le Census 2021. Une seule catégorie est appliquée à la fois afin de comparer la composition locale entre les secteurs.",
'POI distance':
"Filtre la distance au point d'intérêt le plus proche du type choisi, calculée depuis le centroïde du code postal. Utilise les points d'intérêt OpenStreetMap pour comparer l'accès local aux services et équipements.",
'POIs within 2km':
"Filtre le nombre de points d'intérêt du type choisi dans un rayon de 2 km autour du code postal. Utile pour comparer les équipements accessibles à pied ou à courte distance.",
'POIs within 5km':
"Filtre le nombre de points d'intérêt du type choisi dans un rayon de 5 km autour du code postal. Utile pour comparer l'offre plus large de services, commerces et équipements autour d'un secteur.",
'Political vote share':
'Filtre le pourcentage de votes obtenu par un parti sélectionné dans la circonscription couvrant chaque code postal, lors des élections générales britanniques de juillet 2024.',
},
de: {
'Property type':
@ -154,7 +162,7 @@ export const details: Record<string, Record<string, string>> = {
'Price per sqm':
'Berechnet durch Division des zuletzt bekannten Verkaufspreises durch die Gesamtnutzfläche aus dem EPC-Zertifikat. Nützlich zum Vergleich des Wertes verschiedener Immobiliengrößen. Nur verfügbar, wenn sowohl Preis- als auch Flächendaten vorhanden sind.',
'Est. price per sqm':
'Berechnet durch Division des modellierten geschätzten aktuellen Preises durch die Gesamtnutzfläche aus dem EPC-Zertifikat. Bietet einen aktuelleren Preis-pro-Fläche-Vergleich als der historische Verkaufspreis pro sqm.',
'Berechnet durch Division des modellierten geschätzten aktuellen Preises durch die Gesamtnutzfläche aus dem EPC-Zertifikat. Bietet einen aktuelleren Preis-pro-Fläche-Vergleich als der historische Verkaufspreis pro Quadratmeter.',
'Estimated monthly rent':
'Durchschnittlicher monatlicher Mietpreis aus dem ONS Price Index of Private Rents (PIPR), abgeglichen nach Gemeinde und Zimmeranzahl.',
'Total floor area (sqm)':
@ -173,24 +181,22 @@ export const details: Record<string, Record<string, string>> = {
'Die potenzielle Energieeffizienzklasse aus dem Energieausweis-Zertifikat, wenn alle im EPC-Bericht empfohlenen kosteneffizienten Verbesserungen durchgeführt würden. Reicht von A (am effizientesten) bis G (am wenigsten effizient).',
'Interior height (m)':
'Durchschnittliche lichte Raumhöhe in Metern, wie während der Energieausweis-Begutachtung erfasst. Berechnet durch Division des gesamten Innenvolumens durch die Gesamtwohnfläche.',
'Distance to nearest train or tube station (km)':
'Luftlinienentfernung in Kilometern vom Postleitzahlenzentrum zur nächsten Bahnstation oder U-Bahn-/Metro-/Straßenbahnhaltestelle.',
'Good+ primary schools within 2km':
'Staatlich geförderte Grundschulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von „Good" oder „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Staatlich geförderte Grundschulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von Gut oder Hervorragend. Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Good+ secondary schools within 2km':
'Staatlich geförderte weiterführende Schulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von „Good" oder „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Staatlich geförderte weiterführende Schulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von Gut oder Hervorragend. Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Good+ primary schools within 5km':
'Staatlich geförderte Grundschulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Good" oder „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Staatlich geförderte Grundschulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von Gut oder Hervorragend. Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Good+ secondary schools within 5km':
'Staatlich geförderte weiterführende Schulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Good" oder „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Staatlich geförderte weiterführende Schulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von Gut oder Hervorragend. Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Outstanding primary schools within 2km':
'Staatlich geförderte Grundschulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Staatlich geförderte Grundschulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von Hervorragend. Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Outstanding secondary schools within 2km':
'Staatlich geförderte weiterführende Schulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Staatlich geförderte weiterführende Schulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von Hervorragend. Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Outstanding primary schools within 5km':
'Staatlich geförderte Grundschulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Staatlich geförderte Grundschulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von Hervorragend. Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Outstanding secondary schools within 5km':
'Staatlich geförderte weiterführende Schulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Staatlich geförderte weiterführende Schulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von Hervorragend. Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Education, Skills and Training Score':
'Aus den englischen Deprivationsindizes, in ein nationales Perzentil umgerechnet: 0 % steht für die am stärksten benachteiligten Gebiete, 100 % für die am wenigsten benachteiligten. Umfasst Schulleistungen, Hochschulzugang, Qualifikationen Erwachsener und Englischsprachkenntnisse.',
'Income Score':
@ -271,14 +277,24 @@ export const details: Record<string, Record<string, string>> = {
'Luftlinienentfernung in Kilometern vom Postleitzahlenzentrum zum nächsten Parkeingang. Umfasst öffentliche Parks, Gärten, Sportplätze und Spielbereiche. Verwendet Zugangspunktstandorte aus dem OS Open Greenspace-Datensatz, sodass Immobilien an der Grenze eines großen Parks korrekt eine kurze Entfernung anzeigen.',
'Number of parks within 1km':
'Anzahl öffentlicher Parks, Gärten, Sportplätze und Spielbereiche mit mindestens einem Eingang innerhalb eines 1-km-Radius um den Postleitzahlenmittelpunkt der Immobilie. Abgeleitet aus dem OS Open Greenspace-Datensatz (Ordnance Survey) unter Verwendung von Parkeingangsstandorten für genaues Abstandsmatching.',
'Number of restaurants within 2km':
'Restaurants, Cafés und Gastronomiebetriebe innerhalb von 2 km vom Postleitzahlenzentrum. Bezogen aus OpenStreetMap.',
'Number of grocery shops and supermarkets within 2km':
'Anzahl von Supermärkten, Lebensmittelläden und anderen Lebensmittelgeschäften innerhalb eines 2-km-Radius um den Postleitzahlenmittelpunkt der Immobilie. Abgeleitet aus OpenStreetMap-POI-Daten.',
'Noise (dB)':
'Straßenlärmpegel in Dezibel (Lden, ein 24-Stunden-gewichteter Durchschnitt) aus Defras Strategic Noise Mapping Round 4 (2022). Modelliert in 4 m Höhe über dem Boden auf einem 10-m-Raster. Über ~55 dB ist in der Regel wahrnehmbar; über ~70 dB gilt laut WHO als gesundheitsschädlich.',
'Max available download speed (Mbps)':
'Maximale verfügbare Festnetz-Download-Geschwindigkeit von einem beliebigen Anbieter, aus Ofcom Connected Nations 2025. Gibt die theoretische Höchstgeschwindigkeit an, keine tatsächlich erreichten Geschwindigkeiten. 10 Mbps = Basis, 30 = Superfast, 100+ = Ultrafast, 1000 = Gigabit.',
'Maximale verfügbare Festnetz-Download-Geschwindigkeit von einem beliebigen Anbieter, aus Ofcom Connected Nations 2025. Gibt die theoretische Höchstgeschwindigkeit an, keine tatsächlich erreichten Geschwindigkeiten. 10 Mbps = Basis, 30 = schnell, 100+ = ultraschnell, 1000 = Gigabit.',
Schools:
'Filtert nahegelegene staatlich finanzierte Grundschulen oder weiterführende Schulen nach der gewählten Ofsted-Bewertung und Entfernung. Die verfügbaren Schwellen decken in der Regel Gute oder Hervorragende Schulen oder nur Hervorragende Schulen innerhalb von 2 km oder 5 km ab.',
'Specific crimes':
'Filtert jeweils eine Kategorie der Straßenkriminalität anhand der durchschnittlichen jährlichen Vorfälle im LSOA. Die Werte stammen aus police.uk-Daten 2023-2025 und helfen, Kategorien wie Einbruch, Fahrzeugkriminalität oder asoziales Verhalten getrennt zu betrachten.',
Ethnicities:
'Filtert den Bevölkerungsanteil einer ausgewählten ethnischen Gruppe auf Basis des Census 2021. Es wird jeweils eine Kategorie angewendet, damit die lokale Zusammensetzung zwischen Gebieten vergleichbar bleibt.',
'POI distance':
'Filtert die Entfernung zum nächsten Point of Interest des gewählten Typs, berechnet vom Postleitzahlenmittelpunkt. Verwendet OpenStreetMap-POIs, um den lokalen Zugang zu Diensten und Einrichtungen zu vergleichen.',
'POIs within 2km':
'Filtert die Anzahl der Points of Interest des gewählten Typs innerhalb von 2 km um die Postleitzahl. Nützlich zum Vergleich von Einrichtungen, die zu Fuß oder in kurzer Entfernung erreichbar sind.',
'POIs within 5km':
'Filtert die Anzahl der Points of Interest des gewählten Typs innerhalb von 5 km um die Postleitzahl. Nützlich zum Vergleich des breiteren Angebots an Diensten, Geschäften und Einrichtungen in einem Gebiet.',
'Political vote share':
'Filtert den Stimmenanteil einer ausgewählten Partei in dem Wahlkreis, der die jeweilige Postleitzahl abdeckt, bei der britischen Parlamentswahl im Juli 2024.',
},
zh: {
'Property type':
@ -311,24 +327,22 @@ export const details: Record<string, Record<string, string>> = {
'若实施EPC报告中建议的所有具有成本效益的改进措施后该房产的潜在能源效率等级。从A最高效到G最低效。',
'Interior height (m)':
'EPC评估期间记录的平均室内净高。通过将室内总容积除以总建筑面积计算得出。',
'Distance to nearest train or tube station (km)':
'从邮政编码到最近铁路站或地铁/城铁/轻轨站的直线距离km。',
'Good+ primary schools within 2km':
'2km范围内Ofsted评级为"Good"或"Outstanding"的公立小学数量。尚未接受评估的学校不计入。',
'2km范围内Ofsted评级为“良好”或“优秀”的公立小学数量。尚未接受评估的学校不计入。',
'Good+ secondary schools within 2km':
'2km范围内Ofsted评级为"Good"或"Outstanding"的公立中学数量。尚未接受评估的学校不计入。',
'2km范围内Ofsted评级为“良好”或“优秀”的公立中学数量。尚未接受评估的学校不计入。',
'Good+ primary schools within 5km':
'5km范围内Ofsted评级为"Good"或"Outstanding"的公立小学数量。尚未接受评估的学校不计入。',
'5km范围内Ofsted评级为“良好”或“优秀”的公立小学数量。尚未接受评估的学校不计入。',
'Good+ secondary schools within 5km':
'5km范围内Ofsted评级为"Good"或"Outstanding"的公立中学数量。尚未接受评估的学校不计入。',
'5km范围内Ofsted评级为“良好”或“优秀”的公立中学数量。尚未接受评估的学校不计入。',
'Outstanding primary schools within 2km':
'2km范围内Ofsted评级为"Outstanding"的公立小学数量。尚未接受评估的学校不计入。',
'2km范围内Ofsted评级为“优秀”的公立小学数量。尚未接受评估的学校不计入。',
'Outstanding secondary schools within 2km':
'2km范围内Ofsted评级为"Outstanding"的公立中学数量。尚未接受评估的学校不计入。',
'2km范围内Ofsted评级为“优秀”的公立中学数量。尚未接受评估的学校不计入。',
'Outstanding primary schools within 5km':
'5km范围内Ofsted评级为"Outstanding"的公立小学数量。尚未接受评估的学校不计入。',
'5km范围内Ofsted评级为“优秀”的公立小学数量。尚未接受评估的学校不计入。',
'Outstanding secondary schools within 5km':
'5km范围内Ofsted评级为"Outstanding"的公立中学数量。尚未接受评估的学校不计入。',
'5km范围内Ofsted评级为“优秀”的公立中学数量。尚未接受评估的学校不计入。',
'Education, Skills and Training Score':
'来自英格兰剥夺指数转换为全国百分位0%表示最贫困100%表示最不贫困。涵盖学校成绩、高等教育入学率、成人学历和英语水平。',
'Income Score':
@ -403,14 +417,24 @@ export const details: Record<string, Record<string, string>> = {
'从邮政编码到最近公园入口的直线距离km。涵盖公共公园、花园、运动场和游乐场地。使用 OS Open Greenspace 数据集中的出入口位置,因此紧邻大型公园的房产可正确显示较短距离。',
'Number of parks within 1km':
'以房产邮政编码中心点为圆心1km半径内至少有一个入口的公共公园、花园、运动场和游乐场地数量。来源于OS Open Greenspace数据集英国地形测量局使用公园入口位置进行精确近距离匹配。',
'Number of restaurants within 2km':
'邮政编码2km范围内的餐厅、咖啡馆和餐饮场所数量。来源于OpenStreetMap。',
'Number of grocery shops and supermarkets within 2km':
'以房产邮政编码中心点为圆心2km半径内的超市、便利店和其他杂货店数量。来源于OpenStreetMap POI数据。',
'Noise (dB)':
'来自Defra战略噪声图第4轮2022年的道路噪声水平单位为分贝Lden24小时加权平均值。在地面以上4m、10m网格间距处建模。一般而言超过约55 dB可明显感知超过约70 dB被世卫组织认定为有害。',
'Max available download speed (Mbps)':
'来自Ofcom Connected Nations 2025的任意运营商可提供的最大固定宽带下载速度。代表理论最大值而非实际达到的速度。10 Mbps为基础级30为超快级100+为极速级1000为千兆级。',
Schools:
'按所选Ofsted评级和距离筛选附近的公立小学或中学。可用阈值通常包括2公里或5公里内的良好及以上学校或仅优秀学校。',
'Specific crimes':
'一次筛选一种街面犯罪类别使用LSOA内的年均案件数。数值来自2023-2025年的police.uk数据可单独查看入室盗窃、车辆犯罪或反社会行为等类别。',
Ethnicities:
'根据2021年人口普查筛选所选族裔群体占人口的百分比。每次应用一个类别便于比较不同地区的本地人口构成。',
'POI distance':
'筛选到所选类型最近兴趣点的距离从邮政编码中心点计算。使用OpenStreetMap兴趣点用于比较本地服务和设施的可达性。',
'POIs within 2km':
'筛选邮政编码周围2公里内所选类型兴趣点的数量。适合比较步行或短距离可达的设施。',
'POIs within 5km':
'筛选邮政编码周围5公里内所选类型兴趣点的数量。适合比较某一区域周边更广泛的服务、商店和设施供给。',
'Political vote share':
'筛选在覆盖每个邮政编码的选区中所选政党在2024年7月英国大选获得的得票百分比。',
},
hi: {
'Property type':
@ -418,137 +442,145 @@ export const details: Record<string, Record<string, string>> = {
'Leasehold/Freehold':
'HM Land Registry Price Paid डेटा से लिया गया. फ्रीहोल्ड का मतलब है कि भवन और जिस जमीन पर वह है, दोनों आपके हैं. लीजहोल्ड का मतलब है कि भवन आपका है लेकिन जमीन नहीं: फ्रीहोल्डर से निश्चित वर्षों के लिए लीज मिला होता है.',
'Last known price':
'इस संपत्ति की अंतिम दर्ज बिक्री कीमत HM Land Registry Price Paid डेटा से आती है. यह England की आवासीय बिक्री को कवर करती है. अगर संपत्ति हाल में नहीं बिकी है तो यह कीमत कई साल पुरानी हो सकती है.',
'इस संपत्ति की अंतिम दर्ज बिक्री कीमत HM Land Registry Price Paid डेटा से आती है. यह इंग्लैंड की आवासीय बिक्री को कवर करती है. अगर संपत्ति हाल में नहीं बिकी है तो यह कीमत कई साल पुरानी हो सकती है.',
'Estimated current price':
'अंतिम बिक्री कीमत, स्थानीय repeat-sales कीमत बदलाव और आसपास हाल में बिकी संपत्तियों पर आधारित. Repeat-sales index को postcode sector और property type के अनुसार ट्रैक किया जाता है, और कम डेटा होने पर smoothing व nearby-sale blending इस्तेमाल होती है. हाल की बिक्री दर्ज कीमत के करीब रहती है; पुरानी बिक्री में मॉडल पर निर्भरता ज्यादा होती है.',
'अंतिम बिक्री कीमत, स्थानीय दोबारा-बिक्री कीमत बदलाव और आसपास हाल में बिकी संपत्तियों पर आधारित. दोबारा-बिक्री सूचकांक को पोस्टकोड सेक्टर और संपत्ति प्रकार के अनुसार ट्रैक किया जाता है, और कम डेटा होने पर समतलन तथा नजदीकी बिक्री के साथ मिलान किया जाता है. हाल की बिक्री दर्ज कीमत के करीब रहती है; पुरानी बिक्री में मॉडल पर निर्भरता ज्यादा होती है.',
'Price per sqm':
'अंतिम ज्ञात बिक्री कीमत को EPC प्रमाणपत्र में दर्ज कुल फर्श क्षेत्र से भाग देकर निकाला गया. अलग-अलग आकार की संपत्तियों की मूल्य तुलना के लिए उपयोगी. केवल तब उपलब्ध जब कीमत और फर्श क्षेत्र, दोनों डेटा मौजूद हों.',
'Est. price per sqm':
'मॉडल से अनुमानित मौजूदा कीमत को EPC प्रमाणपत्र में दर्ज कुल फर्श क्षेत्र से भाग देकर निकाला गया. ऐतिहासिक बिक्री कीमत पर आधारित प्रति वर्ग मी कीमत की तुलना में ज्यादा ताजा कीमत/क्षेत्र तुलना देता है.',
'मॉडल से अनुमानित मौजूदा कीमत को EPC प्रमाणपत्र में दर्ज कुल फर्श क्षेत्र से भाग देकर निकाला गया. ऐतिहासिक बिक्री कीमत पर आधारित प्रति वर्ग मीटर कीमत की तुलना में ज्यादा ताजा कीमत/क्षेत्र तुलना देता है.',
'Estimated monthly rent':
'ONS Price Index of Private Rents (PIPR) से औसत मासिक किराया, जिसे स्थानीय प्राधिकरण और बेडरूम संख्या से मिलाया गया है.',
'Total floor area (sqm)':
'Energy Performance Certificate (EPC) आकलन के दौरान मापा गया कुल उपयोगी फर्श क्षेत्र, वर्ग मीटर में. सभी रहने योग्य कमरे शामिल, लेकिन गैरेज, बाहरी इमारतें और बाहरी क्षेत्र शामिल नहीं.',
'EPC आकलन के दौरान मापा गया कुल उपयोगी फर्श क्षेत्र, वर्ग मीटर में. सभी रहने योग्य कमरे शामिल हैं, लेकिन गैरेज, बाहरी इमारतें और बाहरी क्षेत्र शामिल नहीं हैं.',
'Number of bedrooms & living rooms':
'Energy Performance Certificate (EPC) में दर्ज कुल रहने योग्य कमरों की संख्या (बेडरूम और लिविंग रूम). रसोई और बाथरूम आमतौर पर शामिल नहीं होते, जब तक वे रहने योग्य कमरा माने जाने लायक बड़े न हों.',
'EPC में दर्ज कुल रहने योग्य कमरों की संख्या (बेडरूम और बैठक कमरे). रसोई और बाथरूम आमतौर पर शामिल नहीं होते, जब तक वे रहने योग्य कमरा माने जाने लायक बड़े न हों.',
'Construction year':
"EPC में दर्ज निर्माण आयु-श्रेणी (जैसे '1930-1949') से मध्य बिंदु लेकर अनुमानित. पुराने भवनों के लिए कम सटीक हो सकता है, जहां आयु-श्रेणी कई दशकों तक फैली होती है.",
'Date of last transaction':
'इस संपत्ति की सबसे हाल की दर्ज बिक्री तारीख, HM Land Registry Price Paid डेटा से. डेटा में तारीख/समय के रूप में रखी होती है; फिल्टर और चार्ट के लिए fractional year में बदली जाती है.',
'इस संपत्ति की सबसे हाल की दर्ज बिक्री तारीख, HM Land Registry Price Paid डेटा से. डेटा में तारीख/समय के रूप में रखी होती है; फिल्टर और चार्ट के लिए इसे भिन्नात्मक वर्ष में बदला जाता है.',
'Former council house':
'Energy Performance Certificate डेटा के TENURE field से निकाला गया. अगर इस संपत्ति के किसी EPC प्रमाणपत्र ने टेन्योर को social rented के रूप में दर्ज किया, तो यह संकेत देता है कि उस निरीक्षण के समय संपत्ति council या housing association stock में थी. बाद में बेची गई संपत्तियां (जैसे Right to Buy के जरिए) यह संकेतक बनाए रखती हैं.',
'EPC डेटा के TENURE फ़ील्ड से निकाला गया. अगर इस संपत्ति के किसी EPC प्रमाणपत्र में स्वामित्व/किरायेदारी को सामाजिक किराये के रूप में दर्ज किया गया था, तो यह संकेत देता है कि उस निरीक्षण के समय संपत्ति काउंसिल या हाउसिंग एसोसिएशन के आवास भंडार में थी. बाद में बेची गई संपत्तियां (जैसे Right to Buy के जरिए) यह संकेतक बनाए रखती हैं.',
'Current energy rating':
'Energy Performance Certificate (EPC) से current energy efficiency rating. A (सबसे efficient) से G (सबसे कम efficient) तक. Property की floor area प्रति energy use पर आधारित.',
'EPC से मौजूदा ऊर्जा दक्षता रेटिंग. यह A (सबसे दक्ष) से G (सबसे कम दक्ष) तक होती है. यह संपत्ति के फर्श क्षेत्र प्रति ऊर्जा उपयोग पर आधारित है.',
'Potential energy rating':
'Energy Performance Certificate (EPC) से potential energy efficiency rating, अगर EPC report की सभी cost-effective recommended improvements कर दी जाएं. A (सबसे efficient) से G (सबसे कम efficient) तक.',
'EPC से संभावित ऊर्जा दक्षता रेटिंग, यदि EPC रिपोर्ट में सुझाए गए सभी किफायती सुधार कर दिए जाएं. यह A (सबसे दक्ष) से G (सबसे कम दक्ष) तक होती है.',
'Interior height (m)':
'Energy Performance Certificate (EPC) assessment के दौरान दर्ज average internal floor-to-ceiling height, metres में. Total internal volume को total floor area से divide करके निकाली जाती है.',
'Distance to nearest train or tube station (km)':
'Postcode से निकटतम railway station या tube/metro/tram stop तक सीधी रेखा में दूरी, kilometres में.',
'EPC आकलन के दौरान दर्ज औसत अंदरूनी फर्श-से-छत ऊंचाई, मीटर में. कुल आंतरिक आयतन को कुल फर्श क्षेत्र से भाग देकर निकाली जाती है.',
'Good+ primary schools within 2km':
'2km के भीतर state-funded primary schools जिनकी current Ofsted rating Good या Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'2 km के भीतर सरकारी वित्तपोषित प्राइमरी स्कूल जिनकी मौजूदा Ofsted रेटिंग अच्छी या उत्कृष्ट है. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
'Good+ secondary schools within 2km':
'2km के भीतर state-funded secondary schools जिनकी current Ofsted rating Good या Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'2 km के भीतर सरकारी वित्तपोषित सेकेंडरी स्कूल जिनकी मौजूदा Ofsted रेटिंग अच्छी या उत्कृष्ट है. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
'Good+ primary schools within 5km':
'5km के भीतर state-funded primary schools जिनकी current Ofsted rating Good या Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'5 km के भीतर सरकारी वित्तपोषित प्राइमरी स्कूल जिनकी मौजूदा Ofsted रेटिंग अच्छी या उत्कृष्ट है. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
'Good+ secondary schools within 5km':
'5km के भीतर state-funded secondary schools जिनकी current Ofsted rating Good या Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'5 km के भीतर सरकारी वित्तपोषित सेकेंडरी स्कूल जिनकी मौजूदा Ofsted रेटिंग अच्छी या उत्कृष्ट है. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
'Outstanding primary schools within 2km':
'2km के भीतर state-funded primary schools जिनकी current Ofsted rating Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'2 km के भीतर सरकारी वित्तपोषित प्राइमरी स्कूल जिनकी मौजूदा Ofsted रेटिंग उत्कृष्ट है. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
'Outstanding secondary schools within 2km':
'2km के भीतर state-funded secondary schools जिनकी current Ofsted rating Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'2 km के भीतर सरकारी वित्तपोषित सेकेंडरी स्कूल जिनकी मौजूदा Ofsted रेटिंग उत्कृष्ट है. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
'Outstanding primary schools within 5km':
'5km के भीतर state-funded primary schools जिनकी current Ofsted rating Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'5 km के भीतर सरकारी वित्तपोषित प्राइमरी स्कूल जिनकी मौजूदा Ofsted रेटिंग उत्कृष्ट है. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
'Outstanding secondary schools within 5km':
'5km के भीतर state-funded secondary schools जिनकी current Ofsted rating Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'5 km के भीतर सरकारी वित्तपोषित सेकेंडरी स्कूल जिनकी मौजूदा Ofsted रेटिंग उत्कृष्ट है. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
'Education, Skills and Training Score':
'English Indices of Deprivation से लिया गया और national percentile में बदला गया: 0% सबसे अधिक deprived, 100% सबसे कम deprived. School attainment, higher education entry, adult qualifications और English language proficiency को cover करता है.',
'English Indices of Deprivation से लिया गया और राष्ट्रीय प्रतिशतक में बदला गया: 0% सबसे अधिक वंचित क्षेत्रों और 100% सबसे कम वंचित क्षेत्रों को दर्शाता है. इसमें स्कूल उपलब्धि, उच्च शिक्षा में प्रवेश, वयस्क योग्यता और अंग्रेजी भाषा दक्षता शामिल है.',
'Income Score':
'English Indices of Deprivation से लिया गया और national percentile में बदला गया: 0% सबसे अधिक income deprived, 100% सबसे कम income deprived. Income support, income-based Jobseekers Allowance, income-based Employment and Support Allowance, Pension Credit, Working and Child Tax Credit, Universal Credit और asylum seekers पर आधारित.',
'English Indices of Deprivation से लिया गया और राष्ट्रीय प्रतिशतक में बदला गया: 0% सबसे अधिक आय वंचना और 100% सबसे कम आय वंचना को दर्शाता है. Income Support, आय-आधारित Jobseekers Allowance, आय-आधारित Employment and Support Allowance, Pension Credit, Working Tax Credit और Child Tax Credit, Universal Credit तथा शरण चाहने वालों पर आधारित.',
'Employment Score':
'English Indices of Deprivation से लिया गया और national percentile में बदला गया: 0% सबसे अधिक employment deprived, 100% सबसे कम employment deprived. Jobseekers Allowance, Employment and Support Allowance, Incapacity Benefit, Severe Disablement Allowance, Carers Allowance और relevant Universal Credit claimants पर आधारित.',
'English Indices of Deprivation से लिया गया और राष्ट्रीय प्रतिशतक में बदला गया: 0% सबसे अधिक रोजगार वंचना और 100% सबसे कम रोजगार वंचना को दर्शाता है. Jobseekers Allowance, Employment and Support Allowance, Incapacity Benefit, Severe Disablement Allowance, Carers Allowance और संबंधित Universal Credit दावेदारों पर आधारित.',
'Health Deprivation and Disability Score':
'English Indices of Deprivation से लिया गया और national percentile में बदला गया: 0% सबसे अधिक health deprived, 100% सबसे कम health deprived. Years of potential life lost, comparative illness and disability ratio, acute morbidity और mood/anxiety disorders से derived.',
'English Indices of Deprivation से लिया गया और राष्ट्रीय प्रतिशतक में बदला गया: 0% सबसे अधिक स्वास्थ्य वंचना और 100% सबसे कम स्वास्थ्य वंचना को दर्शाता है. संभावित जीवन-वर्षों की हानि, तुलनात्मक बीमारी और विकलांगता अनुपात, तीव्र रुग्णता तथा मनोदशा/चिंता विकारों से निकाला गया.',
'Housing Conditions Score':
'English Indices of Deprivation के Living Environment domain से लिया गया और national percentile में बदला गया: 0% सबसे खराब conditions, 100% सबसे अच्छी. Housing stock की quality मापता है: central heating availability, housing condition और Decent Homes standards.',
'English Indices of Deprivation के Living Environment क्षेत्र से लिया गया और राष्ट्रीय प्रतिशतक में बदला गया: 0% सबसे खराब स्थितियों और 100% सबसे अच्छी स्थितियों को दर्शाता है. यह आवास भंडार की गुणवत्ता मापता है: केंद्रीय हीटिंग की उपलब्धता, आवास की स्थिति और Decent Homes मानक.',
'Air Quality and Road Safety Score':
'English Indices of Deprivation के Living Environment domain से लिया गया और national percentile में बदला गया: 0% सबसे खराब conditions, 100% सबसे अच्छी. Air quality indicators और pedestrians/cyclists से जुड़े road traffic accident casualties के जरिए outdoor living environment quality मापता है.',
'English Indices of Deprivation के Living Environment क्षेत्र से लिया गया और राष्ट्रीय प्रतिशतक में बदला गया: 0% सबसे खराब स्थितियों और 100% सबसे अच्छी स्थितियों को दर्शाता है. यह वायु गुणवत्ता संकेतकों और पैदल यात्रियों/साइकिल चालकों से जुड़े सड़क यातायात दुर्घटना हताहतों के जरिए बाहरी रहने के वातावरण की गुणवत्ता मापता है.',
'Serious crime per 1k residents (avg/yr)':
'LSOA में प्रति 1,000 usual residents प्रति वर्ष violence, robbery, burglary और possession of weapons. police.uk street-level crime data (2023-2025) और Census 2021 population counts का उपयोग करता है. Population density normalize करता है ताकि areas size की परवाह किए बिना comparable हों.',
'LSOA में प्रति 1,000 सामान्य निवासियों पर प्रति वर्ष हिंसा, लूट, सेंधमारी और हथियार रखने के अपराध. police.uk के सड़क-स्तर अपराध डेटा (2023-2025) और Census 2021 जनसंख्या गणना का उपयोग करता है. जनसंख्या घनत्व के अनुसार सामान्यीकृत करता है ताकि क्षेत्र आकार की परवाह किए बिना तुलनीय हों.',
'Minor crime per 1k residents (avg/yr)':
'LSOA में प्रति 1,000 usual residents प्रति वर्ष anti-social behaviour, shoplifting, bicycle theft और other lower-severity crimes. police.uk street-level crime data (2023-2025) और Census 2021 population counts का उपयोग करता है. Population density normalize करता है ताकि areas comparable हों.',
'LSOA में प्रति 1,000 सामान्य निवासियों पर प्रति वर्ष असामाजिक व्यवहार, दुकान से चोरी, साइकिल चोरी और अन्य कम-गंभीर अपराध. police.uk के सड़क-स्तर अपराध डेटा (2023-2025) और Census 2021 जनसंख्या गणना का उपयोग करता है. जनसंख्या घनत्व के अनुसार सामान्यीकृत करता है ताकि क्षेत्र तुलनीय हों.',
'Serious crime (avg/yr)':
'LSOA में प्रति वर्ष violence, robbery, burglary और possession of weapons का sum, police.uk street-level crime data (2023-2025) से. Serious crime का एक single indicator देता है.',
'LSOA में प्रति वर्ष हिंसा, लूट, सेंधमारी और हथियार रखने के अपराधों का योग, police.uk के सड़क-स्तर अपराध डेटा (2023-2025) से. गंभीर अपराध का एक संयुक्त संकेतक देता है.',
'Minor crime (avg/yr)':
'LSOA में प्रति वर्ष anti-social behaviour, shoplifting, bicycle theft और other lower-severity crimes का sum, police.uk street-level crime data (2023-2025) से. Minor crime का एक single indicator देता है.',
'LSOA में प्रति वर्ष असामाजिक व्यवहार, दुकान से चोरी, साइकिल चोरी और अन्य कम-गंभीर अपराधों का योग, police.uk के सड़क-स्तर अपराध डेटा (2023-2025) से. मामूली अपराध का एक संयुक्त संकेतक देता है.',
'Violence and sexual offences (avg/yr)':
'LSOA में प्रति वर्ष violence और sexual offences की average count, police.uk street-level crime data (2023-2025) से. Assaults, harassment और sexual offences शामिल.',
'LSOA में प्रति वर्ष हिंसा और यौन अपराधों की औसत संख्या, police.uk के सड़क-स्तर अपराध डेटा (2023-2025) से. इसमें हमले, उत्पीड़न और यौन अपराध शामिल हैं.',
'Burglary (avg/yr)':
'LSOA में प्रति वर्ष burglaries की average count, police.uk street-level crime data (2023-2025) से. Residential और commercial burglaries शामिल.',
'LSOA में प्रति वर्ष सेंधमारी की औसत संख्या, police.uk के सड़क-स्तर अपराध डेटा (2023-2025) से. इसमें आवासीय और व्यावसायिक सेंधमारी शामिल हैं.',
'Robbery (avg/yr)':
'LSOA में प्रति वर्ष robberies की average count, police.uk street-level crime data (2023-2025) से. Robbery में force या threat of force के साथ theft शामिल है.',
'LSOA में प्रति वर्ष लूट की औसत संख्या, police.uk के सड़क-स्तर अपराध डेटा (2023-2025) से. लूट में बल प्रयोग या बल प्रयोग की धमकी के साथ चोरी शामिल होती है.',
'Vehicle crime (avg/yr)':
'LSOA में प्रति वर्ष vehicle-related crime incidents की average count, police.uk street-level crime data (2023-2025) से. Vehicles की theft और vehicles के अंदर से theft शामिल.',
'LSOA में प्रति वर्ष वाहन-संबंधी अपराध घटनाओं की औसत संख्या, police.uk के सड़क-स्तर अपराध डेटा (2023-2025) से. इसमें वाहनों की चोरी और वाहनों के अंदर से चोरी शामिल है.',
'Anti-social behaviour (avg/yr)':
'LSOA में प्रति वर्ष anti-social behaviour incidents की average count, police.uk street-level crime data (2023-2025) से. Nuisance, environmental और personal anti-social behaviour शामिल.',
'LSOA में प्रति वर्ष असामाजिक व्यवहार घटनाओं की औसत संख्या, police.uk के सड़क-स्तर अपराध डेटा (2023-2025) से. इसमें उपद्रव, पर्यावरणीय और व्यक्तिगत असामाजिक व्यवहार शामिल हैं.',
'Criminal damage and arson (avg/yr)':
'LSOA में प्रति वर्ष criminal damage और arson incidents की average count, police.uk street-level crime data (2023-2025) से.',
'LSOA में प्रति वर्ष आपराधिक क्षति और आगजनी घटनाओं की औसत संख्या, police.uk के सड़क-स्तर अपराध डेटा (2023-2025) से.',
'Other theft (avg/yr)':
"'Other theft' offences की LSOA में प्रति वर्ष average count, police.uk street-level crime data (2023-2025) से. Burglary, vehicle crime, shoplifting या bicycle theft में न आने वाली theft शामिल.",
'LSOA में प्रति वर्ष अन्य चोरी अपराधों की औसत संख्या, police.uk के सड़क-स्तर अपराध डेटा (2023-2025) से. इसमें सेंधमारी, वाहन अपराध, दुकान से चोरी या साइकिल चोरी में न आने वाली चोरी शामिल है.',
'Theft from the person (avg/yr)':
'LSOA में प्रति वर्ष theft-from-the-person offences की average count, police.uk street-level crime data (2023-2025) से. Pickpocketing और बिना force के bag snatching शामिल.',
'LSOA में प्रति वर्ष व्यक्ति से चोरी के अपराधों की औसत संख्या, police.uk के सड़क-स्तर अपराध डेटा (2023-2025) से. इसमें जेबकतरी और बिना बल प्रयोग के बैग छीनना शामिल है.',
'Shoplifting (avg/yr)':
'LSOA में प्रति वर्ष shoplifting offences की average count, police.uk street-level crime data (2023-2025) से.',
'LSOA में प्रति वर्ष दुकान से चोरी के अपराधों की औसत संख्या, police.uk के सड़क-स्तर अपराध डेटा (2023-2025) से.',
'Bicycle theft (avg/yr)':
'LSOA में प्रति वर्ष bicycle theft offences की average count, police.uk street-level crime data (2023-2025) से.',
'LSOA में प्रति वर्ष साइकिल चोरी के अपराधों की औसत संख्या, police.uk के सड़क-स्तर अपराध डेटा (2023-2025) से.',
'Drugs (avg/yr)':
'LSOA में प्रति वर्ष drug offences की average count, police.uk street-level crime data (2023-2025) से. Possession और trafficking offences शामिल.',
'LSOA में प्रति वर्ष नशीले पदार्थों से जुड़े अपराधों की औसत संख्या, police.uk के सड़क-स्तर अपराध डेटा (2023-2025) से. इसमें कब्जे और तस्करी के अपराध शामिल हैं.',
'Possession of weapons (avg/yr)':
'LSOA में प्रति वर्ष possession-of-weapons offences की average count, police.uk street-level crime data (2023-2025) से.',
'LSOA में प्रति वर्ष हथियार रखने के अपराधों की औसत संख्या, police.uk के सड़क-स्तर अपराध डेटा (2023-2025) से.',
'Public order (avg/yr)':
'LSOA में प्रति वर्ष public order offences की average count, police.uk street-level crime data (2023-2025) से. Fear, alarm या distress पैदा करने वाले acts शामिल.',
'LSOA में प्रति वर्ष सार्वजनिक व्यवस्था से जुड़े अपराधों की औसत संख्या, police.uk के सड़क-स्तर अपराध डेटा (2023-2025) से. इसमें भय, घबराहट या परेशानी पैदा करने वाले कृत्य शामिल हैं.',
'Other crime (avg/yr)':
'LSOA में प्रति वर्ष other criminal offences की average count, police.uk street-level crime data (2023-2025) से. कहीं और classified न होने वाले offences के लिए catch-all category.',
'LSOA में प्रति वर्ष अन्य आपराधिक अपराधों की औसत संख्या, police.uk के सड़क-स्तर अपराध डेटा (2023-2025) से. यह उन अपराधों के लिए सामान्य श्रेणी है जिन्हें कहीं और वर्गीकृत नहीं किया गया है.',
'Median age':
'Census 2021 (TS007A) से. LSOA में usual residents की median age, five-year age band counts से linear interpolation द्वारा calculated. Younger-population areas आमतौर पर urban, university towns या family-heavy होते हैं; higher medians rural और coastal areas में common हैं.',
'Census 2021 (TS007A) से. LSOA में सामान्य निवासियों की मध्य आयु, पांच-वर्षीय आयु समूहों की गणना से रैखिक इंटरपोलेशन द्वारा निकाली गई. युवा आबादी वाले क्षेत्र आमतौर पर शहरी, विश्वविद्यालय नगर या अधिक परिवारों वाले होते हैं; अधिक मध्य आयु वाले क्षेत्र आमतौर पर ग्रामीण और तटीय इलाकों में मिलते हैं.',
'% White':
'Census 2021 से. Local authority population का percentage जो White (English, Welsh, Scottish, Northern Irish, British, Irish, Gypsy or Irish Traveller, Roma या any other white background) के रूप में identify करता है.',
'Census 2021 से. स्थानीय प्राधिकरण की आबादी का प्रतिशत जो खुद को श्वेत (अंग्रेज़, वेल्श, स्कॉटिश, उत्तरी आयरिश, ब्रिटिश, आयरिश, जिप्सी या आयरिश ट्रैवलर, रोमा या किसी अन्य श्वेत पृष्ठभूमि) के रूप में पहचानता है.',
'% South Asian':
'Census 2021 से. Local authority population का percentage जो Indian, Pakistani, Bangladeshi या any other Asian background के रूप में identify करता है.',
'Census 2021 से. स्थानीय प्राधिकरण की आबादी का प्रतिशत जो खुद को भारतीय, पाकिस्तानी, बांग्लादेशी या किसी अन्य एशियाई पृष्ठभूमि के रूप में पहचानता है.',
'% Black':
'Census 2021 से. Local authority population का percentage जो Black, Black British, Caribbean या African के रूप में identify करता है.',
'Census 2021 से. स्थानीय प्राधिकरण की आबादी का प्रतिशत जो खुद को अश्वेत, अश्वेत ब्रिटिश, कैरिबियाई या अफ्रीकी के रूप में पहचानता है.',
'% East Asian':
'Census 2021 से. Local authority population का percentage जो Chinese के रूप में identify करता है.',
'Census 2021 से. स्थानीय प्राधिकरण की आबादी का प्रतिशत जो खुद को चीनी के रूप में पहचानता है.',
'% Mixed':
'Census 2021 से. Local authority population का percentage जो Mixed या multiple ethnic groups (White and Black Caribbean, White and Black African, White and Asian या other mixed/multiple background) के रूप में identify करता है.',
'Census 2021 से. स्थानीय प्राधिकरण की आबादी का प्रतिशत जो खुद को मिश्रित या कई जातीय समूहों (श्वेत और अश्वेत कैरिबियाई, श्वेत और अश्वेत अफ्रीकी, श्वेत और एशियाई या अन्य मिश्रित/बहुजातीय पृष्ठभूमि) के रूप में पहचानता है.',
'% Other':
'Census 2021 से. Local authority population का percentage जो another ethnic group (Arab या main categories से बाहर any other ethnic group) के रूप में identify करता है.',
'Census 2021 से. स्थानीय प्राधिकरण की आबादी का प्रतिशत जो खुद को किसी अन्य जातीय समूह (अरब या मुख्य श्रेणियों से बाहर किसी अन्य जातीय समूह) के रूप में पहचानता है.',
'Voter turnout (%)':
'July 2024 UK General Election में valid vote देने वाले registered electorate का proportion. Valid votes divided by electorate size. Higher turnout अक्सर affluent areas और closer contests से correlate करता है.',
'जुलाई 2024 के ब्रिटेन के आम चुनाव में वैध मत देने वाले पंजीकृत मतदाताओं का अनुपात. इसकी गणना वैध मतों को मतदाता सूची के आकार से भाग देकर की जाती है. अधिक मतदान आमतौर पर संपन्न क्षेत्रों और कड़ी चुनावी प्रतिस्पर्धा से जुड़ा होता है.',
'% Labour':
'July 2024 UK General Election में इस postcode को cover करने वाली constituency में Labour Party candidates को पड़े valid votes का percentage.',
'जुलाई 2024 के ब्रिटेन के आम चुनाव में इस पोस्टकोड को कवर करने वाले निर्वाचन क्षेत्र में Labour Party के उम्मीदवारों को पड़े वैध मतों का प्रतिशत.',
'% Conservative':
'July 2024 UK General Election में इस postcode को cover करने वाली constituency में Conservative Party को पड़े valid votes का percentage.',
'जुलाई 2024 के ब्रिटेन के आम चुनाव में इस पोस्टकोड को कवर करने वाले निर्वाचन क्षेत्र में Conservative Party को पड़े वैध मतों का प्रतिशत.',
'% Liberal Democrat':
'July 2024 UK General Election में इस postcode को cover करने वाली constituency में Liberal Democrats को पड़े valid votes का percentage.',
'जुलाई 2024 के ब्रिटेन के आम चुनाव में इस पोस्टकोड को कवर करने वाले निर्वाचन क्षेत्र में Liberal Democrats को पड़े वैध मतों का प्रतिशत.',
'% Reform UK':
'July 2024 UK General Election में इस postcode को cover करने वाली constituency में Reform UK को पड़े valid votes का percentage.',
'जुलाई 2024 के ब्रिटेन के आम चुनाव में इस पोस्टकोड को कवर करने वाले निर्वाचन क्षेत्र में Reform UK को पड़े वैध मतों का प्रतिशत.',
'% Green':
'July 2024 UK General Election में इस postcode को cover करने वाली constituency में Green Party को पड़े valid votes का percentage.',
'जुलाई 2024 के ब्रिटेन के आम चुनाव में इस पोस्टकोड को कवर करने वाले निर्वाचन क्षेत्र में Green Party को पड़े वैध मतों का प्रतिशत.',
'% Other parties':
'इस postcode की constituency में Labour, Conservative, Liberal Democrat, Reform UK और Green के अलावा अन्य parties को पड़े valid votes का percentage. Independents, Speaker और minor parties शामिल.',
'इस पोस्टकोड के निर्वाचन क्षेत्र में Labour, Conservative, Liberal Democrat, Reform UK और Green के अलावा अन्य पार्टियों को पड़े वैध मतों का प्रतिशत. इसमें निर्दलीय, स्पीकर और छोटे दल शामिल हैं.',
'Distance to nearest park (km)':
'Postcode से nearest park entrance तक सीधी रेखा में दूरी, kilometres में. Public parks, gardens, playing fields और play spaces को cover करता है. OS Open Greenspace dataset के access-point locations का उपयोग करता है, इसलिए बड़े park के किनारे properties सही short distance दिखाती हैं.',
'पोस्टकोड से निकटतम पार्क प्रवेश तक सीधी रेखा में दूरी, किलोमीटर में. इसमें सार्वजनिक पार्क, बगीचे, खेल मैदान और खेल स्थान शामिल हैं. OS Open Greenspace डेटा सेट के प्रवेश-बिंदु स्थानों का उपयोग करता है, इसलिए बड़े पार्क के किनारे स्थित संपत्तियां सही कम दूरी दिखाती हैं.',
'Number of parks within 1km':
'Property postcode centroid से 1km radius के भीतर कम से कम एक entrance रखने वाले public parks, gardens, playing fields और play spaces की संख्या. OS Open Greenspace dataset (Ordnance Survey) से derived, accurate proximity matching के लिए park entrance locations उपयोग करता है.',
'Number of restaurants within 2km':
'Postcode से 2km के भीतर restaurants, cafés और food-service venues. Source: OpenStreetMap.',
'Number of grocery shops and supermarkets within 2km':
'Property postcode centroid से 2km radius के भीतर supermarkets, convenience stores और other grocery shops की संख्या. OpenStreetMap POI data से derived.',
'संपत्ति के पोस्टकोड केंद्र से 1 km दायरे में कम से कम एक प्रवेश रखने वाले सार्वजनिक पार्कों, बगीचों, खेल मैदानों और खेल स्थानों की संख्या. OS Open Greenspace डेटा सेट (Ordnance Survey) से निकाला गया, और सटीक निकटता मिलान के लिए पार्क प्रवेश स्थानों का उपयोग करता है.',
'Noise (dB)':
'Defra Strategic Noise Mapping Round 4 (2022) से road-noise level in decibels (Lden, 24-hour weighted average). Ground से 4m ऊपर 10m grid पर modelled. लगभग 55 dB से ऊपर शोर आमतौर पर noticeable होता है; लगभग 70 dB से ऊपर WHO harmful मानता है.',
'Defra Strategic Noise Mapping Round 4 (2022) से सड़क-शोर स्तर, डेसीबल में (Lden, 24-घंटे भारित औसत). जमीन से 4 m ऊपर 10 m ग्रिड पर मॉडल किया गया. लगभग 55 dB से ऊपर शोर आमतौर पर महसूस होता है; लगभग 70 dB से ऊपर WHO इसे हानिकारक मानता है.',
'Max available download speed (Mbps)':
'Ofcom Connected Nations 2025 से किसी भी provider द्वारा उपलब्ध maximum fixed-broadband download speed. Theoretical maximum दर्शाता है, achieved speed नहीं. 10 Mbps = basic, 30 = superfast, 100+ = ultrafast, 1000 = gigabit.',
'Ofcom Connected Nations 2025 से किसी भी प्रदाता द्वारा उपलब्ध अधिकतम स्थिर ब्रॉडबैंड डाउनलोड गति. यह सैद्धांतिक अधिकतम दिखाता है, वास्तविक प्राप्त गति नहीं. 10 Mbps = बुनियादी, 30 = तेज, 100+ = अत्यंत तेज, 1000 = गीगाबिट.',
Schools:
'चुनी गई Ofsted रेटिंग और दूरी के अनुसार पास के सरकारी वित्तपोषित प्राइमरी या सेकेंडरी स्कूल फिल्टर करता है. उपलब्ध सीमाएं आमतौर पर 2 किमी या 5 किमी के भीतर अच्छी या उत्कृष्ट रेटिंग वाले स्कूलों, या सिर्फ उत्कृष्ट स्कूलों को कवर करती हैं.',
'Specific crimes':
'LSOA में सालाना औसत घटनाओं के आधार पर एक समय में एक सड़क-स्तर अपराध श्रेणी फिल्टर करता है. मान 2023-2025 के police.uk डेटा से आते हैं और चोरी, वाहन अपराध या असामाजिक व्यवहार जैसी श्रेणियों को अलग से देखने में मदद करते हैं.',
Ethnicities:
'Census 2021 के आधार पर चुने गए जातीय समूह की आबादी का प्रतिशत फिल्टर करता है. अलग-अलग क्षेत्रों की स्थानीय संरचना की तुलना के लिए एक समय में एक श्रेणी लागू होती है.',
'POI distance':
'चुने गए प्रकार के सबसे नजदीकी रुचि-स्थल तक दूरी फिल्टर करता है, जो पोस्टकोड केंद्र से निकाली जाती है. स्थानीय सेवाओं और सुविधाओं तक पहुंच की तुलना के लिए OpenStreetMap रुचि-स्थलों का उपयोग करता है.',
'POIs within 2km':
'पोस्टकोड के 2 किमी दायरे में चुने गए प्रकार के रुचि-स्थलों की संख्या फिल्टर करता है. पैदल या कम दूरी में पहुंच योग्य सुविधाओं की तुलना के लिए उपयोगी.',
'POIs within 5km':
'पोस्टकोड के 5 किमी दायरे में चुने गए प्रकार के रुचि-स्थलों की संख्या फिल्टर करता है. किसी क्षेत्र के आसपास सेवाओं, दुकानों और सुविधाओं की व्यापक उपलब्धता की तुलना के लिए उपयोगी.',
'Political vote share':
'हर पोस्टकोड को कवर करने वाले निर्वाचन क्षेत्र में, जुलाई 2024 के ब्रिटेन के आम चुनाव में चुनी गई पार्टी को मिले वोटों का प्रतिशत फिल्टर करता है.',
},
hu: {
'Property type':
@ -562,27 +594,25 @@ export const details: Record<string, Record<string, string>> = {
'Price per sqm':
'Az utolsó ismert adásvételi árat az EPC tanúsítványból származó teljes alapterülettel elosztva számítják ki. Hasznos a különböző méretű ingatlanok értékének összehasonlításához. Csak akkor elérhető, ha mind az ár, mind az alapterület adatai rendelkezésre állnak.',
'Est. price per sqm':
'A modellezett becsült aktuális árat az EPC tanúsítványból származó teljes alapterülettel elosztva számítják ki. Naprakészebb ár/terület összehasonlítást nyújt, mint a korábbi adásvételi ár per sqm.',
'A modellezett becsült aktuális árat az EPC tanúsítványból származó teljes alapterülettel elosztva számítják ki. Naprakészebb ár/terület összehasonlítást nyújt, mint a korábbi négyzetméterenkénti adásvételi ár.',
'Estimated monthly rent':
'Az ONS Price Index of Private Rents (PIPR) alapján számított átlagos havi bérleti díj, helyi hatóság és hálószobák száma szerint párosítva.',
'Total floor area (sqm)':
'Az Energy Performance Certificate felmérése során mért teljes hasznos alapterület négyzetméterben. Tartalmazza az összes lakható helyiséget, de kizárja a garázsokat, melléképületeket és külső területeket.',
'Az EPC-tanúsítvány felmérése során mért teljes hasznos alapterület négyzetméterben. Tartalmazza az összes lakható helyiséget, de kizárja a garázsokat, melléképületeket és külső területeket.',
'Number of bedrooms & living rooms':
'Az Energy Performance Certificate-ben rögzített összes lakható helyiség száma (hálószobák és nappali szobák összege). A konyhák és fürdőszobák jellemzően nem számítanak bele, kivéve, ha elég nagyok ahhoz, hogy lakható helyiségnek minősüljenek.',
'Az EPC-tanúsítványban rögzített összes lakható helyiség száma (hálószobák és nappali szobák összege). A konyhák és fürdőszobák jellemzően nem számítanak bele, kivéve, ha elég nagyok ahhoz, hogy lakható helyiségnek minősüljenek.',
'Construction year':
"Az EPC-ben szereplő építési korszak alapján (pl. '19301949') a középértékkel becsülve. Régebbi épületeknél kevésbé pontos, ahol a korcsoport több évtizedet ölel fel.",
'Date of last transaction':
'Az ingatlan legutóbbi rögzített adásvételének dátuma az HM Land Registry Price Paid adatokból. Az adatokban dátum/idő formátumban tárolódik; szűréshez és diagramokhoz törtéves formátumra konvertálva.',
'Former council house':
'Az Energy Performance Certificate adatok TENURE mezőjéből származtatva. Ha az ingatlan bármely EPC tanúsítványa szociális bérlakásként rögzítette a bérleti jogviszonyt, ez azt jelzi, hogy az ingatlan az adott ellenőrzés idején önkormányzati vagy lakásszövetkezeti állomány volt. Azok az ingatlanok, amelyeket később értékesítettek (pl. Right to Buy útján), megőrzik ezt a jelzést.',
'Az EPC-adatok TENURE mezőjéből származtatva. Ha az ingatlan bármely EPC tanúsítványa szociális bérlakásként rögzítette a bérleti jogviszonyt, ez azt jelzi, hogy az ingatlan az adott ellenőrzés idején önkormányzati vagy lakásszövetkezeti állomány volt. Azok az ingatlanok, amelyeket később értékesítettek (pl. Right to Buy útján), megőrzik ezt a jelzést.',
'Current energy rating':
'Az Energy Performance Certificate aktuális energiahatékonysági besorolása. A-tól (leghatékonyabb) G-ig (legkevésbé hatékony) terjed. Az ingatlan alapterületre vetített energiafelhasználásán alapul.',
'Az EPC-tanúsítvány aktuális energiahatékonysági besorolása. A-tól (leghatékonyabb) G-ig (legkevésbé hatékony) terjed. Az ingatlan alapterületre vetített energiafelhasználásán alapul.',
'Potential energy rating':
'Az Energy Performance Certificate potenciális energiahatékonysági besorolása, amennyiben az EPC-jelentésben ajánlott összes költséghatékony fejlesztést elvégeznék. A-tól (leghatékonyabb) G-ig (legkevésbé hatékony) terjed.',
'Az EPC-tanúsítvány potenciális energiahatékonysági besorolása, amennyiben az EPC-jelentésben ajánlott összes költséghatékony fejlesztést elvégeznék. A-tól (leghatékonyabb) G-ig (legkevésbé hatékony) terjed.',
'Interior height (m)':
'Az Energy Performance Certificate felmérése során rögzített átlagos belső padló-mennyezet magasság méterben. A teljes belső térfogatot osztják a teljes alapterülettel.',
'Distance to nearest train or tube station (km)':
'Légvonalbeli távolság kilométerben az irányítószámtól a legközelebbi vasút- vagy metró-/városi vasút-/villamosmegállóig.',
'Az EPC-tanúsítvány felmérése során rögzített átlagos belső padló-mennyezet magasság méterben. A teljes belső térfogatot osztják a teljes alapterülettel.',
'Good+ primary schools within 2km':
'2 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Good+ secondary schools within 2km':
@ -678,14 +708,24 @@ export const details: Record<string, Record<string, string>> = {
'Distance to nearest park (km)':
'Légvonalbeli távolság kilométerben az irányítószámtól a legközelebbi park bejáratáig. Magában foglalja a közparkokat, kerteket, játszótereket és szabadidős területeket. Az OS Open Greenspace adatkészlet hozzáférési pont helyszíneit használja, így a nagy park szomszédságában lévő ingatlanok helyesen rövid távolságot mutatnak.',
'Number of parks within 1km':
'A közparkok, kertek, játszóterek és szabadidős területek száma, amelyeknek legalább egy bejárata van az ingatlan irányítószám centroidjától számított 1 km-es körzetben. Az OS Open Greenspace adatkészletből (Ordnance Survey) származik, park bejárati helyszíneket használva a pontos közelségi egyeztetéshez.',
'Number of restaurants within 2km':
'Az ingatlan irányítószámjától 2 km-en belüli éttermek, kávézók és vendéglátóhelyek. Forrás: OpenStreetMap.',
'Number of grocery shops and supermarkets within 2km':
'Az ingatlan irányítószám centroidjától számított 2 km-es körzetben lévő szupermarketek, kisboltok és egyéb élelmiszerboltok száma. Az OpenStreetMap POI-adatokból származtatva.',
'A közparkok, kertek, játszóterek és szabadidős területek száma, amelyeknek legalább egy bejárata van az ingatlan irányítószámának középpontjától számított 1 km-es körzetben. Az OS Open Greenspace adatkészletből (Ordnance Survey) származik, park bejárati helyszíneket használva a pontos közelségi egyeztetéshez.',
'Noise (dB)':
'Közúti zajszint decibel (Lden, 24 órás súlyozott átlag) értékben, a Defra Stratégiai Zajtérképezés 4. fordulójából (2022). 4 m magasságban, 10 m-es rácson modellezve. ~55 dB felett általában érzékelhető; ~70 dB felett az WHO károsnak minősíti.',
'Közúti zajszint decibel (Lden, 24 órás súlyozott átlag) értékben, a Defra Stratégiai Zajtérképezés 4. fordulójából (2022). 4 m magasságban, 10 m-es rácson modellezve. ~55 dB felett általában érzékelhető; ~70 dB felett a WHO károsnak minősíti.',
'Max available download speed (Mbps)':
'Bármely szolgáltatótól elérhető maximális rögzített szélessávú letöltési sebesség, az Ofcom Connected Nations 2025 adataiból. Az elméleti maximumot jelöli, nem a valós sebességet. 10 Mbps = alapszintű, 30 = szupergyors, 100+ = ultragyors, 1000 = gigabites.',
Schools:
'A közeli állami finanszírozású általános vagy középiskolákat szűri a kiválasztott Ofsted minősítés és távolság alapján. Az elérhető küszöbök általában a 2 km-en vagy 5 km-en belüli Jó vagy Kiemelkedő, illetve csak Kiemelkedő iskolákat fedik le.',
'Specific crimes':
'Egyszerre egy utcai bűncselekmény-kategóriát szűr az LSOA éves átlagos esetszámai alapján. Az értékek a 2023-2025-ös police.uk adatokból származnak, és segítenek külön vizsgálni például a betörést, járműbűnözést vagy antiszociális viselkedést.',
Ethnicities:
'A kiválasztott etnikai csoport népességi arányát szűri a 2021-es népszámlálás alapján. Egyszerre egy kategória alkalmazható, hogy a helyi összetétel összehasonlítható legyen a területek között.',
'POI distance':
'A kiválasztott típusú legközelebbi érdekes pont távolságát szűri, az irányítószám középpontjától számítva. OpenStreetMap POI-adatokat használ a helyi szolgáltatások és létesítmények elérhetőségének összehasonlításához.',
'POIs within 2km':
'A kiválasztott típusú érdekes pontok számát szűri az irányítószám 2 km-es körzetében. Hasznos a gyalogosan vagy rövid úton elérhető létesítmények összehasonlításához.',
'POIs within 5km':
'A kiválasztott típusú érdekes pontok számát szűri az irányítószám 5 km-es körzetében. Hasznos a környék szélesebb szolgáltatás-, üzlet- és létesítménykínálatának összehasonlításához.',
'Political vote share':
'A kiválasztott párt szavazatarányát szűri az egyes irányítószámokat lefedő választókerületben, a 2024. júliusi brit parlamenti választás alapján.',
},
};

View file

@ -7,6 +7,8 @@ const de: Translations = {
cancel: 'Abbrechen',
close: 'Schließen',
delete: 'Löschen',
finish: 'Fertig',
language: 'Sprache',
open: 'Öffnen',
share: 'Teilen',
copy: 'Kopieren',
@ -21,6 +23,7 @@ const de: Translations = {
viewDataSource: 'Datenquelle ansehen',
total: 'Gesamt',
min: 'Min.',
max: 'Max.',
or: 'oder',
area: 'Gebiet',
properties: 'Immobilien',
@ -31,6 +34,11 @@ const de: Translations = {
clickForDetails: 'Für Details klicken',
property: 'Immobilie',
propertiesPlural: 'Immobilien',
places: 'Orte',
noData: 'Keine Daten',
allLow: 'Alles niedrig',
connectingToServer: 'Verbindung zum Server...',
closePane: 'Bereich schließen',
},
// ── Header / Nav ───────────────────────────────────
@ -49,7 +57,7 @@ const de: Translations = {
exportToExcel: 'Als Excel exportieren',
exportReady: 'Export bereit. Der Download sollte starten.',
exportFailed: 'Export fehlgeschlagen.',
exportTimedOut: 'Export timed out. Bitte erneut versuchen.',
exportTimedOut: 'Zeitüberschreitung beim Export. Bitte erneut versuchen.',
exportUnavailable: 'Die Karte lädt noch. Bitte gleich erneut versuchen.',
exportEmpty: 'Der Export wurde abgeschlossen, aber die Datei ist leer.',
openMenu: 'Menü öffnen',
@ -72,13 +80,43 @@ const de: Translations = {
home: 'Startseite',
},
// ── Toasts ─────────────────────────────────────────
toasts: {
propertySaved: 'Immobilie gespeichert!',
viewSaved: 'Gespeicherte ansehen',
dontShowAgain: 'Nicht erneut anzeigen',
},
// ── SEO Page Chrome ────────────────────────────────
seo: {
breadcrumb: 'Breadcrumb',
reviewDataSources: 'Datenquellen prüfen',
whatYouCanCompare: 'Was Sie vergleichen können',
whatYouCanCompareDesc:
'Jede Seite ist für echte Vorauswahl gedacht: unmögliche Orte aussortieren, die verbleibenden Postleitzahlen vergleichen und entscheiden, was als Nächstes geprüft werden soll.',
howToUseIt: 'So nutzen Sie es',
howToUseItDesc:
'Nutzen Sie diese Abläufe, damit die Seite schon hilft, bevor Sie ein Immobilienportal öffnen oder eine Besichtigung buchen.',
methodAndLimitations: 'Methode und Grenzen',
methodAndLimitationsDesc:
'Die Daten dienen zum Vergleichen und Vorauswählen. Wichtige Entscheidungen brauchen weiterhin aktuelle Inserate, fachliche Prüfungen und direkte lokale Validierung.',
questionsBuyersAsk: 'Fragen von Käufern',
relatedGuides: 'Verwandte Leitfäden',
relatedGuidesDesc:
'Navigieren Sie mit kanonischen internen Links durch die indexierten öffentlichen Seiten.',
frequentlyAskedQuestions: 'Häufig gestellte Fragen',
relatedPages: 'Verwandte Seiten',
relatedPagesDesc:
'Folgen Sie diesen internen Links, um denselben Immobiliensuch-Workflow aus einem anderen Blickwinkel zu vergleichen.',
},
// ── Auth Modal ─────────────────────────────────────
auth: {
logIn: 'Anmelden',
createAccount: 'Konto erstellen',
resetPassword: 'Passwort zurücksetzen',
valueProp:
'Speichere Suchen, merke dir Immobilien und erstelle eine Shortlist passender Gebiete.',
'Speichere Suchen, merke dir Immobilien und erstelle eine Auswahlliste passender Gebiete.',
continueWithGoogle: 'Weiter mit Google',
email: 'E-Mail',
emailPlaceholder: 'du@beispiel.de',
@ -143,7 +181,7 @@ const de: Translations = {
oneTimeLifetime: 'Einmalzahlung, lebenslanger Zugang.',
upgradeToFullMap: 'Zur Vollversion upgraden',
chooseFilters:
'Klicke auf Hinzufügen, um zu filtern. Die kleinen Buttons zeigen Daten oder färben die Karte.',
'Klicke auf Hinzufügen, um zu filtern. Die kleinen Schaltflächen zeigen Daten oder färben die Karte.',
searchFeatures: 'Filter durchsuchen...',
noMatchingFeatures: 'Keine passenden Filter',
tryDifferentSearch: 'Versuche einen anderen Suchbegriff',
@ -164,6 +202,20 @@ const de: Translations = {
clearAllSavePrompt: 'Möchtest du deine aktuellen Filter vor dem Löschen speichern?',
saveAndClear: 'Speichern & löschen',
clearWithoutSaving: 'Ohne Speichern löschen',
withoutThisFilter: '+{{value}} ohne diesen Filter',
schoolType: 'Schultyp',
schoolRating: 'Schulbewertung',
schoolDistance: 'Schulentfernung',
primary: 'Grundschule',
secondary: 'Weiterführend',
rating: 'Bewertung',
goodPlus: 'Gut+',
outstanding: 'Hervorragend',
distance: 'Entfernung',
crimeType: 'Deliktart',
ethnicity: 'Ethnie',
poiType: 'POI-Typ',
party: 'Partei',
},
// ── Philosophy Popup ───────────────────────────────
@ -248,6 +300,16 @@ const de: Translations = {
previewing: 'Vorschau von \u201c{{name}}\u201d',
},
// ── Map ────────────────────────────────────────────
map: {
ogTitle: 'Ihre perfekte Postleitzahl',
ogPropertyPrices: 'Immobilienpreise',
ogEnergyRatings: 'Energiebewertungen',
ogSchools: 'Schulen',
ogCrimeStats: 'Kriminalitätsdaten',
ogTransport: 'Verkehr',
},
// ── Properties Pane ────────────────────────────────
propertyCard: {
unknownAddress: 'Unbekannte Adresse',
@ -278,16 +340,20 @@ const de: Translations = {
areaOverview: 'Übersicht',
statsFor: 'Statistiken für alle Immobilien in diesem {{type}}',
matchingFilters: ', die allen aktiven Filtern entsprechen',
filtersAffectStats:
'Filter werden hier angewendet: Werte, Diagramme und Immobilienzahlen nutzen die {{count}} aktiven Filter.',
statsBasis: 'Statistikbasis',
matchingFiltersOption: 'Passende Filter',
allPropertiesOption: 'Alle Immobilien',
filtersAffectStats: 'Die Statistiken in diesem Bereich verwenden {{count}} aktive Filter.',
filtersIgnoredForStats:
'Statistiken für alle Immobilien im ausgewählten Gebiet werden angezeigt.',
noFiltersAffectStats:
'Fügen Sie Filter hinzu, um diese Werte für passende Immobilien neu zu berechnen.',
noFilteredMatches: 'Keine Immobilien in diesem Gebiet entsprechen Ihren Filtern.',
unfilteredAreaCount:
'{{count}} Immobilien gibt es hier vor den Filtern; der Ort ist gültig, wird aber herausgefiltert.',
noUnfilteredAreaProperties:
'In diesem ausgewählten Gebiet wurden auch vor den Filtern keine Immobilien gefunden.',
relaxFiltersHint: 'Lockern oder löschen Sie Filter, um Immobilien in diesem Gebiet zu sehen.',
'Keine aktiven Filter; die Statistiken umfassen alle Immobilien in diesem Gebiet.',
filteredStatsEmpty: 'Gefilterte Statistiken sind leer',
showAllStatsHint:
'Vor den Filtern gibt es {{count}} Immobilien. Wechseln Sie zu allen Immobilien, um dieses Gebiet zu prüfen.',
showAllStatsFallback:
'Wechseln Sie zu allen Immobilien, um dieses Gebiet ohne aktive Filter zu prüfen.',
showAllStats: 'Alle Immobilien anzeigen',
viewProperties: '{{count}} Immobilien ansehen',
viewPropertiesShort: 'Immobilien ansehen',
priceHistory: 'Preisentwicklung',
@ -362,12 +428,33 @@ const de: Translations = {
'Legen Sie Budget, Pendelzeit, Schulen, Sicherheit, Lärm, Breitband und Lebensstil fest. Perfect Postcode scannt Englands Postleitzahlen und zeigt Orte, die wirklich passen, auch Gegenden, die Sie nie in ein Immobilienportal eingegeben hätten.',
exploreTheMap: 'Passende Postleitzahlen finden',
seeTheDifference: 'So funktioniert es',
productDemoLabel: 'Perfect Postcode-Produktdemo',
playProductDemo: 'Perfect Postcode-Produktdemo abspielen',
scrollToProductDemo: 'Zur Produktdemo scrollen',
showcaseHeader: 'So funktioniert es',
showcaseContext: 'So funktioniert Perfect Postcode',
showcaseFeaturePriceShort: 'Preis',
showcaseFeatureNoiseShort: 'Lärm',
showcaseFeatureSchoolsShort: 'Schulen',
showcaseFeatureTravelShort: 'Fahrzeit',
showcaseGoodPrimariesNearby: '{{count}}+ gute Grundschulen in der Nähe',
showcaseWithinRail: 'Innerhalb von {{count}} Min. zur Bahn',
showcaseMatchingHomesLabel: 'Passende Immobilien',
showcaseMatchingHomes: '{{value}} passende Immobilien',
showcaseMedianPrice: '{{value}} Median',
showcaseJourneyRoutes: 'Routen',
showcaseNearby: '{{value}} in der Nähe',
showcasePoliticalVoteShare: 'Politischer Stimmenanteil',
showcaseLotsMore: '...und vieles mehr',
showcaseMinutes: '{{count}} Min.',
showcaseSendShortlist: 'Auswahlliste senden',
showcaseDownloadXlsx: '.xlsx herunterladen',
showcaseTopThree: 'Top 3',
showcaseScoutBullet1:
'Gehen Sie durch die Straßen, bevor die Inseratsuche Ihre Optionen einengt.',
showcaseScoutBullet2:
'Testen Sie den Pendelweg von einer echten Haustür, nicht nur anhand eines Bezirksnamens.',
showcaseScoutBullet3: 'Vergleichen Sie Besichtigungen mit belastbaren Daten im Rücken.',
showcaseStep1Tab: 'Filtern',
showcaseStep1Title: 'Aus vagen Wünschen eine präzise Suche machen',
showcaseStep1Body:
@ -382,21 +469,21 @@ const de: Translations = {
'Durchsuchen Sie England nach Passung, statt mit vertrauten Gebietsnamen zu beginnen. Verborgene Ecken werden sichtbar, bevor Immobilienportale Ihre Suche einengen.',
showcaseStep2Region: 'Großraum London',
showcaseStep2Sources: 'Land Registry · ONS · Ofsted · DfT',
showcaseStep2ClustersLabel: 'Passende Cluster',
showcaseStep2ClustersLabel: 'Passende Gruppen',
showcaseStep3Tab: 'Prüfen',
showcaseStep3Title: 'Prüfen, warum eine Postleitzahl passt',
showcaseStep3Body:
'Öffnen Sie ein passendes Gebiet und prüfen Sie Preise, Sicherheit, Schulen, Breitband und Kompromisse in einem Panel, bevor Sie ein Wochenende dort verbringen.',
'Öffnen Sie ein passendes Gebiet und prüfen Sie Preise, Sicherheit, Schulen, Breitband und Kompromisse in einem Bereich, bevor Sie ein Wochenende dort verbringen.',
showcaseStep3HeaderArea: 'Ihre perfekte Postleitzahl',
showcaseStep3HeaderFit: 'Nachbarschaftsbelege',
showcaseStep3Stat1Label: 'Verkaufspreis-Trend',
showcaseStep3Stat2Label: 'Kriminalität',
showcaseStep3Stat2Value: 'Unter Borough-Schnitt',
showcaseStep3Stat2Value: 'Unter dem Bezirksdurchschnitt',
showcaseStep3Stat3Label: 'Median-Alter',
showcaseStep3Stat4Label: 'Breitband',
showcaseStep3Stat4Value: '1 Gbps verfügbar',
showcaseStep3Stat5Label: 'Grundschulen',
showcaseStep3Stat5Value: '3 „outstanding“ in 1 Meile',
showcaseStep3Stat5Value: '3 „Hervorragend“ innerhalb von 1 Meile',
showcaseStep4Tab: 'Erkunden',
showcaseStep4Title: 'Selbst vor Ort prüfen',
showcaseStep4Body:
@ -404,7 +491,7 @@ const de: Translations = {
showcaseStep4FileName: 'areas-to-scout.xlsx',
showcaseStep4ExportLabel: 'Nach Excel exportieren',
showcaseStep4ColPostcode: 'Postleitzahl',
showcaseStep4ColScore: 'Fit',
showcaseStep4ColScore: 'Passung',
showcaseStep4ColCommute: 'Pendeln',
showcaseStep4ColPrice: 'Median verkauft',
showcaseStep4Conclusion: 'Von hier aus können Sie Ihre Suche beginnen.',
@ -436,10 +523,10 @@ const de: Translations = {
compPropertyData: 'Historie auf Immobilienebene',
compPropertyDataSub: '(Verkaufspreise, EPC, Wohnfläche, Schätzwert)',
compFilters: '56 Filter, die zusammenarbeiten',
compFiltersSub: '(nicht eine Postleitzahl oder ein Listing nach dem anderen)',
compFiltersSub: '(nicht eine Postleitzahl oder ein Inserat nach dem anderen)',
ctaTitle: 'Hören Sie auf zu raten, wo Sie kaufen sollen.',
ctaDescription:
'Erstellen Sie eine Shortlist von Postleitzahlen, die zu Ihrem echten Leben passen, und prüfen Sie sie dann vor Ort.',
'Erstellen Sie eine Auswahlliste von Postleitzahlen, die zu Ihrem echten Leben passen, und prüfen Sie sie dann vor Ort.',
},
// ── Pricing Page ───────────────────────────────────
@ -478,7 +565,7 @@ const de: Translations = {
learnPage: {
faq: 'Häufige Fragen',
dataSources: 'Datenquellen',
support: 'Support',
support: 'Hilfe',
dataSourcesIntro:
'Diese Anwendung kombiniert {{count}} offene Datensätze zu Immobilienpreisen, Energieeffizienz, Verkehr, Demografie, Kriminalität, Umwelt und mehr.',
faqIntro:
@ -499,18 +586,18 @@ const de: Translations = {
attrOsmLicense: 'verfügbar unter der',
attrOsmLicenseLink: 'Open Data Commons Open Database License (ODbL)',
// Data source names & descriptions
dsPricePaidName: 'Price Paid Data',
dsPricePaidName: 'Verkaufspreisdaten',
dsPricePaidOrigin: 'HM Land Registry',
dsPricePaidUse: 'Vollständige historische Immobilien-Verkaufspreise für England.',
dsEpcName: 'Energy Performance Certificates (EPC)',
dsEpcName: 'Energieausweise (EPC)',
dsEpcOrigin: 'Ministry of Housing, Communities & Local Government',
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: 'Nationales Postleitzahlenverzeichnis (NSPL)',
dsNsplOrigin: 'ONS / ArcGIS',
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: 'Englische Deprivationsindizes 2025',
dsIodOrigin: 'Ministry of Housing, Communities & Local Government',
dsIodUse:
'Nationale Benachteiligungsperzentile für Einkommen, Beschäftigung, Bildung, Gesundheit, Kriminalität und Wohnumfeld für jedes Viertel in England.',
@ -518,43 +605,43 @@ const de: Translations = {
dsEthnicityOrigin: 'ONS',
dsEthnicityUse:
'Bevölkerungsanteile nach ethnischer Gruppe (südasiatisch, ostasiatisch, schwarz, gemischt, weiß, andere) pro Bezirk.',
dsCrimeName: 'Street-level Crime Data',
dsCrimeName: 'Kriminalitätsdaten auf Straßenebene',
dsCrimeOrigin: 'data.police.uk',
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',
dsOsmUse:
'Sehenswürdigkeiten und Einrichtungen wie Geschäfte, Restaurants, Gesundheitseinrichtungen, Freizeit, Tourismus und mehr in ganz Großbritannien.',
dsGeolytixRetailName: 'GEOLYTIX Grocery Retail Points',
dsGeolytixRetailName: 'GEOLYTIX-Lebensmittelhandelsstandorte',
dsGeolytixRetailOrigin: 'GEOLYTIX',
dsGeolytixRetailUse:
'Supermarkt- und Convenience-Store-Standorte im Vereinigten Königreich, darunter Ketten wie Waitrose, Tesco, Sainsburys, Asda, Morrisons, Aldi, Lidl, Co-op, M&S, Iceland und Spar.',
dsGreenspaceName: 'OS Open Greenspace',
dsGreenspaceName: 'OS-Open-Greenspace-Daten',
dsGreenspaceOrigin: 'Ordnance Survey',
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 (Haltestellen des öffentlichen Verkehrs)',
dsNaptanOrigin: 'Department for Transport',
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-Lärmkartierung',
dsNoiseOrigin: 'Defra / Environment Agency',
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-Schulinspektionen',
dsOfstedOrigin: 'Ofsted',
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-Breitbandleistung',
dsBroadbandOrigin: 'Ofcom',
dsBroadbandUse:
'Festnetz-Breitbandabdeckung und maximale Download-Geschwindigkeiten nach Gebiet aus Ofcom Connected Nations 2025.',
dsCouncilTaxName: 'Council Tax Levels 2025-26',
dsCouncilTaxName: 'Council-Tax-Sätze 2025-26',
dsCouncilTaxOrigin: 'Ministry of Housing, Communities & Local Government',
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: 'Statistiken zum privaten Mietmarkt',
dsRentalOrigin: 'ONS / Valuation Office Agency',
dsRentalUse:
'Monatliche Medianmieten des privaten Mietmarkts nach Bezirk und Schlafzimmerkategorie (Okt. 2022 - Sept. 2023). Über Bezirkscode und geschätzte Schlafzimmeranzahl mit Immobilien verknüpft.',
@ -590,17 +677,17 @@ const de: Translations = {
'Die Reisezeiten werden im Voraus für jedes gespeicherte Ziel berechnet. Wir prüfen, welche Postleitzahlen dieses Ziel per Auto, Fahrrad, zu Fuß oder mit öffentlichen Verkehrsmitteln erreichen können, und speichern diese Ergebnisse, damit die Karte beim Filtern schnell reagiert.',
faqCommute2Q: 'Was sollte ich über die Reisezeitwerte wissen?',
faqCommute2A:
'Zeiten für öffentliche Verkehrsmittel basieren auf einem Pendelweg an Wochentagen morgens, mit Abfahrten zwischen 07:30 und 08:30. Die normale Einstellung zeigt eine typische Fahrt in diesem Zeitraum. Es sind Planungswerte, keine Live-Verspätungen, Verkehrsmeldungen oder kurzfristigen Gleiswechsel.',
faqCommute3Q: 'Wann sollte ich den Bestfall-Button nutzen?',
'Zeiten für öffentliche Verkehrsmittel basieren auf einem Pendelweg an Wochentagen morgens, mit Abfahrten zwischen 07:30 und 08:30. Die normale Einstellung zeigt eine typische Fahrt in diesem Zeitraum. Es sind Planungswerte, keine Echtzeitverspätungen, Verkehrsmeldungen oder kurzfristigen Gleiswechsel.',
faqCommute3Q: 'Wann sollte ich die Bestfall-Schaltfläche nutzen?',
faqCommute3A:
'Nutzen Sie den Bestfall-Button für öffentliche Verkehrsmittel, wenn Sie die Fahrt mit gut gewählter Abfahrt und guten Anschlüssen sehen möchten. Lassen Sie ihn ausgeschaltet, wenn Sie den Alltagsvergleich möchten.',
'Nutzen Sie die Bestfall-Schaltfläche für öffentliche Verkehrsmittel, wenn Sie die Fahrt mit gut gewählter Abfahrt und guten Anschlüssen sehen möchten. Lassen Sie sie ausgeschaltet, wenn Sie den Alltagsvergleich möchten.',
// FAQ items — Budget and Value
faqBudget1Q: 'Wie schätzen Sie aktuelle Immobilienpreise?',
faqBudget1A:
'Die Schätzung beginnt mit dem letzten bei HM Land Registry erfassten Verkaufspreis. Wir bringen diesen Verkauf näher an den heutigen Markt, indem wir ansehen, wie sich ähnliche Häuser entwickelt haben, besonders Häuser desselben Typs in der Nähe. Wenn es nur wenige lokale Verkäufe gibt, stützt sich die Schätzung stärker auf Trends in einem größeren Gebiet. Danach wird sie mit nahegelegenen jüngeren Verkäufen und der Wohnfläche abgeglichen.',
faqBudget2Q: 'Warum den geschätzten aktuellen Preis statt des letzten Verkaufspreises nutzen?',
faqBudget2A:
'Der letzte Verkaufspreis kann Jahre oder Jahrzehnte alt sein, und Angebotspreise zeigen nur, was heute zum Verkauf steht. Der geschätzte aktuelle Preis bringt ältere Verkäufe näher an den heutigen Markt, damit Sie mehr Häuser vergleichen und Gebiete mit möglichem Wert erkennen können, bevor passende Inserate erscheinen. Nutzen Sie ihn als Orientierung für Ihre Shortlist, nicht als Bankbewertung.',
'Der letzte Verkaufspreis kann Jahre oder Jahrzehnte alt sein, und Angebotspreise zeigen nur, was heute zum Verkauf steht. Der geschätzte aktuelle Preis bringt ältere Verkäufe näher an den heutigen Markt, damit Sie mehr Häuser vergleichen und Gebiete mit möglichem Wert erkennen können, bevor passende Inserate erscheinen. Nutzen Sie ihn als Orientierung für Ihre Auswahlliste, nicht als Bankbewertung.',
// FAQ items — Safety and Neighbourhood
faqSafety1Q: 'Welche Art von Kriminalität ist rund um diese Postleitzahl häufig?',
faqSafety1A:
@ -612,7 +699,7 @@ const de: Translations = {
faqFamilies1Q:
'Welche Gebiete haben die richtige Mischung aus Schulen, Platz, Sicherheit und Pendelzeit?',
faqFamilies1A:
'Legen Sie Schulbewertungen, Kriminalität, Parks, Pendelzeit, Platz, Haustyp und Budget auf eine Karte. Das Ergebnis ist eine praktische Familien-Shortlist statt vieler getrennter Suchen.',
'Legen Sie Schulbewertungen, Kriminalität, Parks, Pendelzeit, Platz, Haustyp und Budget auf eine Karte. Das Ergebnis ist eine praktische Familien-Auswahlliste statt vieler getrennter Suchen.',
faqFamilies2Q: 'Beweist das, dass ich im Einzugsgebiet einer Schule liege?',
faqFamilies2A:
'Nein. Wir zeigen nahegelegene Schulqualität und lokale Bildungsinformationen, aber Aufnahmegebiete und Prioritätsregeln können sich ändern. Nutzen Sie Perfect Postcode zur Auswahl und prüfen Sie Einzugsgebiete anschließend bei der Schule oder Gemeinde.',
@ -620,26 +707,26 @@ const de: Translations = {
faqEnv1Q:
'Wie vermeide ich eine laute Straße, ohne Pendelzeit oder Breitbandqualität zu verlieren?',
faqEnv1A:
'Filtern Sie nach Straßenlärm und lassen Sie Pendelzeit, Breitband, Preis und Hausfilter aktiv. Sie können die Karte nach einem Merkmal einfärben, während die anderen Filter die Shortlist realistisch halten.',
'Filtern Sie nach Straßenlärm und lassen Sie Pendelzeit, Breitband, Preis und Hausfilter aktiv. Sie können die Karte nach einem Merkmal einfärben, während die anderen Filter die Auswahlliste realistisch halten.',
faqEnv2Q: 'Zeigt es Hochwasser-, Senkungs- oder Gutachterrisiken?',
faqEnv2A:
'Nicht heute. Wir zeigen Straßenlärm, Energieklasse, Baualter und die lokale Umgebung rund um die Postleitzahl. Hochwasserrisiko, rechtliche Fragen, bauliche Mängel, Finanzierungsthemen und Gutachten müssen vor dem Kauf separat geprüft werden.',
faqEnv3Q: 'Welche laufenden Kosten kann ich vor einer Besichtigung prüfen?',
faqEnv3A:
'Sie können vor der Besichtigung Energieklasse, Wohnfläche, Baualter, Council-Tax-Gebiet, Breitband und Lärm prüfen. Das sagt Ihre genauen Rechnungen nicht voraus, hilft aber, offensichtliche Fehlgriffe früh auszusortieren.',
'Sie können vor der Besichtigung Energieklasse, Wohnfläche, Baualter, kommunales Steuergebiet, Breitband und Lärm prüfen. Das sagt Ihre genauen Rechnungen nicht voraus, hilft aber, offensichtliche Fehlgriffe früh auszusortieren.',
// FAQ items — Listing Portals and Due Diligence
faqDueDiligence1Q: 'Sollte ich das vor oder nach Rightmove nutzen?',
faqDueDiligence1A:
'Nutzen Sie Perfect Postcode vor und neben Listing-Seiten. Rightmove, Zoopla und OnTheMarket bleiben die Orte für aktuell verfügbare Häuser, Fotos, Makler, Besichtigungen und Benachrichtigungen. Perfect Postcode hilft zu entscheiden, welche Postleitzahlen die Suche wert sind.',
faqDueDiligence2Q: 'Kann ich nach Garten, Garage, Grundriss oder Anzeigentext filtern?',
'Nutzen Sie Perfect Postcode vor und parallel zu Inseratsseiten. Rightmove, Zoopla und OnTheMarket bleiben die Orte für aktuell verfügbare Häuser, Fotos, Makler, Besichtigungen und Benachrichtigungen. Perfect Postcode hilft zu entscheiden, welche Postleitzahlen die Suche wert sind.',
faqDueDiligence2Q: 'Kann ich nach Garten, Garage, Grundriss oder Inseratstext filtern?',
faqDueDiligence2A:
'Diese Details sind nicht für jedes Haus zuverlässig verfügbar. Perfect Postcode kann nach Wohnfläche, Haustyp, Eigentumsform, Energieklasse, Verkaufspreisen und lokalen Informationen filtern. Gärten, Garagen, Ausrichtung, Raumaufteilung und Maklerformulierungen müssen weiterhin in der Anzeige und bei der Besichtigung geprüft werden.',
faqDueDiligence3Q: 'Kann ich Preisreduzierungen oder die Online-Dauer eines Listings sehen?',
faqDueDiligence3Q: 'Kann ich Preisreduzierungen oder die Online-Dauer eines Inserats sehen?',
faqDueDiligence3A:
'Derzeit nicht. Perfect Postcode basiert auf Verkaufspreisen, Energieklassen, Postleitzahlen, Reisezeiten und Nachbarschaftsinformationen, nicht auf Live-Änderungen in Inseraten. Sie können aber Verkaufshistorie, geschätzten aktuellen Wert und Preis pro m² nutzen, um einzuschätzen, ob ein Angebotspreis hoch wirkt.',
'Derzeit nicht. Perfect Postcode basiert auf Verkaufspreisen, Energieklassen, Postleitzahlen, Reisezeiten und Nachbarschaftsinformationen, nicht auf Echtzeitänderungen in Inseraten. Sie können aber Verkaufshistorie, geschätzten aktuellen Wert und Preis pro m² nutzen, um einzuschätzen, ob ein Angebotspreis hoch wirkt.',
faqDueDiligence4Q: 'Was sollte ich vor einem Angebot trotzdem prüfen?',
faqDueDiligence4A:
'Nutzen Sie Perfect Postcode, um Gebiet und wahrscheinlichen Wert zu prüfen, und bestätigen Sie dann die Inseratsdetails vor einem Angebot. Prüfen Sie außerdem Eigentumsform, Leasehold-Details, Nebenkosten, Planungshistorie, Hochwasserrisiko, rechtliche Fragen, Hypothekenanforderungen und Gutachten.',
'Nutzen Sie Perfect Postcode, um Gebiet und wahrscheinlichen Wert zu prüfen, und bestätigen Sie dann die Inseratsdetails vor einem Angebot. Prüfen Sie außerdem Eigentumsform, Details zum Erbbaurecht, Nebenkosten, Planungshistorie, Hochwasserrisiko, rechtliche Fragen, Hypothekenanforderungen und Gutachten.',
// FAQ items — Privacy and Data Protection
faqPrivacy1Q: 'Speichern Sie personenbezogene Daten über mich?',
faqPrivacy1A:
@ -647,17 +734,17 @@ const de: Translations = {
// FAQ items — Why Perfect Postcode
faqWhy1Q: 'Was zeigt das, was Immobilienportale normalerweise nicht zeigen?',
faqWhy1A:
'Listing-Seiten beginnen mit Häusern, die gerade zum Verkauf stehen. Perfect Postcode beginnt mit Orten, die zu Ihrem Leben und Budget passen, mit Verkaufspreisen, Platz, Pendelzeit, Schulen, Kriminalität, Lärm, Breitband, Energieklasse, Eigentumsform und Ausstattung, bevor Sie Inserate öffnen.',
'Inseratsseiten beginnen mit Häusern, die gerade zum Verkauf stehen. Perfect Postcode beginnt mit Orten, die zu Ihrem Leben und Budget passen, mit Verkaufspreisen, Platz, Pendelzeit, Schulen, Kriminalität, Lärm, Breitband, Energieklasse, Eigentumsform und Ausstattung, bevor Sie Inserate öffnen.',
faqWhy2Q: 'Wie viel manuelle Recherche spart das?',
faqWhy2A:
'Sie könnten das selbst tun, aber dann müssten Sie Verkaufspreise, Energieklassen, Kriminalität, Schulen, Breitband, lokale Fakten, Umwelt, Reisezeiten und Karten Postleitzahl für Postleitzahl prüfen. Perfect Postcode bringt diese Quellen in einer durchsuchbaren Karte für England zusammen.',
faqWhy3Q: 'Wie verlässlich sind die Daten?',
faqWhy3A:
'Die wichtigsten Quellen sind offizielle oder weit genutzte öffentliche Daten: Verkaufspreise, Energieklassen, lokale Fakten, Schulbewertungen, Breitband, Kriminalität, Umwelt, Karten und Straßendaten. Sie sind nützlich für Shortlists und Vergleiche, aber jede Kaufentscheidung braucht aktuelle Prüfungen und bei Bedarf fachlichen Rat.',
'Die wichtigsten Quellen sind offizielle oder weit genutzte öffentliche Daten: Verkaufspreise, Energieklassen, lokale Fakten, Schulbewertungen, Breitband, Kriminalität, Umwelt, Karten und Straßendaten. Sie sind nützlich für Auswahllisten und Vergleiche, aber jede Kaufentscheidung braucht aktuelle Prüfungen und bei Bedarf fachlichen Rat.',
// FAQ items — Pricing and Access
faqPricing1Q: 'Warum bezahlen, wenn Postleitzahlberichte kostenlos sind?',
faqPricing1A:
'Kostenlose Postleitzahltools sind nützlich, wenn Sie schon wissen, was Sie prüfen wollen. Perfect Postcode durchsucht jede Postleitzahl in England nach Ihren Bedürfnissen, kombiniert Filter, vergleicht Optionen, speichert Suchen und exportiert eine Shortlist, bevor Sie Wochenenden für Besichtigungen einplanen.',
'Kostenlose Postleitzahltools sind nützlich, wenn Sie schon wissen, was Sie prüfen wollen. Perfect Postcode durchsucht jede Postleitzahl in England nach Ihren Bedürfnissen, kombiniert Filter, vergleicht Optionen, speichert Suchen und exportiert eine Auswahlliste, bevor Sie Wochenenden für Besichtigungen einplanen.',
faqPricing2Q: 'Was bedeutet lebenslanger Zugang?',
faqPricing2A:
'Lebenslanger Zugang bedeutet, dass eine Zahlung Ihrem Konto laufenden Zugriff auf die kostenpflichtige Perfect-Postcode-Karte für die Lebensdauer des Dienstes gibt. Es ist kein Monats- oder Jahresabo, und normale Datenaktualisierungen sind enthalten. Sie können es während dieser Suche nutzen, später zurückkommen und weiterhin Zugriff haben, wenn Sie erneut umziehen.',
@ -668,7 +755,7 @@ const de: Translations = {
// FAQ items — Tips and Tricks
faqTips1Q: 'Wie sehe ich eine Filtervorschau auf der Karte?',
faqTips1A:
'Klicken Sie auf das Augen-Symbol neben einem Filter oder Merkmal, um die Karte nach diesem Punkt einzufärben. Ihre aktiven Filter bleiben bestehen, sodass Sie schnell eine Sache wie Preis, Pendelzeit, Schulen, Kriminalität oder Lärm vergleichen können, ohne die Shortlist zu ändern.',
'Klicken Sie auf das Augen-Symbol neben einem Filter oder Merkmal, um die Karte nach diesem Punkt einzufärben. Ihre aktiven Filter bleiben bestehen, sodass Sie schnell eine Sache wie Preis, Pendelzeit, Schulen, Kriminalität oder Lärm vergleichen können, ohne die Auswahlliste zu ändern.',
faqTips2Q: 'Wie erfahre ich, was ein Filter bedeutet?',
faqTips2A:
'Klicken Sie auf den i-Infobutton neben einem Filter oder Merkmal, um eine kurze Erklärung zu sehen, was es bedeutet und wie Sie es lesen. Einige Teile der Karte, etwa Reisezeitkarten, haben ebenfalls einen eigenen Infobutton.',
@ -811,11 +898,11 @@ const de: Translations = {
Properties: 'Immobilien',
Transport: 'Verkehr',
Education: 'Bildung',
Deprivation: 'Benachteiligung',
'Area characteristics': 'Gebietsmerkmale',
Crime: 'Kriminalität',
Demographics: 'Demografie',
Politics: 'Politik',
Neighbours: 'Nachbarn',
Amenities: 'Infrastruktur',
Environment: 'Umwelt',
// ─ Feature names (Properties) ─
'Property type': 'Immobilientyp',
@ -836,8 +923,6 @@ const de: Translations = {
'Interior height (m)': 'Raumhöhe (m)',
// ─ Feature names (Transport) ─
'Distance to nearest train or tube station (km)':
'Entfernung zum nächsten Bahn- oder U-Bahnhof (km)',
'Travel time to nearest train or tube station (min)':
'Fahrzeit zum nächsten Bahn- oder U-Bahnhof (Min.)',
@ -854,7 +939,7 @@ const de: Translations = {
'Hervorragende weiterführende Schulen im Umkreis von 5 km',
'Education, Skills and Training Score': 'Score für Bildung, Kompetenzen und Ausbildung',
// ─ Feature names (Deprivation) ─
// ─ Feature names (Area characteristics) ─
'Income Score': 'Einkommensscore',
'Employment Score': 'Beschäftigungsscore',
'Health Deprivation and Disability Score': 'Score für Gesundheit und Behinderung',
@ -883,7 +968,7 @@ const de: Translations = {
'Public order (avg/yr)': 'Störung der öffentlichen Ordnung (Durchschn./Jahr)',
'Other crime (avg/yr)': 'Sonstige Straftaten (Durchschn./Jahr)',
// ─ Feature names (Demographics) ─
// ─ Feature names (Neighbours) ─
'Median age': 'Medianalter',
'% White': '% Weiß',
'% South Asian': '% Südasiatisch',
@ -892,7 +977,6 @@ const de: Translations = {
'% Mixed': '% Gemischt',
'% Other': '% Sonstige',
// ─ Feature names (Politics) ─
'Voter turnout (%)': 'Wahlbeteiligung (%)',
'% Labour': '% Labour',
'% Conservative': '% Conservative',
@ -904,12 +988,17 @@ const de: Translations = {
// ─ Feature names (Amenities) ─
'Distance to nearest park (km)': 'Entfernung zum nächsten Park (km)',
'Number of parks within 1km': 'Anzahl Parks im Umkreis von 1 km',
'Number of restaurants within 2km': 'Anzahl Restaurants 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ärm (dB)',
'Max available download speed (Mbps)': 'Max. verfügbare Downloadgeschwindigkeit (Mbps)',
// ─ Client-side aggregate filter names ─
Schools: 'Schulen',
'Specific crimes': 'Einzelne Delikte',
Ethnicities: 'Ethnien',
'POI distance': 'POI-Entfernung',
'POIs within 2km': 'POIs innerhalb von 2 km',
'POIs within 5km': 'POIs innerhalb von 5 km',
// ─ Enum values ─
Detached: 'Freistehend',
'Semi-Detached': 'Doppelhaushälfte',
@ -926,6 +1015,9 @@ const de: Translations = {
'Minor crime': 'Leichte Straftaten',
'Ethnic composition': 'Ethnische Zusammensetzung',
'Political vote share': 'Stimmenverteilung',
'Anti-social': 'Antisozial',
Vehicle: 'Fahrzeug',
Burglary: 'Einbruch',
// ─ POI group names ─
'Public Transport': 'Öffentlicher Nahverkehr',

View file

@ -5,6 +5,8 @@ const en = {
cancel: 'Cancel',
close: 'Close',
delete: 'Delete',
finish: 'Finish',
language: 'Language',
open: 'Open',
share: 'Share',
copy: 'Copy',
@ -19,6 +21,7 @@ const en = {
viewDataSource: 'View data source',
total: 'Total',
min: 'min',
max: 'max',
or: 'or',
area: 'Area',
properties: 'Properties',
@ -29,6 +32,11 @@ const en = {
clickForDetails: 'Click for details',
property: 'property',
propertiesPlural: 'properties',
places: 'places',
noData: 'No data',
allLow: 'All low',
connectingToServer: 'Connecting to server...',
closePane: 'Close pane',
},
// ── Header / Nav ───────────────────────────────────
@ -70,6 +78,35 @@ const en = {
home: 'Home',
},
// ── Toasts ─────────────────────────────────────────
toasts: {
propertySaved: 'Property saved!',
viewSaved: 'View saved',
dontShowAgain: "Don't show again",
},
// ── SEO Page Chrome ────────────────────────────────
seo: {
breadcrumb: 'Breadcrumb',
reviewDataSources: 'Review the data sources',
whatYouCanCompare: 'What you can compare',
whatYouCanCompareDesc:
'Each page is built around real shortlisting work: removing impossible places, comparing the remaining postcodes, and deciding what to validate next.',
howToUseIt: 'How to use it',
howToUseItDesc:
'Use these workflows to make the page useful before you open a listing portal or book a viewing.',
methodAndLimitations: 'Method and limitations',
methodAndLimitationsDesc:
'The data is designed for comparison and shortlisting. Important decisions still need current listings, professional checks, and direct local validation.',
questionsBuyersAsk: 'Questions buyers ask',
relatedGuides: 'Related guides',
relatedGuidesDesc: 'Continue through the indexed public pages using canonical internal links.',
frequentlyAskedQuestions: 'Frequently asked questions',
relatedPages: 'Related pages',
relatedPagesDesc:
'Follow these internal links to compare the same property-search workflow from another angle.',
},
// ── Auth Modal ─────────────────────────────────────
auth: {
logIn: 'Log in',
@ -159,6 +196,20 @@ const en = {
clearAllSavePrompt: 'Would you like to save your current filters before clearing?',
saveAndClear: 'Save & Clear',
clearWithoutSaving: 'Clear without saving',
withoutThisFilter: '+{{value}} without this filter',
schoolType: 'School type',
schoolRating: 'School rating',
schoolDistance: 'School distance',
primary: 'Primary',
secondary: 'Secondary',
rating: 'Rating',
goodPlus: 'Good+',
outstanding: 'Outstanding',
distance: 'Distance',
crimeType: 'Crime type',
ethnicity: 'Ethnicity',
poiType: 'POI type',
party: 'Party',
},
// ── Philosophy Popup ───────────────────────────────
@ -243,6 +294,16 @@ const en = {
previewing: 'Previewing \u201c{{name}}\u201d',
},
// ── Map ────────────────────────────────────────────
map: {
ogTitle: 'Your perfect postcode',
ogPropertyPrices: 'Property prices',
ogEnergyRatings: 'Energy ratings',
ogSchools: 'Schools',
ogCrimeStats: 'Crime stats',
ogTransport: 'Transport',
},
// ── Properties Pane ────────────────────────────────
propertyCard: {
unknownAddress: 'Unknown Address',
@ -273,14 +334,18 @@ const en = {
areaOverview: 'Overview',
statsFor: 'Stats for all properties in this {{type}}',
matchingFilters: ' matching all active filters',
filtersAffectStats:
'Filters are applied here: values, charts, and property counts use the {{count}} active filters.',
noFiltersAffectStats: 'Add filters to recalculate these values for matching properties.',
noFilteredMatches: 'No properties match your filters in this area.',
unfilteredAreaCount:
'{{count}} properties exist here before filters, so the location is valid but filtered out.',
noUnfilteredAreaProperties: 'No properties were found in this selected area before filters.',
relaxFiltersHint: 'Relax or clear filters to see properties in this area.',
statsBasis: 'Stats basis',
matchingFiltersOption: 'Matching filters',
allPropertiesOption: 'All properties',
filtersAffectStats: 'Using {{count}} active filters for the stats in this pane.',
filtersIgnoredForStats: 'Showing stats for all properties in the selected area.',
noFiltersAffectStats: 'No active filters; stats cover all properties in this area.',
filteredStatsEmpty: 'Filtered stats are empty',
showAllStatsHint:
'{{count}} properties exist before filters. Switch to all properties to inspect this area.',
showAllStatsFallback:
'Switch to all properties to inspect this area without the active filters.',
showAllStats: 'Show all properties',
viewProperties: 'View {{count}} Properties',
viewPropertiesShort: 'View properties',
priceHistory: 'Price History',
@ -355,12 +420,31 @@ const en = {
'Set your budget, commute, schools, safety, noise, broadband, and lifestyle needs. Perfect Postcode scans Englands postcodes and reveals the places that actually fit, including areas youd never have typed into a listing portal.',
exploreTheMap: 'Find my matching postcodes',
seeTheDifference: 'See how it works',
productDemoLabel: 'Perfect Postcode product demo',
playProductDemo: 'Play Perfect Postcode product demo',
scrollToProductDemo: 'Scroll to product demo',
showcaseHeader: 'How it works',
showcaseContext: 'How Perfect Postcode works',
showcaseFeaturePriceShort: 'Price',
showcaseFeatureNoiseShort: 'Noise',
showcaseFeatureSchoolsShort: 'Schools',
showcaseFeatureTravelShort: 'Travel',
showcaseGoodPrimariesNearby: '{{count}}+ good primaries nearby',
showcaseWithinRail: 'Within {{count}} min of rail',
showcaseMatchingHomesLabel: 'Matching homes',
showcaseMatchingHomes: '{{value}} matching homes',
showcaseMedianPrice: '{{value}} median',
showcaseJourneyRoutes: 'Journey routes',
showcaseNearby: '{{value}} nearby',
showcasePoliticalVoteShare: 'Political vote share',
showcaseLotsMore: '...and lots more',
showcaseMinutes: '{{count}} min',
showcaseSendShortlist: 'Send the shortlist',
showcaseDownloadXlsx: 'Download .xlsx',
showcaseTopThree: 'Top 3',
showcaseScoutBullet1: 'Walk the streets before the listing search narrows your options.',
showcaseScoutBullet2: 'Test the commute from a real front door, not a borough name.',
showcaseScoutBullet3: 'Compare viewings with evidence already in hand.',
showcaseStep1Tab: 'Filter',
showcaseStep1Title: 'Turn vague needs into a tight search',
showcaseStep1Body:
@ -798,11 +882,11 @@ const en = {
Properties: 'Properties',
Transport: 'Transport',
Education: 'Education',
Deprivation: 'Deprivation',
'Area characteristics': 'Area characteristics',
Crime: 'Crime',
Demographics: 'Demographics',
Politics: 'Politics',
Neighbours: 'Neighbours',
Amenities: 'Amenities',
Environment: 'Environment',
// ─ Feature names (Properties) ─
'Property type': 'Property type',
@ -823,8 +907,6 @@ const en = {
'Interior height (m)': 'Interior height (m)',
// ─ Feature names (Transport) ─
'Distance to nearest train or tube station (km)':
'Distance to nearest train or tube station (km)',
'Travel time to nearest train or tube station (min)':
'Travel time to nearest train or tube station (min)',
@ -839,7 +921,7 @@ const en = {
'Outstanding secondary schools within 5km': 'Outstanding secondary schools within 5km',
'Education, Skills and Training Score': 'Education, Skills and Training Score',
// ─ Feature names (Deprivation) ─
// ─ Feature names (Area characteristics) ─
'Income Score': 'Income Score',
'Employment Score': 'Employment Score',
'Health Deprivation and Disability Score': 'Health Deprivation and Disability Score',
@ -866,7 +948,7 @@ const en = {
'Public order (avg/yr)': 'Public order (avg/yr)',
'Other crime (avg/yr)': 'Other crime (avg/yr)',
// ─ Feature names (Demographics) ─
// ─ Feature names (Neighbours) ─
'Median age': 'Median age',
'% White': '% White',
'% South Asian': '% South Asian',
@ -875,7 +957,6 @@ const en = {
'% Mixed': '% Mixed',
'% Other': '% Other',
// ─ Feature names (Politics) ─
'Voter turnout (%)': 'Voter turnout (%)',
'% Labour': '% Labour',
'% Conservative': '% Conservative',
@ -887,12 +968,17 @@ const en = {
// ─ Feature names (Amenities) ─
'Distance to nearest park (km)': 'Distance to nearest park (km)',
'Number of parks within 1km': 'Number of parks within 1km',
'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',
'Noise (dB)': 'Noise (dB)',
'Max available download speed (Mbps)': 'Max available download speed (Mbps)',
// ─ Client-side aggregate filter names ─
Schools: 'Schools',
'Specific crimes': 'Specific crimes',
Ethnicities: 'Ethnicities',
'POI distance': 'POI distance',
'POIs within 2km': 'POIs within 2km',
'POIs within 5km': 'POIs within 5km',
// ─ Enum values ─
Detached: 'Detached',
'Semi-Detached': 'Semi-Detached',
@ -909,6 +995,9 @@ const en = {
'Minor crime': 'Minor crime',
'Ethnic composition': 'Ethnic composition',
'Political vote share': 'Political vote share',
'Anti-social': 'Anti-social',
Vehicle: 'Vehicle',
Burglary: 'Burglary',
// ─ POI group names ─
'Public Transport': 'Public Transport',

View file

@ -7,6 +7,8 @@ const fr: Translations = {
cancel: 'Annuler',
close: 'Fermer',
delete: 'Supprimer',
finish: 'Terminer',
language: 'Langue',
open: 'Ouvrir',
share: 'Partager',
copy: 'Copier',
@ -21,6 +23,7 @@ const fr: Translations = {
viewDataSource: 'Voir la source des données',
total: 'Total',
min: 'min',
max: 'max',
or: 'ou',
area: 'Zone',
properties: 'Propriétés',
@ -31,6 +34,11 @@ const fr: Translations = {
clickForDetails: 'Cliquez pour les détails',
property: 'propriété',
propertiesPlural: 'propriétés',
places: 'lieux',
noData: 'Aucune donnée',
allLow: 'Tout est faible',
connectingToServer: 'Connexion au serveur...',
closePane: 'Fermer le panneau',
},
// ── Header / Nav ───────────────────────────────────
@ -72,6 +80,36 @@ const fr: Translations = {
home: 'Accueil',
},
// ── Toasts ─────────────────────────────────────────
toasts: {
propertySaved: 'Bien enregistré !',
viewSaved: 'Voir lenregistrement',
dontShowAgain: 'Ne plus afficher',
},
// ── SEO Page Chrome ────────────────────────────────
seo: {
breadcrumb: 'Fil dAriane',
reviewDataSources: 'Consulter les sources de données',
whatYouCanCompare: 'Ce que vous pouvez comparer',
whatYouCanCompareDesc:
'Chaque page sert un vrai travail de présélection : éliminer les lieux impossibles, comparer les codes postaux restants et décider quoi vérifier ensuite.',
howToUseIt: 'Comment lutiliser',
howToUseItDesc:
'Utilisez ces parcours pour rendre la page utile avant douvrir un portail dannonces ou de réserver une visite.',
methodAndLimitations: 'Méthode et limites',
methodAndLimitationsDesc:
'Les données sont conçues pour comparer et présélectionner. Les décisions importantes nécessitent toujours des annonces à jour, des vérifications professionnelles et une validation locale directe.',
questionsBuyersAsk: 'Questions des acheteurs',
relatedGuides: 'Guides associés',
relatedGuidesDesc:
'Continuez parmi les pages publiques indexées grâce aux liens internes canoniques.',
frequentlyAskedQuestions: 'Questions fréquentes',
relatedPages: 'Pages associées',
relatedPagesDesc:
'Suivez ces liens internes pour comparer le même parcours de recherche immobilière sous un autre angle.',
},
// ── Auth Modal ─────────────────────────────────────
auth: {
logIn: 'Se connecter',
@ -165,6 +203,20 @@ const fr: Translations = {
clearAllSavePrompt: 'Souhaitez-vous sauvegarder vos filtres actuels avant de les effacer ?',
saveAndClear: 'Sauvegarder et effacer',
clearWithoutSaving: 'Effacer sans sauvegarder',
withoutThisFilter: '+{{value}} sans ce filtre',
schoolType: 'Type décole',
schoolRating: 'Note de lécole',
schoolDistance: 'Distance de lécole',
primary: 'Primaire',
secondary: 'Secondaire',
rating: 'Note',
goodPlus: 'Bien+',
outstanding: 'Excellent',
distance: 'Distance',
crimeType: 'Type de crime',
ethnicity: 'Origine ethnique',
poiType: 'Type de POI',
party: 'Parti',
},
// ── Philosophy Popup ───────────────────────────────
@ -250,6 +302,16 @@ const fr: Translations = {
previewing: 'Aperçu de \u201c{{name}}\u201d',
},
// ── Map ────────────────────────────────────────────
map: {
ogTitle: 'Votre code postal idéal',
ogPropertyPrices: 'Prix immobiliers',
ogEnergyRatings: 'Classes énergie',
ogSchools: 'Écoles',
ogCrimeStats: 'Criminalité',
ogTransport: 'Transports',
},
// ── Properties Pane ────────────────────────────────
propertyCard: {
unknownAddress: 'Adresse inconnue',
@ -280,16 +342,20 @@ const fr: Translations = {
areaOverview: 'Vue densemble',
statsFor: 'Statistiques pour toutes les propriétés de ce/cette {{type}}',
matchingFilters: ' correspondant à tous les filtres actifs',
filtersAffectStats:
'Les filtres sont appliqués ici : valeurs, graphiques et nombres de propriétés utilisent les {{count}} filtres actifs.',
statsBasis: 'Base des statistiques',
matchingFiltersOption: 'Filtres actifs',
allPropertiesOption: 'Toutes les propriétés',
filtersAffectStats: 'Les statistiques de ce panneau utilisent les {{count}} filtres actifs.',
filtersIgnoredForStats:
'Affiche les statistiques de toutes les propriétés de la zone sélectionnée.',
noFiltersAffectStats:
'Ajoutez des filtres pour recalculer ces valeurs pour les propriétés correspondantes.',
noFilteredMatches: 'Aucune propriété de cette zone ne correspond à vos filtres.',
unfilteredAreaCount:
'{{count}} propriétés existent ici avant les filtres ; le lieu est valide, mais filtré.',
noUnfilteredAreaProperties:
'Aucune propriété na été trouvée dans cette zone sélectionnée avant les filtres.',
relaxFiltersHint: 'Assouplissez ou effacez les filtres pour voir les propriétés de cette zone.',
'Aucun filtre actif ; les statistiques couvrent toutes les propriétés de cette zone.',
filteredStatsEmpty: 'Les statistiques filtrées sont vides',
showAllStatsHint:
'{{count}} propriétés existent avant les filtres. Passez à toutes les propriétés pour inspecter cette zone.',
showAllStatsFallback:
'Passez à toutes les propriétés pour inspecter cette zone sans les filtres actifs.',
showAllStats: 'Afficher toutes les propriétés',
viewProperties: 'Voir {{count}} propriétés',
viewPropertiesShort: 'Voir les propriétés',
priceHistory: 'Historique des prix',
@ -364,12 +430,33 @@ const fr: Translations = {
'Définissez votre budget, trajet, écoles, sécurité, bruit, débit internet et style de vie. Perfect Postcode analyse les codes postaux dAngleterre et révèle les lieux qui correspondent vraiment, y compris ceux que vous nauriez jamais cherchés sur un portail immobilier.',
exploreTheMap: 'Trouver mes codes postaux',
seeTheDifference: 'Voir comment ça marche',
productDemoLabel: 'Démo produit Perfect Postcode',
playProductDemo: 'Lire la démo produit Perfect Postcode',
scrollToProductDemo: 'Faire défiler jusquà la démo produit',
showcaseHeader: 'Comment ça marche',
showcaseContext: 'Comment fonctionne Perfect Postcode',
showcaseFeaturePriceShort: 'Prix',
showcaseFeatureNoiseShort: 'Bruit',
showcaseFeatureSchoolsShort: 'Écoles',
showcaseFeatureTravelShort: 'Trajet',
showcaseGoodPrimariesNearby: '{{count}}+ bonnes écoles primaires à proximité',
showcaseWithinRail: 'À moins de {{count}} min du train',
showcaseMatchingHomesLabel: 'Biens correspondants',
showcaseMatchingHomes: '{{value}} biens correspondants',
showcaseMedianPrice: 'médiane {{value}}',
showcaseJourneyRoutes: 'Itinéraires',
showcaseNearby: '{{value}} à proximité',
showcasePoliticalVoteShare: 'Répartition des voix',
showcaseLotsMore: '...et bien plus',
showcaseMinutes: '{{count}} min',
showcaseSendShortlist: 'Envoyer la sélection',
showcaseDownloadXlsx: 'Télécharger le .xlsx',
showcaseTopThree: 'Top 3',
showcaseScoutBullet1:
'Parcourez les rues avant que la recherche dannonces ne réduise vos options.',
showcaseScoutBullet2:
'Testez le trajet depuis une vraie porte dentrée, pas seulement depuis un nom darrondissement.',
showcaseScoutBullet3: 'Comparez les visites avec des preuves déjà en main.',
showcaseStep1Tab: 'Filtrer',
showcaseStep1Title: 'Transformez des besoins vagues en recherche précise',
showcaseStep1Body:
@ -393,12 +480,12 @@ const fr: Translations = {
showcaseStep3HeaderFit: 'Éléments sur le quartier',
showcaseStep3Stat1Label: 'Tendance des prix vendus',
showcaseStep3Stat2Label: 'Criminalité',
showcaseStep3Stat2Value: 'Sous la moyenne du borough',
showcaseStep3Stat2Value: 'Sous la moyenne de larrondissement',
showcaseStep3Stat3Label: 'Âge médian',
showcaseStep3Stat4Label: 'Débit internet',
showcaseStep3Stat4Value: '1 Gbps disponible',
showcaseStep3Stat5Label: 'Écoles primaires',
showcaseStep3Stat5Value: '3 « outstanding » à moins dun mile',
showcaseStep3Stat5Value: '3 « Excellent » à moins dun mile',
showcaseStep4Tab: 'Repérer',
showcaseStep4Title: 'Allez vérifier par vous-même',
showcaseStep4Body:
@ -451,7 +538,7 @@ const fr: Translations = {
'Accès à vie à la carte qui vous aide à savoir où chercher avant de réserver des visites.',
costContext:
'Les acheteurs passent souvent leurs soirées à recouper annonces, trajets, rapports scolaires, cartes de criminalité, Street View et prix vendus. À Londres, cest incessant, mais le même problème existe dans toute lAngleterre. Perfect Postcode rassemble la recherche de zone sur une seule carte avant que vous nengagiez vos week-ends, vos frais et votre attention.',
lessThanSurvey: 'Moins quun survey. Bien plus utile pour guider vos choix.',
lessThanSurvey: 'Moins quune expertise immobilière. Bien plus utile pour guider vos choix.',
currentTier: 'Palier actuel',
firstNUsers: '{{count}} premiers utilisateurs',
everyoneAfter: 'Tous les suivants',
@ -500,18 +587,18 @@ const fr: Translations = {
attrOsmLicense: 'disponibles sous la',
attrOsmLicenseLink: 'Open Data Commons Open Database License (ODbL)',
// Data source names & descriptions
dsPricePaidName: 'Price Paid Data',
dsPricePaidName: 'Données des prix payés',
dsPricePaidOrigin: 'HM Land Registry',
dsPricePaidUse: 'Historique complet des prix de vente immobiliers en Angleterre.',
dsEpcName: 'Energy Performance Certificates (EPC)',
dsEpcName: 'Certificats de performance énergétique (EPC)',
dsEpcOrigin: 'Ministry of Housing, Communities & Local Government',
dsEpcUse:
'Certificats de performance énergétique domestiques fournissant la surface, le nombre de pièces, lannée de construction, les classements énergétiques, le type de bien et la forme du bâti. Associés aux données Price Paid par adresse au sein de chaque code postal. Les propriétaires peuvent demander le retrait de la divulgation publique.',
dsNsplName: 'National Statistics Postcode Lookup (NSPL)',
dsNsplName: 'Répertoire national des codes postaux (NSPL)',
dsNsplOrigin: 'ONS / ArcGIS',
dsNsplUse:
'Associe les codes postaux aux coordonnées et aux codes de zones statistiques, utilisé pour relier tous les jeux de données au niveau de la zone aux propriétés individuelles.',
dsIodName: 'English Indices of Deprivation 2025',
dsIodName: 'Indices anglais de défaveur 2025',
dsIodOrigin: 'Ministry of Housing, Communities & Local Government',
dsIodUse:
'Percentiles nationaux de défaveur couvrant le revenu, lemploi, léducation, la santé, la criminalité et le cadre de vie pour chaque quartier dAngleterre.',
@ -519,43 +606,43 @@ const fr: Translations = {
dsEthnicityOrigin: 'ONS',
dsEthnicityUse:
'Pourcentages de population par groupe ethnique (sud-asiatique, est-asiatique, noir, mixte, blanc, autre) par autorité locale.',
dsCrimeName: 'Street-level Crime Data',
dsCrimeName: 'Données de criminalité de proximité',
dsCrimeOrigin: 'data.police.uk',
dsCrimeUse:
'Données de criminalité de proximité de 2023 à 2025, agrégées en moyennes annuelles par LSOA et type dinfraction (violences, cambriolages, troubles à lordre public, stupéfiants, vols de véhicules, etc.).',
dsOsmName: 'OpenStreetMap POIs',
dsOsmName: 'Points dintérêt OpenStreetMap',
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
dsOsmUse:
'Points dintérêt couvrant commerces, restaurants, santé, loisirs, tourisme et plus à travers la Grande-Bretagne.',
dsGeolytixRetailName: 'GEOLYTIX Grocery Retail Points',
dsGeolytixRetailName: 'Points de vente alimentaire GEOLYTIX',
dsGeolytixRetailOrigin: 'GEOLYTIX',
dsGeolytixRetailUse:
'Emplacements de supermarchés et magasins de proximité au Royaume-Uni, incluant des chaînes comme Waitrose, Tesco, Sainsburys, Asda, Morrisons, Aldi, Lidl, Co-op, M&S, Iceland et Spar.',
dsGreenspaceName: 'OS Open Greenspace',
dsGreenspaceName: 'Espaces verts OS Open Greenspace',
dsGreenspaceOrigin: 'Ordnance Survey',
dsGreenspaceUse:
'Limites officielles des espaces verts de Grande-Bretagne, incluant parcs publics, jardins, terrains de sport et aires de jeux. Les centroïdes des polygones sont utilisés pour le comptage de proximité des parcs et le calcul de la distance au parc le plus proche.',
dsNaptanName: 'NaPTAN (Public Transport Stops)',
dsNaptanName: 'NaPTAN (arrêts de transport public)',
dsNaptanOrigin: 'Department for Transport',
dsNaptanUse:
'Emplacements des gares et arrêts pour le rail, le bus, le métro/tramway, le ferry et les aéroports à travers lAngleterre.',
dsNoiseName: 'Defra Noise Mapping',
dsNoiseName: 'Cartographie du bruit Defra',
dsNoiseOrigin: 'Defra / Environment Agency',
dsNoiseUse:
'Niveaux de bruit routier (moyenne pondérée sur 24 heures) issus de la cartographie stratégique du bruit de 2022, modélisés à haute résolution et échantillonnés à chaque code postal.',
dsOfstedName: 'Ofsted School Inspections',
dsOfstedName: 'Inspections scolaires Ofsted',
dsOfstedOrigin: 'Ofsted',
dsOfstedUse:
'Derniers résultats dinspection des écoles publiques (avril 2025). Moyennés par code postal pour donner un score de qualité scolaire local (1=Excellent à 4=Insuffisant).',
dsBroadbandName: 'Ofcom Broadband Performance',
dsBroadbandName: 'Performance du haut débit Ofcom',
dsBroadbandOrigin: 'Ofcom',
dsBroadbandUse:
'Couverture haut débit fixe et débits de téléchargement maximum par zone, issus de Ofcom Connected Nations 2025.',
dsCouncilTaxName: 'Council Tax Levels 2025-26',
dsCouncilTaxName: 'Niveaux de council tax 2025-2026',
dsCouncilTaxOrigin: 'Ministry of Housing, Communities & Local Government',
dsCouncilTaxUse:
'Taux annuels de taxe dhabitation pour les tranches A à H pour les 296 autorités de facturation dAngleterre, pour un logement occupé par deux adultes. Reliés aux propriétés via le code dautorité locale du répertoire de codes postaux NSPL.',
dsRentalName: 'Private Rental Market Statistics',
dsRentalName: 'Statistiques du marché locatif privé',
dsRentalOrigin: 'ONS / Valuation Office Agency',
dsRentalUse:
'Loyers mensuels médians du marché locatif privé par autorité locale et catégorie de chambres (oct. 2022 - sept. 2023). Reliés aux propriétés via le code dautorité locale et le nombre estimé de chambres.',
@ -622,12 +709,13 @@ const fr: Translations = {
'Comment éviter une route bruyante sans perdre en qualité de trajet ou de débit internet ?',
faqEnv1A:
'Filtrez par bruit routier, puis gardez actifs les filtres de trajet, internet, prix et logement. Vous pouvez colorer la carte selon un critère pendant que les autres gardent la sélection réaliste.',
faqEnv2Q: 'Affichez-vous le risque dinondation, daffaissement ou de survey ?',
faqEnv2Q:
'Affichez-vous le risque dinondation, daffaissement ou les problèmes relevés par une expertise ?',
faqEnv2A:
'Pas aujourdhui. Nous affichons le bruit routier, la note énergétique, lâge du bâtiment et lenvironnement local autour du code postal. Le risque dinondation, les questions juridiques, les problèmes de structure, le prêt immobilier et le survey doivent encore être vérifiés séparément avant dacheter.',
'Pas aujourdhui. Nous affichons le bruit routier, la note énergétique, lâge du bâtiment et lenvironnement local autour du code postal. Le risque dinondation, les questions juridiques, les problèmes de structure, le prêt immobilier et lexpertise doivent encore être vérifiés séparément avant dacheter.',
faqEnv3Q: 'Quels coûts dusage puis-je vérifier avant une visite ?',
faqEnv3A:
'Vous pouvez vérifier la note énergétique, la surface, lâge du bâtiment, la zone de council tax, internet et le bruit avant la visite. Cela ne prédit pas vos factures exactes, mais aide à éviter tôt les incompatibilités évidentes.',
'Vous pouvez vérifier la note énergétique, la surface, lâge du bâtiment, la zone de taxe locale, internet et le bruit avant la visite. Cela ne prédit pas vos factures exactes, mais aide à éviter tôt les incompatibilités évidentes.',
// FAQ items — Listing Portals and Due Diligence
faqDueDiligence1Q: 'Faut-il lutiliser avant ou après Rightmove ?',
faqDueDiligence1A:
@ -641,7 +729,7 @@ const fr: Translations = {
'Pas actuellement. Perfect Postcode sappuie sur les prix vendus, les notes énergétiques, les codes postaux, les temps de trajet et les informations de quartier plutôt que sur les changements dannonces en direct. Vous pouvez tout de même utiliser lhistorique des ventes, la valeur actuelle estimée et le prix au m² pour juger si un prix demandé semble élevé.',
faqDueDiligence4Q: 'Que dois-je encore vérifier avant de faire une offre ?',
faqDueDiligence4A:
'Utilisez Perfect Postcode pour vérifier la zone et la valeur probable, puis confirmez les détails de lannonce avant de faire une offre. Vérifiez aussi le type de propriété, les conditions de leasehold, les charges, lhistorique durbanisme, le risque dinondation, les questions juridiques, les exigences du prêt et le survey.',
'Utilisez Perfect Postcode pour vérifier la zone et la valeur probable, puis confirmez les détails de lannonce avant de faire une offre. Vérifiez aussi le type de propriété, les conditions du bail, les charges, lhistorique durbanisme, le risque dinondation, les questions juridiques, les exigences du prêt et lexpertise.',
// FAQ items — Privacy and Data Protection
faqPrivacy1Q: 'Stockez-vous des données personnelles me concernant ?',
faqPrivacy1A:
@ -813,11 +901,11 @@ const fr: Translations = {
Properties: 'Propriétés',
Transport: 'Transports',
Education: 'Éducation',
Deprivation: 'Précarité',
'Area characteristics': 'Caractéristiques du quartier',
Crime: 'Criminalité',
Demographics: 'Démographie',
Politics: 'Politique',
Neighbours: 'Voisins',
Amenities: 'Commodités',
Environment: 'Environnement',
// ─ Feature names (Properties) ─
'Property type': 'Type de bien',
@ -838,8 +926,6 @@ const fr: Translations = {
'Interior height (m)': 'Hauteur intérieure (m)',
// ─ Feature names (Transport) ─
'Distance to nearest train or tube station (km)':
'Distance à la gare ou station de métro la plus proche (km)',
'Travel time to nearest train or tube station (min)':
'Temps de trajet jusquà la gare ou station de métro la plus proche (min)',
@ -854,7 +940,7 @@ const fr: Translations = {
'Outstanding secondary schools within 5km': 'Collèges/lycées Excellent dans un rayon de 5 km',
'Education, Skills and Training Score': 'Score éducation, compétences et formation',
// ─ Feature names (Deprivation) ─
// ─ Feature names (Area characteristics) ─
'Income Score': 'Score de revenu',
'Employment Score': 'Score demploi',
'Health Deprivation and Disability Score': 'Score de santé et handicap',
@ -881,7 +967,7 @@ const fr: Translations = {
'Public order (avg/yr)': 'Troubles à lordre public (moy./an)',
'Other crime (avg/yr)': 'Autres crimes (moy./an)',
// ─ Feature names (Demographics) ─
// ─ Feature names (Neighbours) ─
'Median age': 'Âge médian',
'% White': '% Blancs',
'% South Asian': '% Sud-Asiatiques',
@ -890,7 +976,6 @@ const fr: Translations = {
'% Mixed': '% Métis',
'% Other': '% Autres',
// ─ Feature names (Politics) ─
'Voter turnout (%)': 'Participation électorale (%)',
'% Labour': '% Travaillistes',
'% Conservative': '% Conservateurs',
@ -902,12 +987,17 @@ const fr: Translations = {
// ─ Feature names (Amenities) ─
'Distance to nearest park (km)': 'Distance au parc le plus proche (km)',
'Number of parks within 1km': 'Nombre de parcs à moins de 1 km',
'Number of restaurants within 2km': 'Nombre de restaurants à moins de 2 km',
'Number of grocery shops and supermarkets within 2km':
'Nombre dépiceries et supermarchés à moins de 2 km',
'Noise (dB)': 'Bruit (dB)',
'Max available download speed (Mbps)': 'Débit descendant max. disponible (Mbps)',
// ─ Client-side aggregate filter names ─
Schools: 'Écoles',
'Specific crimes': 'Crimes spécifiques',
Ethnicities: 'Origines ethniques',
'POI distance': 'Distance aux POI',
'POIs within 2km': 'POI à moins de 2 km',
'POIs within 5km': 'POI à moins de 5 km',
// ─ Enum values ─
Detached: 'Individuelle',
'Semi-Detached': 'Jumelée',
@ -924,6 +1014,9 @@ const fr: Translations = {
'Minor crime': 'Délits mineurs',
'Ethnic composition': 'Composition ethnique',
'Political vote share': 'Répartition des voix',
'Anti-social': 'Antisocial',
Vehicle: 'Véhicule',
Burglary: 'Cambriolage',
// ─ POI group names ─
'Public Transport': 'Transports en commun',
@ -955,7 +1048,7 @@ const fr: Translations = {
Nightclub: 'Boîte de nuit',
Cinema: 'Cinéma',
Theatre: 'Théâtre',
'Live Music & Events': 'Musique live et événements',
'Live Music & Events': 'Musique en direct et événements',
Park: 'Parc',
Playground: 'Aire de jeux',
'Sports Centre': 'Centre sportif',

View file

@ -6,6 +6,8 @@ const hi: Translations = {
cancel: 'रद्द करें',
close: 'बंद करें',
delete: 'हटाएं',
finish: 'समाप्त',
language: 'भाषा',
open: 'खोलें',
share: 'साझा करें',
copy: 'कॉपी करें',
@ -20,6 +22,7 @@ const hi: Translations = {
viewDataSource: 'डेटा स्रोत देखें',
total: 'कुल',
min: 'मिनट',
max: 'अधिकतम',
or: 'या',
area: 'क्षेत्र',
properties: 'संपत्तियां',
@ -30,6 +33,11 @@ const hi: Translations = {
clickForDetails: 'विवरण के लिए क्लिक करें',
property: 'संपत्ति',
propertiesPlural: 'संपत्तियां',
places: 'स्थान',
noData: 'कोई डेटा नहीं',
allLow: 'सभी कम',
connectingToServer: 'सर्वर से कनेक्ट हो रहा है...',
closePane: 'पैन बंद करें',
},
header: {
@ -68,6 +76,32 @@ const hi: Translations = {
home: 'होम',
},
toasts: {
propertySaved: 'संपत्ति सहेजी गई!',
viewSaved: 'सहेजी हुई देखें',
dontShowAgain: 'फिर न दिखाएं',
},
seo: {
breadcrumb: 'ब्रेडक्रम्ब',
reviewDataSources: 'डेटा स्रोत देखें',
whatYouCanCompare: 'आप क्या तुलना कर सकते हैं',
whatYouCanCompareDesc:
'हर पेज असली शॉर्टलिस्टिंग काम के लिए बना है: असंभव जगहें हटाना, बचे हुए पोस्टकोड की तुलना करना और अगली जांच तय करना.',
howToUseIt: 'इसे कैसे इस्तेमाल करें',
howToUseItDesc:
'लिस्टिंग पोर्टल खोलने या viewing बुक करने से पहले पेज को उपयोगी बनाने के लिए ये वर्कफ़्लो इस्तेमाल करें.',
methodAndLimitations: 'तरीका और सीमाएं',
methodAndLimitationsDesc:
'डेटा तुलना और शॉर्टलिस्टिंग के लिए है. महत्वपूर्ण निर्णयों के लिए अब भी ताजा लिस्टिंग, पेशेवर जांच और स्थानीय सत्यापन जरूरी है.',
questionsBuyersAsk: 'खरीदारों के सवाल',
relatedGuides: 'संबंधित गाइड',
relatedGuidesDesc: 'कैनोनिकल आंतरिक लिंक से इंडेक्स किए गए सार्वजनिक पेजों पर आगे बढ़ें.',
frequentlyAskedQuestions: 'अक्सर पूछे जाने वाले सवाल',
relatedPages: 'संबंधित पेज',
relatedPagesDesc: 'इन्हीं आंतरिक लिंक से उसी संपत्ति-खोज वर्कफ़्लो को दूसरे कोण से तुलना करें.',
},
auth: {
logIn: 'लॉग इन',
createAccount: 'खाता बनाएं',
@ -133,7 +167,7 @@ const hi: Translations = {
oneTimeLifetime: 'एक बार भुगतान, लाइफटाइम एक्सेस.',
upgradeToFullMap: 'पूरा मानचित्र अपग्रेड करें',
chooseFilters:
'Filter लगाने के लिए Add पर click करें. छोटे buttons data details दिखाते हैं या map colour करते हैं.',
'फिल्टर लगाने के लिए जोड़ें पर क्लिक करें. छोटे बटन डेटा विवरण दिखाते हैं या मानचित्र को रंगते हैं.',
searchFeatures: 'फीचर खोजें...',
noMatchingFeatures: 'कोई मेल खाता फीचर नहीं',
tryDifferentSearch: 'कोई दूसरा खोज शब्द आजमाएं',
@ -154,6 +188,20 @@ const hi: Translations = {
clearAllSavePrompt: 'क्या साफ करने से पहले आप अपने मौजूदा फिल्टर सहेजना चाहेंगे?',
saveAndClear: 'सहेजें और साफ करें',
clearWithoutSaving: 'बिना सहेजे साफ करें',
withoutThisFilter: '+{{value}} इस फिल्टर के बिना',
schoolType: 'स्कूल प्रकार',
schoolRating: 'स्कूल रेटिंग',
schoolDistance: 'स्कूल दूरी',
primary: 'प्राइमरी',
secondary: 'सेकेंडरी',
rating: 'रेटिंग',
goodPlus: 'अच्छा+',
outstanding: 'उत्कृष्ट',
distance: 'दूरी',
crimeType: 'अपराध प्रकार',
ethnicity: 'जातीय समूह',
poiType: 'POI प्रकार',
party: 'पार्टी',
},
philosophy: {
@ -166,7 +214,7 @@ const hi: Translations = {
step3Title: 'सुरक्षा',
step3Desc: '(अपराध दरें, शोर स्तर, जमीन की स्थिरता)',
step4Title: 'स्कूल',
step4Desc: '(नजदीकी Ofsted-rated Good या Outstanding स्कूल)',
step4Desc: '(नजदीकी Ofsted द्वारा अच्छी या उत्कृष्ट रेटिंग वाले स्कूल)',
step5Title: 'जीवनशैली',
step5Desc: '(रेस्तरां, पार्क, ब्रॉडबैंड स्पीड)',
step6Title: 'ऊर्जा',
@ -213,9 +261,9 @@ const hi: Translations = {
describeIdealArea: 'बताएं आप कहां रहना चाहते हैं',
aiSearch: 'AI खोज',
describeHint: 'बताएं आप क्या खोज रहे हैं',
placeholder: 'जैसे 2-bed £525k से कम, काम तक 45 मिनट, शांत...',
example1: '2-bed £525k से कम, काम तक 45 मिनट',
example2: '£650k से कम अच्छे स्कूलों के पास परिवारों वाले क्षेत्र',
placeholder: 'जैसे 2 बेडरूम £525,000 से कम, काम तक 45 मिनट, शांत...',
example1: '2 बेडरूम £525,000 से कम, काम तक 45 मिनट',
example2: '£650,000 से कम अच्छे स्कूलों के पास परिवारों वाले क्षेत्र',
example3: 'समझदारी वाले आवागमन के साथ ज्यादा जगह',
analysing: 'आपकी क्वेरी का विश्लेषण हो रहा है...',
searchingDestinations: 'गंतव्य खोजे जा रहे हैं...',
@ -233,6 +281,15 @@ const hi: Translations = {
previewing: '“{{name}}” का पूर्वावलोकन',
},
map: {
ogTitle: 'आपका परफेक्ट पोस्टकोड',
ogPropertyPrices: 'संपत्ति कीमतें',
ogEnergyRatings: 'ऊर्जा रेटिंग',
ogSchools: 'स्कूल',
ogCrimeStats: 'अपराध आंकड़े',
ogTransport: 'परिवहन',
},
propertyCard: {
unknownAddress: 'अज्ञात पता',
unsaveProperty: 'संपत्ति को असहेजें',
@ -261,15 +318,19 @@ const hi: Translations = {
areaOverview: 'अवलोकन',
statsFor: 'इस {{type}} की सभी संपत्तियों के आंकड़े',
matchingFilters: ' सभी सक्रिय फिल्टर से मेल खाते हुए',
filtersAffectStats:
'फिल्टर यहां लागू होते हैं: मान, चार्ट और संपत्ति संख्या {{count}} सक्रिय फिल्टर का उपयोग करते हैं.',
statsBasis: 'आंकड़ों का आधार',
matchingFiltersOption: 'मेल खाते फिल्टर',
allPropertiesOption: 'सभी संपत्तियां',
filtersAffectStats: 'इस पेन के आंकड़े {{count}} सक्रिय फिल्टर का उपयोग कर रहे हैं.',
filtersIgnoredForStats: 'चुने गए क्षेत्र की सभी संपत्तियों के आंकड़े दिखाए जा रहे हैं.',
noFiltersAffectStats:
'मेल खाने वाली संपत्तियों के लिए ये मान फिर गणना करने हेतु फिल्टर जोड़ें.',
noFilteredMatches: 'इस क्षेत्र में कोई संपत्ति आपके फिल्टर से मेल नहीं खाती.',
unfilteredAreaCount:
'फिल्टर से पहले यहां {{count}} संपत्तियां हैं, इसलिए स्थान वैध है लेकिन फिल्टर से बाहर हो गया है.',
noUnfilteredAreaProperties: 'फिल्टर से पहले इस चुने हुए क्षेत्र में कोई संपत्ति नहीं मिली.',
relaxFiltersHint: 'इस क्षेत्र की संपत्तियां देखने के लिए फिल्टर ढीले करें या साफ करें.',
'कोई सक्रिय फिल्टर नहीं; आंकड़े इस क्षेत्र की सभी संपत्तियों को शामिल करते हैं.',
filteredStatsEmpty: 'फिल्टर किए गए आंकड़े खाली हैं',
showAllStatsHint:
'फिल्टर से पहले यहां {{count}} संपत्तियां हैं. इस क्षेत्र को देखने के लिए सभी संपत्तियों पर जाएं.',
showAllStatsFallback:
'सक्रिय फिल्टर के बिना इस क्षेत्र को देखने के लिए सभी संपत्तियों पर जाएं.',
showAllStats: 'सभी संपत्तियां दिखाएं',
viewProperties: '{{count}} संपत्तियां देखें',
viewPropertiesShort: 'संपत्तियां देखें',
priceHistory: 'कीमत इतिहास',
@ -337,19 +398,38 @@ const hi: Translations = {
'अपना बजट, आवागमन, स्कूल, सुरक्षा, शोर, ब्रॉडबैंड और जीवनशैली की जरूरतें सेट करें. Perfect Postcode इंग्लैंड के पोस्टकोड स्कैन करता है और वे जगहें दिखाता है जो सच में मेल खाती हैं, उन क्षेत्रों सहित जिन्हें आप किसी प्रॉपर्टी पोर्टल में कभी नहीं खोजते.',
exploreTheMap: 'मेरे मेल खाते पोस्टकोड खोजें',
seeTheDifference: 'देखें यह कैसे काम करता है',
productDemoLabel: 'Perfect Postcode उत्पाद डेमो',
playProductDemo: 'Perfect Postcode उत्पाद डेमो चलाएं',
scrollToProductDemo: 'उत्पाद डेमो तक स्क्रोल करें',
showcaseHeader: 'यह कैसे काम करता है',
showcaseContext: 'Perfect Postcode कैसे काम करता है',
showcaseFeaturePriceShort: 'कीमत',
showcaseFeatureNoiseShort: 'शोर',
showcaseFeatureSchoolsShort: 'स्कूल',
showcaseFeatureTravelShort: 'यात्रा',
showcaseGoodPrimariesNearby: '{{count}}+ अच्छे प्राइमरी स्कूल पास में',
showcaseWithinRail: 'रेल से {{count}} मिनट के भीतर',
showcaseMatchingHomesLabel: 'मेल खाते घर',
showcaseMatchingHomes: '{{value}} मेल खाते घर',
showcaseMedianPrice: '{{value}} मीडियन',
showcaseJourneyRoutes: 'यात्रा मार्ग',
showcaseNearby: '{{value}} पास में',
showcasePoliticalVoteShare: 'राजनीतिक वोट हिस्सेदारी',
showcaseLotsMore: '...और भी बहुत कुछ',
showcaseMinutes: '{{count}} मिनट',
showcaseSendShortlist: 'शॉर्टलिस्ट भेजें',
showcaseDownloadXlsx: '.xlsx डाउनलोड करें',
showcaseTopThree: 'शीर्ष 3',
showcaseScoutBullet1: 'लिस्टिंग खोज आपके विकल्प घटाए, उससे पहले सड़कें चलकर देखें.',
showcaseScoutBullet2: 'आवागमन किसी असली दरवाजे से जांचें, सिर्फ बरो नाम से नहीं.',
showcaseScoutBullet3: 'पहले से मौजूद प्रमाण के साथ viewing की तुलना करें.',
showcaseStep1Tab: 'फिल्टर',
showcaseStep1Title: 'अस्पष्ट जरूरतों को सटीक खोज में बदलें',
showcaseStep1Body:
'जो मायने रखता है उसे सेट करें और देखें कि हर जरूरत कितने गलत-फिट पोस्टकोड को आपकी खोज से बाहर रखती है.',
showcaseStep1Chip1: 'शांत सड़कें',
showcaseStep1Chip2: 'शीर्ष-रेटेड प्राइमरी',
showcaseStep1Chip3: '£500k से कम',
showcaseStep1Chip3: '£500,000 से कम',
showcaseStep1VennCenter: 'तीनों शर्तों को पूरा करने वाले पोस्टकोड',
showcaseStep2Tab: 'मिलान',
showcaseStep2Title: 'मानचित्र को वे जगहें दिखाने दें जिन्हें आप टाइप नहीं करते',
@ -371,7 +451,7 @@ const hi: Translations = {
showcaseStep3Stat4Label: 'ब्रॉडबैंड',
showcaseStep3Stat4Value: '1 Gbps उपलब्ध',
showcaseStep3Stat5Label: 'प्राइमरी स्कूल',
showcaseStep3Stat5Value: '1 मील में 3 outstanding',
showcaseStep3Stat5Value: '1 मील के अंदर 3 उत्कृष्ट',
showcaseStep4Tab: 'स्काउट',
showcaseStep4Title: 'खुद जाकर देखें',
showcaseStep4Body:
@ -423,7 +503,7 @@ const hi: Translations = {
'उस मानचित्र का लाइफटाइम एक्सेस जो मकान देखने की बुकिंग से पहले यह पता लगाने में मदद करता है कि कहां देखना है.',
costContext:
'खरीदार अक्सर शामें लिस्टिंग, आवागमन जांच, स्कूल रिपोर्ट, अपराध मानचित्र, Street View और बेचे गए दामों को जोड़ने में बिताते हैं. लंदन में यह लगातार होता है, लेकिन यही शोध-समस्या पूरे इंग्लैंड में दिखाई देती है. Perfect Postcode आपके सप्ताहांत, फीस और ध्यान लगाने से पहले क्षेत्र-शोध को एक मानचित्र पर रखता है.',
lessThanSurvey: 'एक survey से कम. आपके चुनावों को दिशा देने में कहीं अधिक असरदार.',
lessThanSurvey: 'एक मकान सर्वेक्षण से कम खर्च. आपके चुनावों को दिशा देने में कहीं अधिक असरदार.',
currentTier: 'मौजूदा स्तर',
firstNUsers: 'पहले {{count}} उपयोगकर्ता',
everyoneAfter: 'उसके बाद सभी',
@ -442,19 +522,19 @@ const hi: Translations = {
feat1: 'इंग्लैंड भर में 56 फिल्टर',
feat2: 'आपकी जरूरतों से हर पोस्टकोड खोजने योग्य',
feat3: 'असीमित मानचित्र खोज, सहेजी गई खोजें और निर्यात',
feat4: '13M ऐतिहासिक लेनदेन और कीमत संदर्भ',
feat4: '1.3 करोड़ ऐतिहासिक लेनदेन और कीमत संदर्भ',
feat5: 'आवागमन, स्कूल, अपराध, शोर, ब्रॉडबैंड और अधिक',
feat6: 'भविष्य के सभी डेटा अपडेट शामिल',
},
learnPage: {
faq: 'FAQ',
faq: 'अक्सर पूछे जाने वाले प्रश्न',
dataSources: 'डेटा स्रोत',
support: 'सहायता',
dataSourcesIntro:
'यह एप्लिकेशन संपत्ति कीमतों, ऊर्जा प्रदर्शन, परिवहन, जनसांख्यिकी, अपराध, पर्यावरण और अधिक को कवर करने वाले {{count}} खुले डेटासेट को जोड़ता है.',
faqIntro:
'चाहे आप पहली बार खरीदारी की खोज संकरी कर रहे हों, किसी अनजान पोस्टकोड की जांच कर रहे हों या viewing shortlist बना रहे हों, यहां बताया गया है कि Perfect Postcode आपको कहां देखना है यह तय करने में कैसे मदद करता है.',
'चाहे आप पहली बार खरीदारी की खोज संकरी कर रहे हों, किसी अनजान पोस्टकोड की जांच कर रहे हों या देखने के लिए चुने गए विकल्पों की सूची बना रहे हों, यहां बताया गया है कि Perfect Postcode आपको कहां देखना है यह तय करने में कैसे मदद करता है.',
supportIntro: 'कोई सवाल है? हमारा FAQ देखें या सीधे संपर्क करें.',
source: 'स्रोत:',
optOut: 'सार्वजनिक प्रकटीकरण से बाहर निकलें',
@ -462,75 +542,75 @@ const hi: Translations = {
attrLandRegistry: 'HM Land Registry डेटा शामिल है © Crown copyright and database right 2025.',
attrOgl: 'सार्वजनिक क्षेत्र की जानकारी शामिल है, जो इसके अंतर्गत लाइसेंस प्राप्त है:',
attrOglLink: 'Open Government Licence v3.0',
attrOs: 'OS data शामिल है © Crown copyright and database rights 2025.',
attrOs: 'OS डेटा शामिल है © Crown copyright and database rights 2025.',
attrTfl: 'TfL Open Data द्वारा संचालित.',
attrOsm: 'डेटा शामिल है:',
attrOsmContrib: '© OpenStreetMap contributors',
attrOsmLicense: 'इसके अंतर्गत उपलब्ध:',
attrOsmLicenseLink: 'Open Data Commons Open Database License (ODbL)',
dsPricePaidName: 'Price Paid Data',
dsPricePaidName: 'विक्रय मूल्य डेटा',
dsPricePaidOrigin: 'HM Land Registry',
dsPricePaidUse: 'इंग्लैंड के लिए पूर्ण ऐतिहासिक संपत्ति बिक्री कीमतें.',
dsEpcName: 'Energy Performance Certificates (EPC)',
dsEpcName: 'ऊर्जा प्रदर्शन प्रमाणपत्र (EPC)',
dsEpcOrigin: 'Ministry of Housing, Communities & Local Government',
dsEpcUse:
'घरेलू Energy Performance Certificates जो फर्श क्षेत्र, कमरों की संख्या, निर्माण वर्ष, ऊर्जा रेटिंग, संपत्ति प्रकार और built form देते हैं. हर पोस्टकोड के अंदर पते से Price Paid records के साथ मिलाए गए. संपत्ति मालिक सार्वजनिक प्रकटीकरण से opt out कर सकते हैं.',
dsNsplName: 'National Statistics Postcode Lookup (NSPL)',
'घरेलू ऊर्जा प्रदर्शन प्रमाणपत्र फर्श क्षेत्र, कमरों की संख्या, निर्माण वर्ष, ऊर्जा रेटिंग, संपत्ति प्रकार और निर्माण स्वरूप देते हैं. उन्हें हर पोस्टकोड के अंदर पते के आधार पर Price Paid रिकॉर्ड से मिलाया गया है. संपत्ति मालिक सार्वजनिक प्रकटीकरण से बाहर रहने का विकल्प चुन सकते हैं.',
dsNsplName: 'राष्ट्रीय सांख्यिकी पोस्टकोड लुकअप (NSPL)',
dsNsplOrigin: 'ONS / ArcGIS',
dsNsplUse:
'पोस्टकोड को coordinates और statistical area codes से जोड़ता है, जिससे सभी area-level datasets को individual properties से लिंक किया जाता है.',
dsIodName: 'English Indices of Deprivation 2025',
'पोस्टकोड को निर्देशांकों और सांख्यिकीय क्षेत्र कोड से जोड़ता है, जिससे सभी क्षेत्र-स्तरीय डेटासेट को अलग-अलग संपत्तियों से जोड़ा जाता है.',
dsIodName: 'इंग्लैंड वंचना सूचकांक 2025',
dsIodOrigin: 'Ministry of Housing, Communities & Local Government',
dsIodUse:
'इंग्लैंड के हर neighbourhood के लिए income, employment, education, health, crime और living environment में national deprivation percentiles.',
dsEthnicityName: 'Population by Ethnicity (2021 Census)',
'इंग्लैंड के हर पड़ोस के लिए आय, रोजगार, शिक्षा, स्वास्थ्य, अपराध और रहने के वातावरण में राष्ट्रीय वंचना प्रतिशतक.',
dsEthnicityName: 'जातीयता के अनुसार जनसंख्या (2021 जनगणना)',
dsEthnicityOrigin: 'ONS',
dsEthnicityUse:
'Local authority के अनुसार ethnic group (South Asian, East Asian, Black, Mixed, White, Other) के population percentages.',
dsCrimeName: 'Street-level Crime Data',
'स्थानीय प्राधिकरण के अनुसार जातीय समूहों (दक्षिण एशियाई, पूर्वी एशियाई, अश्वेत, मिश्रित, श्वेत, अन्य) की जनसंख्या प्रतिशत.',
dsCrimeName: 'सड़क-स्तर अपराध डेटा',
dsCrimeOrigin: 'data.police.uk',
dsCrimeUse:
'2023 से 2025 तक सड़क-स्तर अपराध डेटा, LSOA और अपराध प्रकार (हिंसा, सेंधमारी, असामाजिक व्यवहार, ड्रग्स, वाहन अपराध आदि) के अनुसार वार्षिक औसत में समेकित.',
dsOsmName: 'OpenStreetMap POIs',
dsOsmName: 'OpenStreetMap रुचि-स्थल',
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
dsOsmUse:
'Great Britain भर में shops, restaurants, healthcare, leisure, tourism और अधिक को cover करने वाले points of interest.',
dsGeolytixRetailName: 'GEOLYTIX Grocery Retail Points',
'ग्रेट ब्रिटेन भर में दुकानों, रेस्तरां, स्वास्थ्य सेवा, अवकाश, पर्यटन आदि को शामिल करने वाले रुचि स्थल.',
dsGeolytixRetailName: 'GEOLYTIX किराना खुदरा बिंदु',
dsGeolytixRetailOrigin: 'GEOLYTIX',
dsGeolytixRetailUse:
'UK भर में supermarket और convenience store locations, जिनमें Waitrose, Tesco, Sainsburys, Asda, Morrisons, Aldi, Lidl, Co-op, M&S, Iceland और Spar जैसे chain retailers शामिल हैं.',
dsGreenspaceName: 'OS Open Greenspace',
'यूके भर में सुपरमार्केट और सुविधा स्टोर के स्थान, जिनमें Waitrose, Tesco, Sainsburys, Asda, Morrisons, Aldi, Lidl, Co-op, M&S, Iceland और Spar जैसे चेन रिटेलर शामिल हैं.',
dsGreenspaceName: 'OS खुला हरित क्षेत्र',
dsGreenspaceOrigin: 'Ordnance Survey',
dsGreenspaceUse:
'Great Britain के लिए authoritative green space boundaries, जिनमें public parks, gardens, playing fields और play spaces शामिल हैं. Park proximity counts और nearest-park distance calculations के लिए polygon centroids उपयोग होते हैं.',
dsNaptanName: 'NaPTAN (Public Transport Stops)',
'ग्रेट ब्रिटेन के लिए आधिकारिक हरित क्षेत्र सीमाएं, जिनमें सार्वजनिक पार्क, उद्यान, खेल मैदान और खेलने की जगहें शामिल हैं. पार्क निकटता गिनती और निकटतम पार्क दूरी गणना के लिए बहुभुज केंद्र बिंदु उपयोग होते हैं.',
dsNaptanName: 'NaPTAN (सार्वजनिक परिवहन स्टॉप)',
dsNaptanOrigin: 'Department for Transport',
dsNaptanUse:
'इंग्लैंड भर में rail, bus, metro/tram, ferry और airports के station और stop locations.',
dsNoiseName: 'Defra Noise Mapping',
'इंग्लैंड भर में रेल, बस, मेट्रो/ट्राम, फेरी और हवाई अड्डों के स्टेशन और स्टॉप स्थान.',
dsNoiseName: 'Defra शोर मैपिंग',
dsNoiseOrigin: 'Defra / Environment Agency',
dsNoiseUse:
'2022 strategic noise mapping से road noise levels (24-hour weighted average), high resolution पर modelled और हर postcode पर sampled.',
dsOfstedName: 'Ofsted School Inspections',
'2022 की रणनीतिक शोर मैपिंग से सड़क शोर स्तर (24-घंटे भारित औसत), उच्च विभेदन पर मॉडल किए गए और हर पोस्टकोड पर नमूना लिए गए.',
dsOfstedName: 'Ofsted स्कूल निरीक्षण',
dsOfstedOrigin: 'Ofsted',
dsOfstedUse:
'State-funded schools के latest inspection outcomes (April 2025 तक). Local school quality score देने के लिए प्रति postcode averaged (1=Outstanding से 4=Inadequate).',
dsBroadbandName: 'Ofcom Broadband Performance',
'सरकारी वित्तपोषित स्कूलों के नवीनतम निरीक्षण परिणाम (अप्रैल 2025 तक). स्थानीय स्कूल गुणवत्ता स्कोर देने के लिए प्रति पोस्टकोड औसत निकाला गया (1=उत्कृष्ट से 4=अपर्याप्त).',
dsBroadbandName: 'Ofcom ब्रॉडबैंड प्रदर्शन',
dsBroadbandOrigin: 'Ofcom',
dsBroadbandUse:
'Ofcom Connected Nations 2025 से area के अनुसार fixed broadband coverage और maximum download speeds.',
dsCouncilTaxName: 'Council Tax Levels 2025-26',
'Ofcom Connected Nations 2025 से क्षेत्र के अनुसार फिक्स्ड ब्रॉडबैंड कवरेज और अधिकतम डाउनलोड गति.',
dsCouncilTaxName: 'काउंसिल टैक्स स्तर 2025-26',
dsCouncilTaxOrigin: 'Ministry of Housing, Communities & Local Government',
dsCouncilTaxUse:
'England की सभी 296 billing authorities के लिए Bands A-H की annual council tax rates, दो वयस्कों द्वारा occupied dwelling के लिए. NSPL postcode lookup से local authority district code के जरिए properties से joined.',
dsRentalName: 'Private Rental Market Statistics',
'इंग्लैंड के सभी 296 बिलिंग प्राधिकरणों के लिए बैंड A-H की वार्षिक काउंसिल टैक्स दरें, दो वयस्कों द्वारा आबाद आवास के लिए. NSPL पोस्टकोड लुकअप से स्थानीय प्राधिकरण जिला कोड के जरिए संपत्तियों से जोड़ी गईं.',
dsRentalName: 'निजी किराया बाजार सांख्यिकी',
dsRentalOrigin: 'ONS / Valuation Office Agency',
dsRentalUse:
'Local authority और bedroom category के अनुसार median monthly private rental prices (Oct 2022 - Sep 2023). Local authority district code और estimated bedroom count से properties से joined.',
dsElectionName: '2024 General Election Results',
'स्थानीय प्राधिकरण और बेडरूम श्रेणी के अनुसार निजी मासिक किराये की मध्यिका कीमतें (अक्टूबर 2022 - सितंबर 2023). स्थानीय प्राधिकरण जिला कोड और अनुमानित बेडरूम संख्या के जरिए संपत्तियों से जोड़ी गईं.',
dsElectionName: '2024 आम चुनाव परिणाम',
dsElectionOrigin: 'UK Parliament',
dsElectionUse:
'July 2024 UK General Election के candidate-level results. Constituency level पर aggregated: voter turnout (%) और party vote shares (%). NSPL postcode lookup से parliamentary constituency code (pcon) के जरिए properties से joined.',
'जुलाई 2024 के यूके आम चुनाव के उम्मीदवार-स्तर परिणाम. निर्वाचन क्षेत्र स्तर पर संकलित: मतदान प्रतिशत (%) और पार्टी वोट शेयर (%). NSPL पोस्टकोड लुकअप से संसदीय निर्वाचन क्षेत्र कोड (pcon) के जरिए संपत्तियों से जोड़ा गया.',
faqFindingTitle: 'कहां देखें',
faqCommuteTitle: 'यात्रा समय',
faqBudgetTitle: 'अनुमानित कीमतें',
@ -554,81 +634,81 @@ const hi: Translations = {
faqCommute1Q: 'यात्रा समय कैसे गणना किए जाते हैं?',
faqCommute1A:
'यात्रा समय हर सहेजे गए गंतव्य के लिए पहले से निकाले जाते हैं. हम देखते हैं कि कौन से पोस्टकोड कार, साइकिल, पैदल या सार्वजनिक परिवहन से उस गंतव्य तक पहुंच सकते हैं, फिर परिणाम सहेजते हैं ताकि फिल्टर करते समय मानचित्र तेजी से जवाब दे.',
faqCommute2Q: 'Travel-time numbers के बारे में क्या जानना चाहिए?',
faqCommute2Q: 'यात्रा-समय के आंकड़ों के बारे में क्या जानना चाहिए?',
faqCommute2A:
'सार्वजनिक परिवहन समय सप्ताह के दिन सुबह के आवागमन पर आधारित हैं, 07:30 से 08:30 के बीच निकलने पर. सामान्य सेटिंग उस समय की आम यात्रा दिखाती है. ये योजना बनाने के अनुमान हैं, लाइव देरी, ट्रैफिक या आखिरी समय के प्लेटफॉर्म बदलाव नहीं.',
faqCommute3Q: 'Best case button कब उपयोग करें?',
faqCommute3Q: 'सर्वश्रेष्ठ स्थिति बटन कब उपयोग करें?',
faqCommute3A:
'सार्वजनिक परिवहन के लिए Best case button तब उपयोग करें जब आप अच्छी timing और अच्छे connections वाली यात्रा देखना चाहते हैं. रोजमर्रा की तुलना के लिए इसे off रखें.',
faqBudget1Q: 'आप मौजूदा property prices कैसे estimate करते हैं?',
'सार्वजनिक परिवहन के लिए सर्वश्रेष्ठ स्थिति बटन तब उपयोग करें जब आप अच्छे समय और अच्छे कनेक्शन वाली यात्रा देखना चाहते हैं. रोजमर्रा की तुलना के लिए इसे बंद रखें.',
faqBudget1Q: 'आप मौजूदा संपत्ति कीमतों का अनुमान कैसे लगाते हैं?',
faqBudget1A:
'Estimate घर की HM Land Registry में दर्ज आखिरी बिक्री कीमत से शुरू होता है. हम उसे आज के बाजार के करीब लाते हैं, यह देखकर कि इसी तरह के घरों की कीमत समय के साथ कैसे बदली है, खासकर पास के उसी प्रकार के घरों की. जहां स्थानीय बिक्री कम है, estimate बड़े क्षेत्र के रुझानों पर ज्यादा भरोसा करता है. फिर इसे पास की हाल की बिक्री और floor area से मिलाकर जांचा जाता है.',
faqBudget2Q: 'Last sold price के बजाय estimated current price क्यों उपयोग करें?',
'अनुमान घर की HM Land Registry में दर्ज आखिरी बिक्री कीमत से शुरू होता है. हम उसे आज के बाजार के करीब लाते हैं, यह देखकर कि इसी तरह के घरों की कीमत समय के साथ कैसे बदली है, खासकर पास के उसी प्रकार के घरों की. जहां स्थानीय बिक्री कम है, अनुमान बड़े क्षेत्र के रुझानों पर ज्यादा भरोसा करता है. फिर इसे पास की हाल की बिक्री और फर्श क्षेत्र से मिलाकर जांचा जाता है.',
faqBudget2Q: 'अंतिम बिक्री कीमत के बजाय अनुमानित मौजूदा कीमत क्यों उपयोग करें?',
faqBudget2A:
'अंतिम बिक्री कीमत कई साल या दशकों पुरानी हो सकती है, जबकि मांग कीमतें केवल आज सूचीबद्ध घरों को दिखाती हैं. अनुमानित मौजूदा कीमत पुराने सौदों को आज के बाजार के करीब लाती है, ताकि आप अधिक घरों की तुलना कर सकें और बेहतर value वाले क्षेत्र पहचान सकें. इसे shortlist बनाने की guide मानें, bank valuation नहीं.',
'अंतिम बिक्री कीमत कई साल या दशकों पुरानी हो सकती है, जबकि मांग कीमतें केवल आज सूचीबद्ध घरों को दिखाती हैं. अनुमानित मौजूदा कीमत पुराने सौदों को आज के बाजार के करीब लाती है, ताकि आप अधिक घरों की तुलना कर सकें और बेहतर मूल्य वाले क्षेत्र पहचान सकें. इसे सूची बनाने की मार्गदर्शिका मानें, बैंक मूल्यांकन नहीं.',
faqSafety1Q: 'इस पोस्टकोड के आसपास किस तरह का अपराध आम है?',
faqSafety1A:
'पुलिस-रिकॉर्ड अपराध प्रकार के अनुसार बंटा होता है, जिसमें हिंसा, सेंधमारी, लूट, वाहन अपराध, असामाजिक व्यवहार, दुकान से चोरी, ड्रग्स और सार्वजनिक व्यवस्था शामिल हैं. आप अस्पष्ट सुरक्षा स्कोर पर निर्भर रहने के बजाय खास जोखिमों से फिल्टर कर सकते हैं.',
faqSafety2Q: 'किसी अनजान सड़क पर viewing से पहले क्या जांचना चाहिए?',
faqSafety2Q: 'किसी अनजान सड़क पर मकान देखने से पहले क्या जांचना चाहिए?',
faqSafety2A:
'मकान देखने की बुकिंग से पहले अपराध, सड़क शोर, इंटरनेट, पार्क, किराना, स्कूल और आवागमन जांचें. लिस्टिंग फोटो उपयोगी हो सकती हैं, पर सड़क कैसी है यह पहली बार उनसे नहीं पता चलना चाहिए.',
faqFamilies1Q: 'किन क्षेत्रों में schools, space, safety और commute का सही मिश्रण है?',
faqFamilies1Q: 'किन क्षेत्रों में स्कूल, जगह, सुरक्षा और आवागमन का सही मिश्रण है?',
faqFamilies1A:
'School ratings, अपराध, पार्क, आवागमन, जगह, घर का प्रकार और बजट को एक मानचित्र पर रखें. परिणाम अलग-अलग स्कूल, अपराध, लिस्टिंग और परिवहन खोजों के ढेर के बजाय व्यावहारिक पारिवारिक shortlist है.',
faqFamilies2Q: 'क्या यह साबित करता है कि मैं school catchment के अंदर हूं?',
'स्कूल रेटिंग, अपराध, पार्क, आवागमन, जगह, घर का प्रकार और बजट को एक मानचित्र पर रखें. परिणाम अलग-अलग स्कूल, अपराध, लिस्टिंग और परिवहन खोजों के ढेर के बजाय व्यावहारिक पारिवारिक सूची है.',
faqFamilies2Q: 'क्या यह साबित करता है कि मैं स्कूल के प्रवेश क्षेत्र में हूं?',
faqFamilies2A:
'नहीं. हम नजदीकी स्कूल गुणवत्ता और स्थानीय शिक्षा जानकारी दिखाते हैं, लेकिन प्रवेश सीमाएं और प्राथमिकता नियम बदल सकते हैं. Perfect Postcode से जगहें shortlist करें, फिर स्कूल या स्थानीय council से catchment और admission जांचें.',
faqEnv1Q: 'Commute या broadband quality खोए बिना noisy road से कैसे बचूं?',
'नहीं. हम नजदीकी स्कूल गुणवत्ता और स्थानीय शिक्षा जानकारी दिखाते हैं, लेकिन प्रवेश सीमाएं और प्राथमिकता नियम बदल सकते हैं. Perfect Postcode से जगहें चुनें, फिर स्कूल या स्थानीय काउंसिल से प्रवेश क्षेत्र और दाखिला नियम जांचें.',
faqEnv1Q: 'आवागमन या ब्रॉडबैंड गुणवत्ता खोए बिना शोरगुल वाली सड़क से कैसे बचूं?',
faqEnv1A:
'सड़क शोर से filter करें, फिर आवागमन, इंटरनेट, कीमत और घर के filters चालू रखें. आप एक feature से map रंग सकते हैं जबकि बाकी shortlist को उपयोगी बनाए रखते हैं.',
faqEnv2Q: 'क्या आप flood risk, subsidence या survey issues दिखाते हैं?',
'सड़क शोर से फिल्टर करें, फिर आवागमन, इंटरनेट, कीमत और घर के फिल्टर चालू रखें. आप एक फीचर से मानचित्र रंग सकते हैं जबकि बाकी चुनी हुई सूची को उपयोगी बनाए रखते हैं.',
faqEnv2Q: 'क्या आप बाढ़ जोखिम, धंसाव या सर्वेक्षण समस्याएं दिखाते हैं?',
faqEnv2A:
'आज नहीं. हम सड़क शोर, energy rating, building age और postcode के आसपास का local environment दिखाते हैं. Flood risk, legal issues, structural issues, mortgage concerns और survey findings खरीदने से पहले अलग से जांचने होंगे.',
faqEnv3Q: 'Viewing से पहले running-cost checks क्या कर सकता हूं?',
'अभी नहीं. हम सड़क शोर, ऊर्जा रेटिंग, निर्माण आयु और पोस्टकोड के आसपास का स्थानीय वातावरण दिखाते हैं. बाढ़ जोखिम, कानूनी मुद्दे, संरचनात्मक समस्याएं, बंधक संबंधी चिंताएं और सर्वेक्षण निष्कर्ष खरीदने से पहले अलग से जांचने होंगे.',
faqEnv3Q: 'मकान देखने से पहले चालू खर्च की कौन सी जांच कर सकता हूं?',
faqEnv3A:
'आप मकान देखने से पहले energy rating, floor area, building age, council tax area, इंटरनेट और शोर देख सकते हैं. यह आपके सटीक बिलों की भविष्यवाणी नहीं करेगा, पर साफ तौर पर गलत विकल्पों से जल्दी बचने में मदद करेगा.',
'आप मकान देखने से पहले ऊर्जा रेटिंग, फर्श क्षेत्र, निर्माण आयु, काउंसिल टैक्स क्षेत्र, इंटरनेट और शोर देख सकते हैं. यह आपके सटीक बिलों की भविष्यवाणी नहीं करेगा, पर साफ तौर पर गलत विकल्पों से जल्दी बचने में मदद करेगा.',
faqDueDiligence1Q: 'क्या मुझे Rightmove देखने से पहले या बाद में इसका उपयोग करना चाहिए?',
faqDueDiligence1A:
'Perfect Postcode का उपयोग listing sites से पहले और साथ-साथ करें. Rightmove, Zoopla और OnTheMarket अभी भी अभी बिक रहे घर, फोटो, एजेंट, viewing और alerts देखने की जगह हैं. Perfect Postcode आपको तय करने में मदद करता है कि किन पोस्टकोड में खोज करनी है.',
faqDueDiligence2Q: 'मैं garden, garage या layout से filter क्यों नहीं कर सकता?',
'Perfect Postcode का उपयोग लिस्टिंग साइटों से पहले और साथ-साथ करें. Rightmove, Zoopla और OnTheMarket अभी भी अभी बिक रहे घर, फोटो, एजेंट, मकान देखने और अलर्ट देखने की जगह हैं. Perfect Postcode आपको तय करने में मदद करता है कि किन पोस्टकोड में खोज करनी है.',
faqDueDiligence2Q: 'मैं बगीचे, गैरेज या लेआउट से फिल्टर क्यों नहीं कर सकता?',
faqDueDiligence2A:
'ये details हर घर के लिए भरोसेमंद तरीके से उपलब्ध नहीं होतीं. Perfect Postcode floor area, home type, ownership type, energy rating, sold prices और local information से filter कर सकता है. बगीचे, गैरेज, दिशा, कमरे और agent wording अभी भी listing और viewing में जांचनी होगी.',
faqDueDiligence3Q: 'क्या आप listing price cuts और time on market track करते हैं?',
'ये विवरण हर घर के लिए भरोसेमंद तरीके से उपलब्ध नहीं होते. Perfect Postcode फर्श क्षेत्र, घर का प्रकार, मालिकाना प्रकार, ऊर्जा रेटिंग, बेची कीमतें और स्थानीय जानकारी से फिल्टर कर सकता है. बगीचे, गैरेज, दिशा, कमरे और एजेंट की भाषा अभी भी लिस्टिंग और मकान देखने में जांचनी होगी.',
faqDueDiligence3Q: 'क्या आप लिस्टिंग की कीमत कटौती और बाजार में बिताया समय ट्रैक करते हैं?',
faqDueDiligence3A:
'अभी नहीं. Perfect Postcode sold prices, energy ratings, postcode data, travel times और neighbourhood information पर बना है, live listing changes पर नहीं. आप फिर भी sale history, estimated current value और price per square metre से अंदाजा लगा सकते हैं कि asking price ज्यादा तो नहीं लगती.',
faqDueDiligence4Q: 'Offer करने से पहले मुझे क्या verify करना चाहिए?',
'अभी नहीं. Perfect Postcode बेची कीमतों, ऊर्जा रेटिंग, पोस्टकोड डेटा, यात्रा समय और पड़ोस जानकारी पर बना है, लाइव लिस्टिंग बदलाव पर नहीं. आप फिर भी बिक्री इतिहास, अनुमानित मौजूदा मूल्य और प्रति वर्ग मीटर कीमत से अंदाजा लगा सकते हैं कि मांग कीमत ज्यादा तो नहीं लगती.',
faqDueDiligence4Q: 'प्रस्ताव देने से पहले मुझे क्या सत्यापित करना चाहिए?',
faqDueDiligence4A:
'क्षेत्र और संभावित value जांचने के लिए Perfect Postcode उपयोग करें, फिर offer करने से पहले listing details confirm करें. Ownership type, lease details, service charges, planning history, flood risk, legal issues, mortgage requirements और survey results भी जांचें.',
faqPrivacy1Q: 'क्या आप मेरे बारे में personal data store करते हैं?',
'क्षेत्र और संभावित मूल्य जांचने के लिए Perfect Postcode उपयोग करें, फिर प्रस्ताव देने से पहले लिस्टिंग विवरण पुष्टि करें. मालिकाना प्रकार, लीज विवरण, सेवा शुल्क, योजना इतिहास, बाढ़ जोखिम, कानूनी मुद्दे, बंधक संबंधी शर्तें और सर्वेक्षण परिणाम भी जांचें.',
faqPrivacy1Q: 'क्या आप मेरे बारे में व्यक्तिगत डेटा संग्रहीत करते हैं?',
faqPrivacy1A:
'Property और neighbourhood information में आपके personal details नहीं होते. अगर आप account बनाते हैं, तो हम service चलाने के लिए जरूरी चीजें रखते हैं, जैसे email address, access status, newsletter choice, saved searches, saved properties और Stripe द्वारा handled payments. Account data को UK privacy law के तहत संभाला जाता है.',
faqWhy1Q: 'यह क्या दिखाता है जो listing portals आमतौर पर नहीं दिखाते?',
'संपत्ति और पड़ोस जानकारी में आपके व्यक्तिगत विवरण नहीं होते. अगर आप खाता बनाते हैं, तो हम सेवा चलाने के लिए जरूरी चीजें रखते हैं, जैसे ईमेल पता, एक्सेस स्थिति, न्यूज़लेटर चयन, सहेजी गई खोजें, सहेजी गई संपत्तियां और Stripe द्वारा संभाले गए भुगतान. खाते का डेटा UK गोपनीयता कानून के तहत संभाला जाता है.',
faqWhy1Q: 'यह क्या दिखाता है जो लिस्टिंग पोर्टल आमतौर पर नहीं दिखाते?',
faqWhy1A:
'Listing sites आज बिक रहे घरों से शुरू करती हैं. Perfect Postcode उन जगहों से शुरू करता है जो आपके जीवन और बजट से मेल खाती हैं, sold prices, space, commute, schools, crime, noise, internet, energy rating, ownership type और amenities के साथ, listings खोलने से पहले.',
faqWhy2Q: 'यह कितनी manual research बचाता है?',
'लिस्टिंग साइटें आज बिक रहे घरों से शुरू करती हैं. Perfect Postcode उन जगहों से शुरू करता है जो आपके जीवन और बजट से मेल खाती हैं, बेची कीमतों, जगह, आवागमन, स्कूल, अपराध, शोर, इंटरनेट, ऊर्जा रेटिंग, मालिकाना प्रकार और सुविधाओं के साथ, लिस्टिंग खोलने से पहले.',
faqWhy2Q: 'यह हाथ से की जाने वाली कितनी खोजबीन बचाता है?',
faqWhy2A:
'आप खुद कर सकते हैं, लेकिन तब sold prices, energy ratings, crime, schools, internet, local facts, environment, travel times और maps को एक-एक postcode से जांचना होगा. Perfect Postcode इन्हें England के searchable map में साथ रखता है.',
faqWhy3Q: 'Data कितना reliable है?',
'आप खुद कर सकते हैं, लेकिन तब बेची कीमतें, ऊर्जा रेटिंग, अपराध, स्कूल, इंटरनेट, स्थानीय तथ्य, पर्यावरण, यात्रा समय और मानचित्रों को एक-एक पोस्टकोड से जांचना होगा. Perfect Postcode इन्हें इंग्लैंड के खोज योग्य मानचित्र में साथ रखता है.',
faqWhy3Q: 'डेटा कितना भरोसेमंद है?',
faqWhy3A:
'मुख्य sources official या widely used public data हैं: sold prices, energy ratings, local facts, school ratings, internet, crime, environment, maps और street data. ये shortlist और comparison के लिए उपयोगी हैं, लेकिन खरीद निर्णय से पहले current checks और जरूरत हो तो expert advice जरूरी है.',
faqPricing1Q: 'जब postcode reports free हैं तो भुगतान क्यों करें?',
'मुख्य स्रोत आधिकारिक या व्यापक रूप से उपयोग किए जाने वाले सार्वजनिक डेटा हैं: बेची कीमतें, ऊर्जा रेटिंग, स्थानीय तथ्य, स्कूल रेटिंग, इंटरनेट, अपराध, पर्यावरण, मानचित्र और सड़क डेटा. ये सूची बनाने और तुलना के लिए उपयोगी हैं, लेकिन खरीद निर्णय से पहले वर्तमान जांच और जरूरत हो तो विशेषज्ञ सलाह जरूरी है.',
faqPricing1Q: 'जब पोस्टकोड रिपोर्ट मुफ्त हैं तो भुगतान क्यों करें?',
faqPricing1A:
'मुफ्त postcode tools तब उपयोगी हैं जब आप पहले से जानते हैं कि क्या जांचना है. Perfect Postcode England के हर postcode को आपकी जरूरतों के हिसाब से देखने, filters जोड़ने, options compare करने, searches save करने और viewings से पहले shortlist export करने के लिए है.',
faqPricing2Q: 'Lifetime access का क्या मतलब है?',
'मुफ्त पोस्टकोड टूल तब उपयोगी हैं जब आप पहले से जानते हैं कि क्या जांचना है. Perfect Postcode इंग्लैंड के हर पोस्टकोड को आपकी जरूरतों के हिसाब से देखने, फिल्टर जोड़ने, विकल्पों की तुलना करने, खोजें सहेजने और मकान देखने से पहले सूची निर्यात करने के लिए है.',
faqPricing2Q: 'आजीवन पहुंच का क्या मतलब है?',
faqPricing2A:
'लाइफटाइम एक्सेस का मतलब है कि एक भुगतान आपके खाते को सेवा की अवधि तक paid Perfect Postcode मानचित्र का लगातार एक्सेस देता है. यह मासिक या वार्षिक सदस्यता नहीं है, और सामान्य डेटा अपडेट शामिल हैं. आप इसे इस खोज में उपयोग कर सकते हैं, बाद में लौट सकते हैं और फिर भी एक्सेस रहेगा अगर आप फिर स्थान बदलते हैं.',
faqPricing3Q: 'Free tier पर मैं क्या access कर सकता हूं?',
'आजीवन पहुंच का मतलब है कि एक भुगतान आपके खाते को सेवा की अवधि तक सशुल्क Perfect Postcode मानचित्र का लगातार एक्सेस देता है. यह मासिक या वार्षिक सदस्यता नहीं है, और सामान्य डेटा अपडेट शामिल हैं. आप इसे इस खोज में उपयोग कर सकते हैं, बाद में लौट सकते हैं और फिर भी एक्सेस रहेगा अगर आप फिर स्थान बदलते हैं.',
faqPricing3Q: 'मुफ्त स्तर पर मैं क्या उपयोग कर सकता हूं?',
faqPricing3A:
'मुफ्त उपयोगकर्ता डेमो क्षेत्र (inner London, लगभग zones 1 to 2) के अंदर सभी फीचर देख सकते हैं. England के बाकी डेटा के लिए लाइफटाइम एक्सेस चाहिए.',
faqTips1Q: 'Map पर filter preview कैसे करें?',
'मुफ्त उपयोगकर्ता डेमो क्षेत्र (इनर लंदन, लगभग जोन 1 से 2) के अंदर सभी फीचर देख सकते हैं. इंग्लैंड के बाकी डेटा के लिए आजीवन पहुंच चाहिए.',
faqTips1Q: 'मानचित्र पर फिल्टर पूर्वावलोकन कैसे करें?',
faqTips1A:
'किसी filter या feature के पास Colour पर click करें ताकि map उसी item से colour हो जाए. आपके active filters वैसे ही रहते हैं, इसलिए आप price, commute time, schools, crime या noise जैसी एक चीज shortlist बदले बिना compare कर सकते हैं.',
faqTips2Q: 'किसी filter का मतलब कैसे जानूं?',
'किसी फिल्टर या फीचर के पास रंगें पर क्लिक करें ताकि मानचित्र उसी मद से रंग जाए. आपके सक्रिय फिल्टर वैसे ही रहते हैं, इसलिए आप कीमत, आवागमन समय, स्कूल, अपराध या शोर जैसी एक चीज सूची बदले बिना तुलना कर सकते हैं.',
faqTips2Q: 'किसी फिल्टर का मतलब कैसे जानूं?',
faqTips2A:
'किसी filter या feature के पास About पर click करें ताकि छोटा explanation खुले कि उसका मतलब क्या है और उसे कैसे पढ़ें. Map के कुछ हिस्सों, जैसे travel-time cards, की अपनी data explanation भी होती है.',
faqTips3Q: 'Map colours कैसे refresh करें?',
'किसी फिल्टर या फीचर के पास जानकारी पर क्लिक करें ताकि छोटा स्पष्टीकरण खुले कि उसका मतलब क्या है और उसे कैसे पढ़ें. मानचित्र के कुछ हिस्सों, जैसे यात्रा-समय कार्ड, की अपनी डेटा जानकारी भी होती है.',
faqTips3Q: 'मानचित्र के रंग कैसे ताज़ा करें?',
faqTips3A:
'जब कोई feature map को colour कर रहा हो, तो map legend में Reset colour scale उपयोग करें ताकि अभी दिख रहे results के colours refresh हों. Map move, zoom या filters बदलने के बाद यह उपयोगी है.',
'जब कोई फीचर मानचित्र को रंग रहा हो, तो मानचित्र संकेतक में रंग स्केल रीसेट करें उपयोग करें ताकि अभी दिख रहे परिणामों के रंग ताज़ा हों. मानचित्र खिसकाने, ज़ूम करने या फिल्टर बदलने के बाद यह उपयोगी है.',
},
accountPage: {
@ -658,7 +738,7 @@ const hi: Translations = {
deleteProperty: 'संपत्ति हटाएं',
deletePropertyConfirm:
'क्या आप वाकई यह सहेजी गई संपत्ति हटाना चाहते हैं? इसे वापस नहीं किया जा सकता.',
bed: 'bed',
bed: 'बेडरूम',
epc: 'EPC',
},
@ -690,7 +770,7 @@ const hi: Translations = {
exploreEvery: 'वे पोस्टकोड खोजें जो आपकी जिंदगी से मेल खाते हैं',
propertyInfo: 'कीमतें, आवागमन, स्कूल, अपराध, शोर, ब्रॉडबैंड, EPC और अधिक',
invalidInvite: 'अमान्य आमंत्रण',
inviteAlreadyUsed: 'Invite पहले ही उपयोग हो चुका है',
inviteAlreadyUsed: 'आमंत्रण पहले ही उपयोग हो चुका है',
inviteAlreadyUsedDesc: 'यह आमंत्रण लिंक पहले ही भुनाया जा चुका है.',
invalidInviteLink: 'अमान्य आमंत्रण लिंक',
invalidInviteLinkDesc: 'यह आमंत्रण लिंक अमान्य है या समाप्त हो चुका है.',
@ -712,9 +792,9 @@ const hi: Translations = {
format: {
justNow: 'अभी',
minutesAgo: '{{count}}m पहले',
hoursAgo: '{{count}}h पहले',
daysAgo: '{{count}}d पहले',
minutesAgo: '{{count}} मिनट पहले',
hoursAgo: '{{count}} घंटे पहले',
daysAgo: '{{count}} दिन पहले',
nFilters: '{{count}} फिल्टर',
noFilters: 'कोई फिल्टर नहीं',
poiCategory: '{{count}} POI श्रेणी',
@ -735,7 +815,7 @@ const hi: Translations = {
'अपना बजट, आवागमन सीमा, स्कूल गुणवत्ता, अपराध सीमा, शोर सहनशीलता, ब्रॉडबैंड जरूरतें या जो भी आपके लिए मायने रखता है सेट करें. केवल मेल खाते क्षेत्र रोशन रहते हैं. किसी भी फीचर से रंगने के लिए आंख वाले आइकन का उपयोग करें.',
step2Title: 'या बस वर्णन करें',
step2Content:
'साधारण भाषा में लिखें, जैसे "quiet area near good schools under £400k", और हम आपके लिए फिल्टर सेट कर देंगे.',
'साधारण भाषा में लिखें, जैसे "अच्छे स्कूलों के पास शांत इलाका £400,000 से कम", और हम आपके लिए फिल्टर सेट कर देंगे.',
step3Title: 'देखें क्या उपलब्ध है',
step3Content:
'इंग्लैंड भर में पैन और जूम करें. किसी भी रंगीन क्षेत्र पर क्लिक करें और देखें यह क्यों मेल खाता है: अपराध, स्कूल, कीमतें, ब्रॉडबैंड, शोर और अधिक.',
@ -753,11 +833,11 @@ const hi: Translations = {
Properties: 'संपत्तियां',
Transport: 'परिवहन',
Education: 'शिक्षा',
Deprivation: 'वंचना',
'Area characteristics': 'क्षेत्र की विशेषताएँ',
Crime: 'अपराध',
Demographics: 'जनसांख्यिकी',
Politics: 'राजनीति',
Neighbours: 'पड़ोसी',
Amenities: 'सुविधाएं',
Environment: 'पर्यावरण',
'Property type': 'संपत्ति प्रकार',
'Leasehold/Freehold': 'लीजहोल्ड/फ्रीहोल्ड',
'Last known price': 'अंतिम ज्ञात कीमत',
@ -774,25 +854,26 @@ const hi: Translations = {
'Current energy rating': 'मौजूदा ऊर्जा रेटिंग',
'Potential energy rating': 'संभावित ऊर्जा रेटिंग',
'Interior height (m)': 'भीतरी ऊंचाई (मी)',
'Distance to nearest train or tube station (km)': 'निकटतम ट्रेन या ट्यूब स्टेशन तक दूरी (किमी)',
'Travel time to nearest train or tube station (min)':
'निकटतम ट्रेन या ट्यूब स्टेशन तक यात्रा समय (मिनट)',
'Good+ primary schools within 2km': '2 किमी के अंदर Good+ प्राथमिक स्कूल',
'Good+ secondary schools within 2km': '2 किमी के अंदर Good+ सेकेंडरी स्कूल',
'Good+ primary schools within 5km': '5 किमी के अंदर Good+ प्राथमिक स्कूल',
'Good+ secondary schools within 5km': '5 किमी के अंदर Good+ सेकेंडरी स्कूल',
'Outstanding primary schools within 2km': '2 किमी के अंदर Outstanding प्राथमिक स्कूल',
'Outstanding secondary schools within 2km': '2 किमी के अंदर Outstanding सेकेंडरी स्कूल',
'Outstanding primary schools within 5km': '5 किमी के अंदर Outstanding प्राथमिक स्कूल',
'Outstanding secondary schools within 5km': '5 किमी के अंदर Outstanding सेकेंडरी स्कूल',
'Good+ primary schools within 2km': '2 किमी के अंदर अच्छी या बेहतर रेटिंग वाले प्राथमिक स्कूल',
'Good+ secondary schools within 2km':
'2 किमी के अंदर अच्छी या बेहतर रेटिंग वाले सेकेंडरी स्कूल',
'Good+ primary schools within 5km': '5 किमी के अंदर अच्छी या बेहतर रेटिंग वाले प्राथमिक स्कूल',
'Good+ secondary schools within 5km':
'5 किमी के अंदर अच्छी या बेहतर रेटिंग वाले सेकेंडरी स्कूल',
'Outstanding primary schools within 2km': '2 किमी के अंदर उत्कृष्ट प्राथमिक स्कूल',
'Outstanding secondary schools within 2km': '2 किमी के अंदर उत्कृष्ट सेकेंडरी स्कूल',
'Outstanding primary schools within 5km': '5 किमी के अंदर उत्कृष्ट प्राथमिक स्कूल',
'Outstanding secondary schools within 5km': '5 किमी के अंदर उत्कृष्ट सेकेंडरी स्कूल',
'Education, Skills and Training Score': 'शिक्षा, कौशल और प्रशिक्षण स्कोर',
'Income Score': 'आय स्कोर',
'Employment Score': 'रोजगार स्कोर',
'Health Deprivation and Disability Score': 'स्वास्थ्य वंचना और विकलांगता स्कोर',
'Housing Conditions Score': 'आवास स्थिति स्कोर',
'Air Quality and Road Safety Score': 'हवा की गुणवत्ता और सड़क सुरक्षा स्कोर',
'Serious crime per 1k residents (avg/yr)': 'प्रति 1k निवासियों गंभीर अपराध (औसत/वर्ष)',
'Minor crime per 1k residents (avg/yr)': 'प्रति 1k निवासियों मामूली अपराध (औसत/वर्ष)',
'Serious crime per 1k residents (avg/yr)': 'प्रति 1,000 निवासियों गंभीर अपराध (औसत/वर्ष)',
'Minor crime per 1k residents (avg/yr)': 'प्रति 1,000 निवासियों मामूली अपराध (औसत/वर्ष)',
'Serious crime (avg/yr)': 'गंभीर अपराध (औसत/वर्ष)',
'Minor crime (avg/yr)': 'मामूली अपराध (औसत/वर्ष)',
'Violence and sexual offences (avg/yr)': 'हिंसा और यौन अपराध (औसत/वर्ष)',
@ -825,11 +906,14 @@ const hi: Translations = {
'% Other parties': '% अन्य पार्टियां',
'Distance to nearest park (km)': 'निकटतम पार्क तक दूरी (किमी)',
'Number of parks within 1km': '1 किमी के अंदर पार्कों की संख्या',
'Number of restaurants within 2km': '2 किमी के अंदर रेस्तरां की संख्या',
'Number of grocery shops and supermarkets within 2km':
'2 किमी के अंदर किराना दुकानों और सुपरमार्केट की संख्या',
'Noise (dB)': 'शोर (dB)',
'Max available download speed (Mbps)': 'अधिकतम उपलब्ध डाउनलोड स्पीड (Mbps)',
Schools: 'स्कूल',
'Specific crimes': 'विशिष्ट अपराध',
Ethnicities: 'जातीय समूह',
'POI distance': 'POI दूरी',
'POIs within 2km': '2 किमी के अंदर POI',
'POIs within 5km': '5 किमी के अंदर POI',
Detached: 'अलग मकान',
'Semi-Detached': 'अर्ध-स्वतंत्र मकान',
Terraced: 'कतारबद्ध मकान',
@ -843,6 +927,9 @@ const hi: Translations = {
'Minor crime': 'मामूली अपराध',
'Ethnic composition': 'जातीय संरचना',
'Political vote share': 'राजनीतिक वोट शेयर',
'Anti-social': 'असामाजिक',
Vehicle: 'वाहन',
Burglary: 'सेंधमारी',
'Public Transport': 'सार्वजनिक परिवहन',
Leisure: 'अवकाश',
'Food & Drink': 'खाना-पीना',

View file

@ -7,6 +7,8 @@ const hu: Translations = {
cancel: 'Mégse',
close: 'Bezárás',
delete: 'Törlés',
finish: 'Befejezés',
language: 'Nyelv',
open: 'Megnyitás',
share: 'Megosztás',
copy: 'Másolás',
@ -21,6 +23,7 @@ const hu: Translations = {
viewDataSource: 'Adatforrás megtekintése',
total: 'Összesen',
min: 'perc',
max: 'max.',
or: 'vagy',
area: 'Terület',
properties: 'Ingatlanok',
@ -31,6 +34,11 @@ const hu: Translations = {
clickForDetails: 'Kattints a részletekhez',
property: 'ingatlan',
propertiesPlural: 'ingatlanok',
places: 'hely',
noData: 'Nincs adat',
allLow: 'Mind alacsony',
connectingToServer: 'Kapcsolódás a szerverhez...',
closePane: 'Panel bezárása',
},
// ── Header / Nav ───────────────────────────────────
@ -72,6 +80,35 @@ const hu: Translations = {
home: 'Főoldal',
},
// ── Toasts ─────────────────────────────────────────
toasts: {
propertySaved: 'Ingatlan mentve!',
viewSaved: 'Mentett megtekintése',
dontShowAgain: 'Ne mutasd újra',
},
// ── SEO Page Chrome ────────────────────────────────
seo: {
breadcrumb: 'Morzsanavigáció',
reviewDataSources: 'Adatforrások áttekintése',
whatYouCanCompare: 'Mit tudsz összehasonlítani',
whatYouCanCompareDesc:
'Minden oldal valódi előszűrési munkára épül: a lehetetlen helyek kizárására, a megmaradó irányítószámok összevetésére és a következő ellenőrzés eldöntésére.',
howToUseIt: 'Használat',
howToUseItDesc:
'Ezekkel a munkafolyamatokkal az oldal már azelőtt hasznos, hogy hirdetési portált nyitnál vagy megtekintést foglalnál.',
methodAndLimitations: 'Módszer és korlátok',
methodAndLimitationsDesc:
'Az adatok összehasonlításra és előszűrésre szolgálnak. Fontos döntésekhez továbbra is aktuális hirdetések, szakmai ellenőrzések és közvetlen helyi validálás szükséges.',
questionsBuyersAsk: 'Vevői kérdések',
relatedGuides: 'Kapcsolódó útmutatók',
relatedGuidesDesc: 'Folytasd az indexelt nyilvános oldalakon kanonikus belső hivatkozásokkal.',
frequentlyAskedQuestions: 'Gyakori kérdések',
relatedPages: 'Kapcsolódó oldalak',
relatedPagesDesc:
'Ezekkel a belső linkekkel ugyanazt az ingatlankeresési folyamatot más nézőpontból hasonlíthatod össze.',
},
// ── Auth Modal ─────────────────────────────────────
auth: {
logIn: 'Bejelentkezés',
@ -86,9 +123,9 @@ const hu: Translations = {
passwordPlaceholderRegister: 'Minimum 8 karakter',
passwordPlaceholderLogin: 'Jelszavad',
forgotPassword: 'Elfelejtetted a jelszavad?',
resetSent: 'Ellenőrizd az e-mailjeidet a visszaállító linkhez.',
resetSent: 'Ellenőrizd az e-mailjeidet a visszaállító hivatkozáshoz.',
pleaseWait: 'Kérjük, várj...',
sendResetLink: 'Visszaállító link küldése',
sendResetLink: 'Visszaállító hivatkozás küldése',
backToLogin: 'Vissza a bejelentkezéshez',
},
@ -163,6 +200,20 @@ const hu: Translations = {
clearAllSavePrompt: 'Szeretnéd menteni a jelenlegi szűrőket a törlés előtt?',
saveAndClear: 'Mentés és törlés',
clearWithoutSaving: 'Törlés mentés nélkül',
withoutThisFilter: '+{{value}} e szűrő nélkül',
schoolType: 'Iskolatípus',
schoolRating: 'Iskolai értékelés',
schoolDistance: 'Iskolatávolság',
primary: 'Általános',
secondary: 'Középiskola',
rating: 'Értékelés',
goodPlus: 'Jó+',
outstanding: 'Kiváló',
distance: 'Távolság',
crimeType: 'Bűncselekménytípus',
ethnicity: 'Etnikai csoport',
poiType: 'POI-típus',
party: 'Párt',
},
// ── Philosophy Popup ───────────────────────────────
@ -246,6 +297,16 @@ const hu: Translations = {
previewing: '\u201c{{name}}\u201d előnézete',
},
// ── Map ────────────────────────────────────────────
map: {
ogTitle: 'A te tökéletes irányítószámod',
ogPropertyPrices: 'Ingatlanárak',
ogEnergyRatings: 'Energiaértékelések',
ogSchools: 'Iskolák',
ogCrimeStats: 'Bűnözési adatok',
ogTransport: 'Közlekedés',
},
// ── Properties Pane ────────────────────────────────
propertyCard: {
unknownAddress: 'Ismeretlen cím',
@ -276,15 +337,19 @@ const hu: Translations = {
areaOverview: 'Áttekintés',
statsFor: 'Statisztikák a(z) {{type}} összes ingatlanáról',
matchingFilters: ' az összes aktív szűrőnek megfelelően',
filtersAffectStats:
'A szűrők itt is érvényesek: az értékek, diagramok és ingatlanszámok a(z) {{count}} aktív szűrőt használják.',
statsBasis: 'Statisztika alapja',
matchingFiltersOption: 'Illeszkedő szűrők',
allPropertiesOption: 'Összes ingatlan',
filtersAffectStats: 'A panel statisztikái a(z) {{count}} aktív szűrőt használják.',
filtersIgnoredForStats: 'A kiválasztott terület összes ingatlanának statisztikái láthatók.',
noFiltersAffectStats:
'Adjon hozzá szűrőket, hogy ezek az értékek az illeszkedő ingatlanokra számolódjanak újra.',
noFilteredMatches: 'Ezen a területen egyetlen ingatlan sem felel meg a szűrőknek.',
unfilteredAreaCount:
'{{count}} ingatlan található itt szűrők nélkül, tehát a hely érvényes, csak a szűrők kizárják.',
noUnfilteredAreaProperties: 'A kiválasztott területen szűrők nélkül sem található ingatlan.',
relaxFiltersHint: 'Lazítson vagy törölje a szűrőket, hogy lássa a terület ingatlanjait.',
'Nincsenek aktív szűrők; a statisztikák a terület összes ingatlanára vonatkoznak.',
filteredStatsEmpty: 'A szűrt statisztikák üresek',
showAllStatsHint:
'{{count}} ingatlan található itt szűrők nélkül. Váltson az összes ingatlanra a terület áttekintéséhez.',
showAllStatsFallback:
'Váltson az összes ingatlanra, hogy aktív szűrők nélkül tekintse át ezt a területet.',
showAllStats: 'Összes ingatlan mutatása',
viewProperties: '{{count}} ingatlan megtekintése',
viewPropertiesShort: 'Ingatlanok megtekintése',
priceHistory: 'Ártörténet',
@ -359,12 +424,32 @@ const hu: Translations = {
'Állítsd be a költségvetést, ingázást, iskolákat, biztonságot, zajt, internetet és életstílust. A Perfect Postcode átnézi Anglia irányítószámait, és megmutatja azokat a helyeket is, amelyeket sosem írtál volna be egy ingatlanportálra.',
exploreTheMap: 'Megfelelő irányítószámok keresése',
seeTheDifference: 'Így működik',
productDemoLabel: 'Perfect Postcode termékdemó',
playProductDemo: 'Perfect Postcode termékdemó lejátszása',
scrollToProductDemo: 'Ugrás a termékdemóhoz',
showcaseHeader: 'Így működik',
showcaseContext: 'Így működik a Perfect Postcode',
showcaseFeaturePriceShort: 'Ár',
showcaseFeatureNoiseShort: 'Zaj',
showcaseFeatureSchoolsShort: 'Iskolák',
showcaseFeatureTravelShort: 'Utazás',
showcaseGoodPrimariesNearby: '{{count}}+ jó általános iskola a közelben',
showcaseWithinRail: '{{count}} percen belül vasúthoz',
showcaseMatchingHomesLabel: 'Illeszkedő otthonok',
showcaseMatchingHomes: '{{value}} illeszkedő otthon',
showcaseMedianPrice: '{{value}} medián',
showcaseJourneyRoutes: 'Útvonalak',
showcaseNearby: '{{value}} a közelben',
showcasePoliticalVoteShare: 'Politikai szavazatarány',
showcaseLotsMore: '...és még sok más',
showcaseMinutes: '{{count}} perc',
showcaseSendShortlist: 'Küldd el a szűkített listát',
showcaseDownloadXlsx: '.xlsx letöltése',
showcaseTopThree: 'Top 3',
showcaseScoutBullet1:
'Járd be az utcákat, mielőtt a hirdetéskeresés leszűkíti a lehetőségeidet.',
showcaseScoutBullet2: 'Valódi bejárati ajtótól teszteld az ingázást, ne csak városrésznévből.',
showcaseScoutBullet3: 'Bizonyítékokkal a kezedben hasonlítsd össze a megtekintéseket.',
showcaseStep1Tab: 'Szűrés',
showcaseStep1Title: 'A homályos igényekből pontos keresés lesz',
showcaseStep1Body:
@ -517,16 +602,16 @@ const hu: Translations = {
dsCrimeName: 'Utcaszintű bűnözési adatok',
dsCrimeOrigin: 'data.police.uk',
dsCrimeUse:
'Utcaszintű bűnözési adatok 2023-tól 2025-ig, éves átlagokba összegézve LSOA-nként és bűncselekménytípusonként (erőszak, betörés, közérdekű rendsértség, kábítószer, járműbűnözés stb.).',
'Utcaszintű bűnözési adatok 2023-tól 2025-ig, éves átlagokba összegezve LSOA-nként és bűncselekménytípusonként (erőszak, betörés, közösségellenes magatartás, kábítószer, járműbűnözés stb.).',
dsOsmName: 'OpenStreetMap POI-k',
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
dsOsmUse:
'Érdekes pontok, beleértve üzleteket, éttermeket, egészségügyet, szabadidőt, turizmust és még sok mást Nagy-Britanniában.',
dsGeolytixRetailName: 'GEOLYTIX Grocery Retail Points',
dsGeolytixRetailName: 'GEOLYTIX élelmiszer-kiskereskedelmi pontok',
dsGeolytixRetailOrigin: 'GEOLYTIX',
dsGeolytixRetailUse:
'Szupermarketek és kisboltok helyei az Egyesült Királyságban, többek között Waitrose, Tesco, Sainsburys, Asda, Morrisons, Aldi, Lidl, Co-op, M&S, Iceland és Spar láncokkal.',
dsGreenspaceName: 'OS Open Greenspace',
dsGreenspaceName: 'OS nyílt zöldterületek',
dsGreenspaceOrigin: 'Ordnance Survey',
dsGreenspaceUse:
'Hivatalos zöldterületi határok Nagy-Britanniában, beleértve a közparkokat, kerteket, sportterületeket és játszótereket. A poligon középpontjait használjuk a park közelségi számláláshoz és a legközelebbi park távolságának számításához.',
@ -621,7 +706,7 @@ const hu: Translations = {
'Ma még nem. Mutatunk például közúti zajt, energiaértékelést, építési kort és az irányítószám környezetét. Árvízkockázatot, jogi kérdéseket, szerkezeti problémákat, hitellel kapcsolatos ügyeket és felmérési eredményeket továbbra is külön kell ellenőrizni vásárlás előtt.',
faqEnv3Q: 'Milyen fenntartási költségeket ellenőrizhetek megtekintés előtt?',
faqEnv3A:
'Megtekintés előtt ellenőrizhetsz energiaértékelést, alapterületet, építési kort, council tax területet, internetet és zajt. Ez nem jósolja meg a pontos számláidat, de segít korán elkerülni a nyilvánvalóan rossz illeszkedéseket.',
'Megtekintés előtt ellenőrizhetsz energiaértékelést, alapterületet, építési kort, helyi adó területét, internetet és zajt. Ez nem jósolja meg a pontos számláidat, de segít korán elkerülni a nyilvánvalóan rossz illeszkedéseket.',
// FAQ items — Listing Portals and Due Diligence
faqDueDiligence1Q: 'Rightmove előtt vagy után használjam?',
faqDueDiligence1A:
@ -635,7 +720,7 @@ const hu: Translations = {
'Jelenleg nem. A Perfect Postcode eladási árakra, energiaértékelésekre, irányítószámokra, utazási időkre és környékinformációkra épül, nem élő hirdetésváltozásokra. Az eladási előzményeket, becsült aktuális értéket és négyzetméterárat viszont használhatod annak megítélésére, hogy egy irányár magasnak tűnik-e.',
faqDueDiligence4Q: 'Mit kell még ellenőriznem ajánlattétel előtt?',
faqDueDiligence4A:
'A Perfect Postcode-dal ellenőrizd a környéket és a valószínű értéket, majd ajánlattétel előtt erősítsd meg a hirdetés részleteit. Ellenőrizd a tulajdonformát, leasehold részleteket, service charge-ot, tervezési előzményeket, árvízkockázatot, jogi kérdéseket, hitelfeltételeket és felmérési eredményeket is.',
'A Perfect Postcode-dal ellenőrizd a környéket és a valószínű értéket, majd ajánlattétel előtt erősítsd meg a hirdetés részleteit. Ellenőrizd a tulajdonformát, a bérleti jog részleteit, a szolgáltatási díjakat, a tervezési előzményeket, az árvízkockázatot, a jogi kérdéseket, a hitelfeltételeket és a felmérési eredményeket is.',
// FAQ items — Privacy and Data Protection
faqPrivacy1Q: 'Tároltok rólam személyes adatokat?',
faqPrivacy1A:
@ -649,7 +734,7 @@ const hu: Translations = {
'Megtehetnéd egyedül is, de akkor eladási árakat, energiaértékeléseket, bűnözést, iskolákat, internetet, helyi tényeket, környezetet, utazási időket és térképeket kellene ellenőrizned irányítószámonként. A Perfect Postcode ezeket egy kereshető angliai térképbe rendezi.',
faqWhy3Q: 'Mennyire megbízhatóak az adatok?',
faqWhy3A:
'A fő források hivatalos vagy széles körben használt nyilvános adatok: eladási árak, energiaértékelések, helyi tények, iskolaminősítések, internet, bűnözés, környezet, térképek és utcai adatok. Hasznosak shortlisthez és összehasonlításhoz, de minden vásárlási döntéshez aktuális ellenőrzések és szükség esetén szakértői tanács kell.',
'A fő források hivatalos vagy széles körben használt nyilvános adatok: eladási árak, energiaértékelések, helyi tények, iskolaminősítések, internet, bűnözés, környezet, térképek és utcai adatok. Hasznosak rövid lista készítéséhez és összehasonlításhoz, de minden vásárlási döntéshez aktuális ellenőrzések és szükség esetén szakértői tanács kell.',
// FAQ items — Pricing and Access
faqPricing1Q: 'Miért fizessek, ha vannak ingyenes irányítószám-jelentések?',
faqPricing1A:
@ -708,17 +793,17 @@ const hu: Translations = {
// ── Invites Page ───────────────────────────────────
invitesPage: {
inviteLinksLicensed: 'A meghívó linkek a licencelt felhasználók számára érhetők el.',
inviteLinksLicensed: 'A meghívó hivatkozások a licencelt felhasználók számára érhetők el.',
inviteAdminLabel: 'Barátok meghívása (100% kedvezmény)',
inviteReferralLabel: 'Barátok meghívása (30% kedvezmény)',
generateFreeInvite: 'Ingyenes meghívó link létrehozása',
generateReferralLink: 'Ajánló link létrehozása',
copyInviteLink: 'Meghívó link másolása',
generateFreeInvite: 'Ingyenes meghívó hivatkozás létrehozása',
generateReferralLink: 'Ajánló hivatkozás létrehozása',
copyInviteLink: 'Meghívó hivatkozás másolása',
adminInvitesTitle: 'Adminisztrátori meghívók (100% kedvezmény)',
referralInvitesTitle: 'Ajánló meghívók (30% kedvezmény)',
yourInviteLinks: 'Meghívó linkjeid',
yourInviteLinks: 'Meghívó hivatkozásaid',
noInvitesYet: 'Még nincsenek létrehozott meghívók',
link: 'Link',
link: 'Hivatkozás',
status: 'Állapot',
created: 'Létrehozva',
redeemed: 'Beváltva',
@ -739,9 +824,9 @@ const hu: Translations = {
propertyInfo: 'Árak, ingázás, iskolák, bűnözés, zaj, szélessáv, EPC és még sok más',
invalidInvite: 'Érvénytelen meghívó',
inviteAlreadyUsed: 'A meghívó már felhasználva',
inviteAlreadyUsedDesc: 'Ez a meghívó link már be lett váltva.',
invalidInviteLink: 'Érvénytelen meghívó link',
invalidInviteLinkDesc: 'Ez a meghívó link érvénytelen vagy lejárt.',
inviteAlreadyUsedDesc: 'Ez a meghívó hivatkozás már be lett váltva.',
invalidInviteLink: 'Érvénytelen meghívó hivatkozás',
invalidInviteLinkDesc: 'Ez a meghívó hivatkozás érvénytelen vagy lejárt.',
licenseActivated: 'Licenc aktiválva!',
fullAccessGranted: 'Most már teljes hozzáférésed van a Perfect Postcode-hoz.',
activating: 'Aktiválás...',
@ -750,7 +835,7 @@ const hu: Translations = {
registerToClaim: 'Regisztrálj az igényléshez',
youAlreadyHaveLicense: 'Már van licenced',
accountHasFullAccess: 'A fiókod már teljes hozzáféréssel rendelkezik.',
failedToValidate: 'Nem sikerült a meghívó link érvényesítése',
failedToValidate: 'Nem sikerült a meghívó hivatkozás érvényesítése',
},
// ── Map Page ───────────────────────────────────────
@ -808,11 +893,11 @@ const hu: Translations = {
Properties: 'Ingatlanok',
Transport: 'Közlekedés',
Education: 'Oktatás',
Deprivation: 'Depriváció',
'Area characteristics': 'Területi jellemzők',
Crime: 'Bűnözés',
Demographics: 'Demográfia',
Politics: 'Politika',
Neighbours: 'Szomszédok',
Amenities: 'Szolgáltatások',
Environment: 'Környezet',
// ─ Feature names (Properties) ─
'Property type': 'Ingatlantípus',
@ -833,8 +918,6 @@ const hu: Translations = {
'Interior height (m)': 'Belmagasság (m)',
// ─ Feature names (Transport) ─
'Distance to nearest train or tube station (km)':
'Távolság a legközelebbi vonat- vagy metróállomástól (km)',
'Travel time to nearest train or tube station (min)':
'Utazási idő a legközelebbi vonat- vagy metróállomásig (perc)',
@ -849,7 +932,7 @@ const hu: Translations = {
'Outstanding secondary schools within 5km': 'Kiemelkedő középiskolák 5 km-en belül',
'Education, Skills and Training Score': 'Oktatás, készségek és képzés pontszám',
// ─ Feature names (Deprivation) ─
// ─ Feature names (Area characteristics) ─
'Income Score': 'Jövedelmi pontszám',
'Employment Score': 'Foglalkoztatottsági pontszám',
'Health Deprivation and Disability Score': 'Egészségügyi depriváció és fogyatékosság pontszám',
@ -865,18 +948,18 @@ const hu: Translations = {
'Burglary (avg/yr)': 'Betörés (éves átlag)',
'Robbery (avg/yr)': 'Rablás (éves átlag)',
'Vehicle crime (avg/yr)': 'Járműbűnözés (éves átlag)',
'Anti-social behaviour (avg/yr)': 'Közérdekű rendsértség (éves átlag)',
'Anti-social behaviour (avg/yr)': 'Közösségellenes magatartás (éves átlag)',
'Criminal damage and arson (avg/yr)': 'Rongálás és gyújtogatás (éves átlag)',
'Other theft (avg/yr)': 'Egyéb lopás (éves átlag)',
'Theft from the person (avg/yr)': 'Személy elleni lopás (éves átlag)',
'Shoplifting (avg/yr)': 'Boltí lopás (éves átlag)',
'Shoplifting (avg/yr)': 'Bolti lopás (éves átlag)',
'Bicycle theft (avg/yr)': 'Kerékpárlopás (éves átlag)',
'Drugs (avg/yr)': 'Kábítószer (éves átlag)',
'Possession of weapons (avg/yr)': 'Fegyvertartás (éves átlag)',
'Public order (avg/yr)': 'Közrend (éves átlag)',
'Other crime (avg/yr)': 'Egyéb bűncselekmény (éves átlag)',
// ─ Feature names (Demographics) ─
// ─ Feature names (Neighbours) ─
'Median age': 'Medián életkor',
'% White': '% fehér',
'% South Asian': '% dél-ázsiai',
@ -885,7 +968,6 @@ const hu: Translations = {
'% Mixed': '% vegyes',
'% Other': '% egyéb',
// ─ Feature names (Politics) ─
'Voter turnout (%)': 'Választási részvétel (%)',
'% Labour': '% Munkáspárt',
'% Conservative': '% Konzervatív',
@ -897,12 +979,17 @@ const hu: Translations = {
// ─ Feature names (Amenities) ─
'Distance to nearest park (km)': 'Távolság a legközelebbi parktól (km)',
'Number of parks within 1km': 'Parkok száma 1 km-en belül',
'Number of restaurants within 2km': 'Éttermek száma 2 km-en belül',
'Number of grocery shops and supermarkets within 2km':
'Élelmiszerboltok és szupermarketek száma 2 km-en belül',
'Noise (dB)': 'Zaj (dB)',
'Max available download speed (Mbps)': 'Max elérhető letöltési sebesség (Mbps)',
// ─ Client-side aggregate filter names ─
Schools: 'Iskolák',
'Specific crimes': 'Konkrét bűncselekmények',
Ethnicities: 'Etnikai csoportok',
'POI distance': 'POI-távolság',
'POIs within 2km': 'POI-k 2 km-en belül',
'POIs within 5km': 'POI-k 5 km-en belül',
// ─ Enum values ─
Detached: 'Különálló',
'Semi-Detached': 'Ikerház',
@ -919,6 +1006,9 @@ const hu: Translations = {
'Minor crime': 'Kisebb bűncselekmény',
'Ethnic composition': 'Etnikai összetétel',
'Political vote share': 'Szavazati megoszlás',
'Anti-social': 'Közösségellenes',
Vehicle: 'Jármű',
Burglary: 'Betörés',
// ─ POI group names ─
'Public Transport': 'Tömegközlekedés',
@ -956,7 +1046,7 @@ const hu: Translations = {
'Sports Centre': 'Sportközpont',
Entertainment: 'Szórakoztatás',
Supermarket: 'Szupermarket',
'Convenience Store': 'Kísbolt',
'Convenience Store': 'Kisbolt',
Bakery: 'Pékség',
'Butcher & Fishmonger': 'Hentes és halas',
Greengrocer: 'Zöldséges',
@ -965,7 +1055,7 @@ const hu: Translations = {
'Fashion & Clothing': 'Divat és ruházat',
Electronics: 'Elektronika',
'Charity Shop': 'Jótékonysági bolt',
'DIY & Hardware': 'Barkacs és vas',
'DIY & Hardware': 'Barkács és vasáru',
'Home & Garden': 'Otthon és kert',
Bookshop: 'Könyvesbolt',
'Pet Shop': 'Állatkereskedés',
@ -975,7 +1065,7 @@ const hu: Translations = {
'Gift & Hobby': 'Ajándék és hobbi',
'Specialist Shop': 'Szaküzlet',
'Hairdresser & Beauty': 'Fodrász és szépség',
'Gym & Fitness': 'Edzterem és fitnesz',
'Gym & Fitness': 'Edzőterem és fitnesz',
'Dry Cleaner & Laundry': 'Vegytisztító és mosoda',
'Car Services': 'Autós szolgáltatások',
'Post Office': 'Posta',

View file

@ -7,6 +7,8 @@ const zh: Translations = {
cancel: '取消',
close: '关闭',
delete: '删除',
finish: '完成',
language: '语言',
open: '打开',
share: '分享',
copy: '复制',
@ -21,6 +23,7 @@ const zh: Translations = {
viewDataSource: '查看数据来源',
total: '总计',
min: '分钟',
max: '最大',
or: '或',
area: '区域',
properties: '房产',
@ -30,6 +33,11 @@ const zh: Translations = {
clickForDetails: '点击查看详情',
property: '处房产',
propertiesPlural: '处房产',
places: '地点',
noData: '无数据',
allLow: '全部为低',
connectingToServer: '正在连接服务器...',
closePane: '关闭面板',
},
// ── Header / Nav ───────────────────────────────────
@ -71,6 +79,33 @@ const zh: Translations = {
home: '首页',
},
// ── Toasts ─────────────────────────────────────────
toasts: {
propertySaved: '房产已保存!',
viewSaved: '查看已保存',
dontShowAgain: '不再显示',
},
// ── SEO Page Chrome ────────────────────────────────
seo: {
breadcrumb: '面包屑导航',
reviewDataSources: '查看数据来源',
whatYouCanCompare: '可比较内容',
whatYouCanCompareDesc:
'每个页面都围绕真实筛选工作构建:排除不可能的地点、比较剩余邮编,并决定下一步要验证什么。',
howToUseIt: '使用方式',
howToUseItDesc: '在打开房源网站或预约看房前,用这些流程先让页面发挥作用。',
methodAndLimitations: '方法与限制',
methodAndLimitationsDesc:
'这些数据用于比较和初筛。重要决定仍需结合最新房源、专业检查和直接的本地验证。',
questionsBuyersAsk: '买家常问的问题',
relatedGuides: '相关指南',
relatedGuidesDesc: '通过规范的内部链接继续浏览已索引的公开页面。',
frequentlyAskedQuestions: '常见问题',
relatedPages: '相关页面',
relatedPagesDesc: '通过这些内部链接,从另一个角度比较同一套房产搜索流程。',
},
// ── Auth Modal ─────────────────────────────────────
auth: {
logIn: '登录',
@ -160,6 +195,20 @@ const zh: Translations = {
clearAllSavePrompt: '是否要在清除前保存当前的筛选条件?',
saveAndClear: '保存并清除',
clearWithoutSaving: '不保存直接清除',
withoutThisFilter: '+{{value}} 不使用此筛选条件',
schoolType: '学校类型',
schoolRating: '学校评级',
schoolDistance: '学校距离',
primary: '小学',
secondary: '中学',
rating: '评级',
goodPlus: '良好+',
outstanding: '优秀',
distance: '距离',
crimeType: '犯罪类型',
ethnicity: '族裔',
poiType: 'POI 类型',
party: '政党',
},
// ── Philosophy Popup ───────────────────────────────
@ -222,9 +271,9 @@ const zh: Translations = {
describeIdealArea: '描述您想住在哪里',
aiSearch: 'AI 搜索',
describeHint: '描述您要找的区域',
placeholder: '例如2居室低于 £525k到公司45分钟安静...',
example1: '2居室低于 £525k到公司45分钟',
example2: '靠近好学校、低于 £650k 的家庭友好区域',
placeholder: '例如2居室低于 £525,000到公司45分钟安静...',
example1: '2居室低于 £525,000到公司45分钟',
example2: '靠近好学校、低于 £650,000 的家庭友好区域',
example3: '空间更大,通勤也合理',
analysing: '正在分析您的需求...',
searchingDestinations: '正在搜索目的地...',
@ -242,6 +291,16 @@ const zh: Translations = {
previewing: '预览\u201c{{name}}\u201d',
},
// ── Map ────────────────────────────────────────────
map: {
ogTitle: '您的理想邮编',
ogPropertyPrices: '房产价格',
ogEnergyRatings: '能源评级',
ogSchools: '学校',
ogCrimeStats: '犯罪统计',
ogTransport: '交通',
},
// ── Properties Pane ────────────────────────────────
propertyCard: {
unknownAddress: '地址未知',
@ -272,13 +331,16 @@ const zh: Translations = {
areaOverview: '概览',
statsFor: '该{{type}}内所有房产的统计数据',
matchingFilters: ',满足所有当前筛选条件',
filtersAffectStats:
'筛选条件会应用到这里:数值、图表和房产数量都会使用 {{count}} 个当前筛选条件。',
noFiltersAffectStats: '添加筛选条件后,这些值会按匹配的房产重新计算。',
noFilteredMatches: '该区域没有房产符合当前筛选条件。',
unfilteredAreaCount: '筛选前这里有 {{count}} 处房产;位置有效,但被筛选条件排除了。',
noUnfilteredAreaProperties: '筛选前该选定区域内也没有找到房产。',
relaxFiltersHint: '放宽或清除筛选条件即可查看该区域的房产。',
statsBasis: '统计范围',
matchingFiltersOption: '匹配筛选',
allPropertiesOption: '全部房产',
filtersAffectStats: '该面板的统计数据正在使用 {{count}} 个当前筛选条件。',
filtersIgnoredForStats: '正在显示所选区域内全部房产的统计数据。',
noFiltersAffectStats: '没有当前筛选条件;统计数据覆盖该区域内的全部房产。',
filteredStatsEmpty: '筛选后的统计为空',
showAllStatsHint: '筛选前这里有 {{count}} 处房产。切换到全部房产即可查看该区域。',
showAllStatsFallback: '切换到全部房产即可在不应用当前筛选条件的情况下查看该区域。',
showAllStats: '显示全部房产',
viewProperties: '查看 {{count}} 处房产',
viewPropertiesShort: '查看房产',
priceHistory: '价格历史',
@ -352,18 +414,37 @@ const zh: Translations = {
'设定预算、通勤、学校、安全、噪音、宽带和生活方式需求。Perfect Postcode 会扫描英格兰的邮编,显示真正匹配的地方,包括您从未想过要在房源网站上搜索的区域。',
exploreTheMap: '找到匹配的邮编',
seeTheDifference: '查看使用方式',
productDemoLabel: 'Perfect Postcode 产品演示',
playProductDemo: '播放 Perfect Postcode 产品演示',
scrollToProductDemo: '滚动到产品演示',
showcaseHeader: '工作原理',
showcaseContext: 'Perfect Postcode 的工作流程',
showcaseFeaturePriceShort: '价格',
showcaseFeatureNoiseShort: '噪声',
showcaseFeatureSchoolsShort: '学校',
showcaseFeatureTravelShort: '出行',
showcaseGoodPrimariesNearby: '附近 {{count}}+ 所良好小学',
showcaseWithinRail: '{{count}} 分钟内到达铁路',
showcaseMatchingHomesLabel: '匹配房源',
showcaseMatchingHomes: '{{value}} 个匹配房源',
showcaseMedianPrice: '{{value}} 中位数',
showcaseJourneyRoutes: '出行路线',
showcaseNearby: '附近 {{value}} 个',
showcasePoliticalVoteShare: '政党得票份额',
showcaseLotsMore: '……以及更多',
showcaseMinutes: '{{count}} 分钟',
showcaseSendShortlist: '发送候选名单',
showcaseDownloadXlsx: '下载 .xlsx',
showcaseTopThree: '前 3 名',
showcaseScoutBullet1: '在房源搜索缩小选择之前,先实地走走街道。',
showcaseScoutBullet2: '从真实门牌测试通勤,而不是只看行政区名称。',
showcaseScoutBullet3: '带着已有证据比较看房结果。',
showcaseStep1Tab: '筛选',
showcaseStep1Title: '把模糊需求变成精准搜索',
showcaseStep1Body: '设置真正重要的条件,并清楚看到每项要求为您排除了多少不合适的邮编。',
showcaseStep1Chip1: '安静街道',
showcaseStep1Chip2: '顶级小学',
showcaseStep1Chip3: '£500k 以内',
showcaseStep1Chip3: '£500,000 以内',
showcaseStep1VennCenter: '同时满足三项条件的邮编',
showcaseStep2Tab: '匹配',
showcaseStep2Title: '让地图浮现您原本不会输入的地方',
@ -385,7 +466,7 @@ const zh: Translations = {
showcaseStep3Stat4Label: '宽带',
showcaseStep3Stat4Value: '可用 1 Gbps',
showcaseStep3Stat5Label: '小学',
showcaseStep3Stat5Value: '1英里内3所「outstanding」',
showcaseStep3Stat5Value: '1英里内3所「优秀」',
showcaseStep4Tab: '踏勘',
showcaseStep4Title: '亲自去看一看',
showcaseStep4Body:
@ -415,7 +496,7 @@ const zh: Translations = {
streetCard2Title: '看房前先看清取舍',
streetCard2Body:
'在把周末花在看房之前,先比较价格、空间、通勤、安全、学校、宽带、噪音和能源评级。',
othersVs: '其他平台 vs',
othersVs: '与其他平台对比',
checkMyPostcode: '房源门户',
areaGuides: '邮编报告',
compSearchWithout: '在知道名称前先发现区域',
@ -436,7 +517,7 @@ const zh: Translations = {
subtitle: '终身访问这张地图,在预约看房前先弄清应该看哪里。',
costContext:
'买家常常把晚上花在拼接房源、通勤查询、学校报告、犯罪地图、Street View 和成交价上。在伦敦这尤其折磨人但同样的研究问题存在于整个英格兰。Perfect Postcode 会先把区域研究放在一张地图上,再让您投入周末、费用和精力。',
lessThanSurvey: '费用低于一次 survey,却能更大程度地指导您的选择。',
lessThanSurvey: '费用低于一次房屋测量,却能更大程度地指导您的选择。',
currentTier: '当前档位',
firstNUsers: '前 {{count}} 名用户',
everyoneAfter: '之后的所有人',
@ -484,57 +565,57 @@ const zh: Translations = {
attrOsmLicense: '的数据,依据',
attrOsmLicenseLink: 'Open Data Commons Open Database License (ODbL)',
// Data source names & descriptions
dsPricePaidName: 'Price Paid Data',
dsPricePaidName: '成交价格数据',
dsPricePaidOrigin: 'HM Land Registry',
dsPricePaidUse: '英格兰完整的历史房产成交价格数据。',
dsEpcName: 'Energy Performance Certificates (EPC)',
dsEpcName: '能源性能证书EPC',
dsEpcOrigin: 'Ministry of Housing, Communities & Local Government',
dsEpcUse:
'住宅能源性能证书,提供建筑面积、房间数量、建造年份、能源评级、房产类型和建筑形式等信息。通过每个邮编内的地址与成交价格数据进行匹配。业主可以退出公开披露。',
dsNsplName: 'National Statistics Postcode Lookup (NSPL)',
dsNsplName: '国家统计邮编查询NSPL',
dsNsplOrigin: 'ONS / ArcGIS',
dsNsplUse: '将邮编映射到坐标和统计区域代码,用于将所有区域级数据集关联到各个房产。',
dsIodName: 'English Indices of Deprivation 2025',
dsIodName: '英格兰贫困指数 2025',
dsIodOrigin: 'Ministry of Housing, Communities & Local Government',
dsIodUse: '英格兰每个社区在收入、就业、教育、健康、犯罪和居住环境方面的全国贫困百分位。',
dsEthnicityName: '按族裔划分的人口2021 年人口普查)',
dsEthnicityOrigin: 'ONS',
dsEthnicityUse:
'按族裔群体(南亚裔、东亚裔、黑人、混血、白人、其他)划分的各地方政府辖区人口百分比。',
dsCrimeName: 'Street-level Crime Data',
dsCrimeName: '街道级犯罪数据',
dsCrimeOrigin: 'data.police.uk',
dsCrimeUse:
'2023 年至 2025 年的街道级犯罪数据,按 LSOA 和犯罪类型(暴力犯罪、入室盗窃、反社会行为、毒品、车辆犯罪等)汇总为年均值。',
dsOsmName: 'OpenStreetMap POIs',
dsOsmName: 'OpenStreetMap 兴趣点',
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
dsOsmUse: '涵盖大不列颠地区的商店、餐厅、医疗、休闲、旅游等兴趣点。',
dsGeolytixRetailName: 'GEOLYTIX Grocery Retail Points',
dsGeolytixRetailName: 'GEOLYTIX 食品零售点',
dsGeolytixRetailOrigin: 'GEOLYTIX',
dsGeolytixRetailUse:
'英国超市和便利店位置数据,包括 Waitrose、Tesco、Sainsburys、Asda、Morrisons、Aldi、Lidl、Co-op、M&S、Iceland 和 Spar 等连锁品牌。',
dsGreenspaceName: 'OS Open Greenspace',
dsGreenspaceName: 'OS 开放绿地',
dsGreenspaceOrigin: 'Ordnance Survey',
dsGreenspaceUse:
'大不列颠地区权威的绿地边界数据,包括公共公园、花园、运动场和游乐场。多边形质心用于公园邻近度计数和最近公园距离计算。',
dsNaptanName: 'NaPTAN (Public Transport Stops)',
dsNaptanName: 'NaPTAN(公共交通站点)',
dsNaptanOrigin: 'Department for Transport',
dsNaptanUse: '英格兰各地铁路、公交、地铁/有轨电车、渡轮和机场的站点位置。',
dsNoiseName: 'Defra Noise Mapping',
dsNoiseName: 'Defra 噪音测绘',
dsNoiseOrigin: 'Defra / Environment Agency',
dsNoiseUse:
'来自 2022 年战略噪音测绘的道路噪音水平24 小时加权平均值),经高分辨率建模并在每个邮编处采样。',
dsOfstedName: 'Ofsted School Inspections',
dsOfstedName: 'Ofsted 学校检查',
dsOfstedOrigin: 'Ofsted',
dsOfstedUse:
'公立学校最新督察结果(截至 2025 年 4 月。按邮编取平均值得出当地学校质量评分1=优秀至4=不合格)。',
dsBroadbandName: 'Ofcom Broadband Performance',
dsBroadbandName: 'Ofcom 宽带性能',
dsBroadbandOrigin: 'Ofcom',
dsBroadbandUse: '来自 Ofcom Connected Nations 2025 的各区域固定宽带覆盖率和最大下载速度。',
dsCouncilTaxName: 'Council Tax Levels 2025-26',
dsCouncilTaxName: '2025-26 年市政税等级',
dsCouncilTaxOrigin: 'Ministry of Housing, Communities & Local Government',
dsCouncilTaxUse:
'英格兰所有 296 个计费机构的 A 至 H 等级年度市政税税率,适用于两名成年人居住的住宅。通过 NSPL 邮编查询中的地方政府区域代码关联到房产。',
dsRentalName: 'Private Rental Market Statistics',
dsRentalName: '私人租赁市场统计',
dsRentalOrigin: 'ONS / Valuation Office Agency',
dsRentalUse:
'按地方政府辖区和卧室类别划分的月度私人租金中位数2022 年 10 月至 2023 年 9 月)。通过地方政府区域代码和估算卧室数量关联到房产。',
@ -649,7 +730,7 @@ const zh: Translations = {
'点击筛选条件或数据项旁边的眼睛图标,即可按该项为地图着色。当前启用的筛选条件会保持不变,因此您可以快速比较价格、通勤时间、学校、犯罪率或噪音等单项,而不会改变候选范围。',
faqTips2Q: '如何了解筛选条件的含义?',
faqTips2A:
'点击筛选条件或数据项旁边的 i 信息按钮,可查看简短说明,了解它的含义以及如何阅读。地图中的一些部分,例如出行时间卡片,也有自己的信息按钮。',
'点击筛选条件或数据项旁边的信息按钮,可查看简短说明,了解它的含义以及如何阅读。地图中的一些部分,例如出行时间卡片,也有自己的信息按钮。',
faqTips3Q: '如何刷新地图颜色?',
faqTips3A:
'当眼睛预览正在为地图着色时,在地图图例中使用“重置颜色比例”即可刷新当前结果的颜色。在移动地图、缩放或更改筛选条件后,这很有用。',
@ -743,8 +824,8 @@ const zh: Translations = {
daysAgo: '{{count}}天前',
nFilters: '{{count}} 个筛选',
noFilters: '无筛选',
poiCategory: '{{count}} 个 POI 类别',
poiCategories: '{{count}} 个 POI 类别',
poiCategory: '{{count}} 个兴趣点类别',
poiCategories: '{{count}} 个兴趣点类别',
travelDestination: '{{count}} 个出行目的地',
travelDestinations: '{{count}} 个出行目的地',
propertiesMatch: '{{count}} 套房产符合',
@ -762,7 +843,7 @@ const zh: Translations = {
'设置预算、通勤上限、学校质量、犯罪门槛、噪音容忍度、宽带需求,或任何您关心的条件。只有匹配区域会保持高亮。使用眼睛图标可按任意指标着色。',
step2Title: '或者直接描述',
step2Content:
'用自然语言输入您的需求例如“安静的地区靠近好学校£400k 以下”,我们会为您设置筛选。',
'用自然语言输入您的需求例如“安静的地区靠近好学校£400,000 以下”,我们会为您设置筛选。',
step3Title: '探索有哪些选择',
step3Content:
'在英格兰各地平移和缩放。点击任何彩色区域,查看它为什么匹配:犯罪率、学校、价格、宽带、噪音等。',
@ -782,11 +863,11 @@ const zh: Translations = {
Properties: '房产',
Transport: '交通',
Education: '教育',
Deprivation: '贫困指数',
'Area characteristics': '区域特征',
Crime: '犯罪',
Demographics: '人口统计',
Politics: '政治',
Neighbours: '邻居',
Amenities: '配套设施',
Environment: '环境',
// ─ Feature names (Properties) ─
'Property type': '房产类型',
@ -807,7 +888,6 @@ const zh: Translations = {
'Interior height (m)': '室内层高(米)',
// ─ Feature names (Transport) ─
'Distance to nearest train or tube station (km)': '到最近火车或地铁站的距离(公里)',
'Travel time to nearest train or tube station (min)': '到最近火车或地铁站的出行时间(分钟)',
// ─ Feature names (Education) ─
@ -821,7 +901,7 @@ const zh: Translations = {
'Outstanding secondary schools within 5km': '5公里内优秀中学数量',
'Education, Skills and Training Score': '教育、技能和培训得分',
// ─ Feature names (Deprivation) ─
// ─ Feature names (Area characteristics) ─
'Income Score': '收入得分',
'Employment Score': '就业得分',
'Health Deprivation and Disability Score': '健康与残障得分',
@ -848,7 +928,7 @@ const zh: Translations = {
'Public order (avg/yr)': '扰乱公共秩序(年均)',
'Other crime (avg/yr)': '其他犯罪(年均)',
// ─ Feature names (Demographics) ─
// ─ Feature names (Neighbours) ─
'Median age': '中位年龄',
'% White': '% 白人',
'% South Asian': '% 南亚裔',
@ -857,7 +937,6 @@ const zh: Translations = {
'% Mixed': '% 混血',
'% Other': '% 其他',
// ─ Feature names (Politics) ─
'Voter turnout (%)': '投票率(%',
'% Labour': '% 工党',
'% Conservative': '% 保守党',
@ -869,11 +948,17 @@ const zh: Translations = {
// ─ Feature names (Amenities) ─
'Distance to nearest park (km)': '到最近公园的距离(公里)',
'Number of parks within 1km': '1公里内公园数量',
'Number of restaurants within 2km': '2公里内餐厅数量',
'Number of grocery shops and supermarkets within 2km': '2公里内食品店和超市数量',
'Noise (dB)': '噪音(分贝)',
'Max available download speed (Mbps)': '最大可用下载速度Mbps',
// ─ Client-side aggregate filter names ─
Schools: '学校',
'Specific crimes': '具体犯罪',
Ethnicities: '族裔',
'POI distance': 'POI 距离',
'POIs within 2km': '2 公里内 POI',
'POIs within 5km': '5 公里内 POI',
// ─ Enum values ─
Detached: '独立式住宅',
'Semi-Detached': '半独立式住宅',
@ -890,6 +975,9 @@ const zh: Translations = {
'Minor crime': '轻微犯罪',
'Ethnic composition': '族裔组成',
'Political vote share': '政党得票率',
'Anti-social': '反社会',
Vehicle: '车辆',
Burglary: '入室盗窃',
// ─ POI group names ─
'Public Transport': '公共交通',

View file

@ -364,6 +364,68 @@ h3 {
bottom: calc(var(--map-mobile-bottom-inset, 0px) + 0.25rem) !important;
}
/* Highlight flash for newly added filter cards — animated green stroke. */
@property --filter-highlight-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
@keyframes filter-highlight-spin {
to {
--filter-highlight-angle: 360deg;
}
}
@keyframes filter-highlight-fade {
0%,
75% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.filter-highlight-flash {
position: relative;
}
.filter-highlight-flash::after {
content: '';
position: absolute;
inset: -2px;
border-radius: 6px;
padding: 2px;
background: conic-gradient(
from var(--filter-highlight-angle, 0deg),
rgba(29, 228, 195, 0) 0%,
#1de4c3 18%,
#05c9aa 32%,
rgba(29, 228, 195, 0) 50%,
#1de4c3 68%,
#05c9aa 82%,
rgba(29, 228, 195, 0) 100%
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation:
filter-highlight-spin 1s linear infinite,
filter-highlight-fade 2s ease-out forwards;
pointer-events: none;
z-index: 20;
}
@media (prefers-reduced-motion: reduce) {
.filter-highlight-flash::after {
background: rgba(29, 228, 195, 0.9);
animation: filter-highlight-fade 2s ease-out forwards;
}
}
/* Hide scrollbar for pill groups on mobile */
.scrollbar-hide {
-ms-overflow-style: none;

View file

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

View file

@ -4,6 +4,7 @@ import type { FeatureMeta } from '../types';
import { apiUrl, assertOk, buildFilterString, isAbortError } from './api';
import { createSchoolFilterKey } from './school-filter';
import { createSpecificCrimeFilterKey } from './crime-filter';
import { createElectionVoteShareFilterKey } from './election-filter';
import { createEthnicityFilterKey } from './ethnicity-filter';
import {
POI_COUNT_2KM_FILTER_NAME,
@ -106,6 +107,23 @@ describe('api utilities', () => {
).toBe('Burglary (avg/yr):0:5;;Vehicle crime (avg/yr):1:10');
});
it('serializes election vote-share filters using their selected backend party feature', () => {
const features: FeatureMeta[] = [
{ name: '% Labour', type: 'numeric', min: 0, max: 100 },
{ name: '% Conservative', type: 'numeric', min: 0, max: 100 },
];
expect(
buildFilterString(
{
[createElectionVoteShareFilterKey('% Labour', 1)]: [30, 60],
[createElectionVoteShareFilterKey('% Conservative', 2)]: [10, 40],
},
features
)
).toBe('% Labour:30:60;;% Conservative:10:40');
});
it('deduplicates repeated ethnicity filters to the strictest backend range', () => {
const features: FeatureMeta[] = [{ name: '% White', type: 'numeric', min: 0, max: 100 }];

View file

@ -3,6 +3,7 @@ import { INITIAL_RETRY_MS, MAX_RETRY_MS } from './consts';
import pb from './pocketbase';
import { getSchoolBackendFeatureName } from './school-filter';
import { getSpecificCrimeFeatureName } from './crime-filter';
import { getElectionVoteShareFeatureName } from './election-filter';
import { getEthnicityFeatureName } from './ethnicity-filter';
import { getPoiDistanceFeatureName } from './poi-distance-filter';
@ -93,6 +94,7 @@ export function buildFilterString(
const backendName =
getSchoolBackendFeatureName(name) ??
getSpecificCrimeFeatureName(name) ??
getElectionVoteShareFeatureName(name) ??
getEthnicityFeatureName(name) ??
getPoiDistanceFeatureName(name) ??
name;

View file

@ -248,14 +248,12 @@ export const STACKED_GROUPS: Record<
],
},
],
Demographics: [
Neighbours: [
{
label: 'Ethnic composition',
unit: '%',
components: ['% White', '% South Asian', '% East Asian', '% Black', '% Mixed', '% Other'],
},
],
Politics: [
{
label: 'Political vote share',
unit: '%',

View file

@ -0,0 +1,113 @@
import type { FeatureFilters, FeatureMeta } from '../types';
export const ELECTION_VOTE_SHARE_FILTER_NAME = 'Political vote share';
export const ELECTION_VOTE_SHARE_FILTER_KEY_PREFIX = `${ELECTION_VOTE_SHARE_FILTER_NAME}:`;
export const ELECTION_VOTE_SHARE_FEATURE_NAMES = [
'% Labour',
'% Conservative',
'% Liberal Democrat',
'% Reform UK',
'% Green',
'% Other parties',
] as const;
const ELECTION_VOTE_SHARE_FEATURE_NAME_SET = new Set<string>(ELECTION_VOTE_SHARE_FEATURE_NAMES);
export function isElectionVoteShareFeatureName(name: string): boolean {
return ELECTION_VOTE_SHARE_FEATURE_NAME_SET.has(name);
}
export function isElectionVoteShareFilterName(name: string): boolean {
return (
isElectionVoteShareFeatureName(name) || name.startsWith(ELECTION_VOTE_SHARE_FILTER_KEY_PREFIX)
);
}
export function createElectionVoteShareFilterKey(featureName: string, id: number | string): string {
return `${ELECTION_VOTE_SHARE_FILTER_KEY_PREFIX}${encodeURIComponent(featureName)}:${id}`;
}
export function getElectionVoteShareFilterKeyId(name: string): string | null {
if (!name.startsWith(ELECTION_VOTE_SHARE_FILTER_KEY_PREFIX)) return null;
const rest = name.substring(ELECTION_VOTE_SHARE_FILTER_KEY_PREFIX.length);
const lastColon = rest.lastIndexOf(':');
return lastColon === -1 ? null : rest.substring(lastColon + 1);
}
export function parseElectionVoteShareFilterKey(name: string): string | null {
if (!name.startsWith(ELECTION_VOTE_SHARE_FILTER_KEY_PREFIX)) return null;
const rest = name.substring(ELECTION_VOTE_SHARE_FILTER_KEY_PREFIX.length);
const lastColon = rest.lastIndexOf(':');
if (lastColon === -1) return null;
const decoded = decodeURIComponent(rest.substring(0, lastColon));
return isElectionVoteShareFeatureName(decoded) ? decoded : null;
}
export function getElectionVoteShareFeatureName(name: string): string | null {
if (isElectionVoteShareFeatureName(name)) return name;
return parseElectionVoteShareFilterKey(name);
}
export function replaceElectionVoteShareFilterKeySelection(
key: string,
featureName: string
): string {
const id = getElectionVoteShareFilterKeyId(key) ?? '0';
return createElectionVoteShareFilterKey(featureName, id);
}
export function getDefaultElectionVoteShareFeatureName(features: FeatureMeta[]): string | null {
return (
ELECTION_VOTE_SHARE_FEATURE_NAMES.find((name) =>
features.some((feature) => feature.name === name)
) ?? null
);
}
export function normalizeElectionVoteShareFilters(filters: FeatureFilters): FeatureFilters {
let changed = false;
const next: FeatureFilters = {};
for (const [name, value] of Object.entries(filters)) {
if (isElectionVoteShareFeatureName(name)) {
next[createElectionVoteShareFilterKey(name, Object.keys(next).length)] = value;
changed = true;
continue;
}
next[name] = value;
}
return changed ? next : filters;
}
export function getElectionVoteShareFilterMeta(features: FeatureMeta[]): FeatureMeta {
const sourceFeatureName = getDefaultElectionVoteShareFeatureName(features);
const sourceFeature = sourceFeatureName
? features.find((feature) => feature.name === sourceFeatureName)
: undefined;
return {
name: ELECTION_VOTE_SHARE_FILTER_NAME,
type: 'numeric',
group: 'Neighbours',
min: sourceFeature?.min ?? 0,
max: sourceFeature?.max ?? 100,
step: 1,
description: 'Vote share by party in the 2024 General Election',
detail:
'Filter by one party vote-share percentage at a time for the constituency covering each postcode.',
source: sourceFeature?.source ?? 'election-results',
suffix: '%',
};
}
export function clampElectionVoteShareRange(
value: [number, number],
feature?: FeatureMeta
): [number, number] {
const min = feature?.histogram?.min ?? feature?.min ?? 0;
const max = feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]);
return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))];
}

View file

@ -85,7 +85,7 @@ export function getEthnicityFilterMeta(features: FeatureMeta[]): FeatureMeta {
return {
name: ETHNICITIES_FILTER_NAME,
type: 'numeric',
group: 'Demographics',
group: 'Neighbours',
min: sourceFeature?.min ?? 0,
max: sourceFeature?.max ?? 100,
step: 0.1,

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { buildPropertySearchUrls } from './external-search';
import { buildPropertySearchUrls, buildRightmoveExactPostcodeRedirectUrl } from './external-search';
describe('external property search URLs', () => {
it('returns null when no postcode is available', () => {
@ -59,7 +59,7 @@ describe('external property search URLs', () => {
expect(zoopla.searchParams.getAll('property_sub_type')).toEqual(['detached', 'flat']);
});
it('omits Rightmove when location identifier is missing and uses zero radius for postcodes', () => {
it('omits Rightmove when location identifier is missing and uses minimum radius for other portals', () => {
const urls = buildPropertySearchUrls({
location: {
lat: 51.501,
@ -75,4 +75,37 @@ describe('external property search URLs', () => {
expect(new URL(urls!.onthemarket).searchParams.get('radius')).toBe('0.25');
expect(new URL(urls!.zoopla).searchParams.get('radius')).toBe('0.25');
});
it('uses Rightmove this-area-only radius when an exact postcode identifier is provided', () => {
const urls = buildPropertySearchUrls({
location: {
lat: 51.501,
lon: -0.141,
resolution: 9,
postcode: 'SW1A 1AA',
isPostcode: true,
},
rightmoveLocationId: 'POSTCODE^837246',
filters: {},
});
const rightmove = new URL(urls!.rightmove!);
expect(rightmove.searchParams.get('searchLocation')).toBe('SW1A 1AA');
expect(rightmove.searchParams.get('locationIdentifier')).toBe('POSTCODE^837246');
expect(rightmove.searchParams.get('radius')).toBe('0.0');
});
it('builds a same-origin Rightmove redirect for exact postcode clicks', () => {
const target =
'https://www.rightmove.co.uk/property-for-sale/find.html?searchLocation=SW1A+1AA&locationIdentifier=OUTCODE%5E2506';
const redirect = new URL(
buildRightmoveExactPostcodeRedirectUrl('SW1A 1AA', target),
'https://perfect-postcode.co.uk'
);
expect(redirect.pathname).toBe('/api/rightmove-search');
expect(redirect.searchParams.get('postcode')).toBe('SW1A 1AA');
expect(redirect.searchParams.get('target')).toBe(target);
});
});

View file

@ -89,6 +89,16 @@ interface SearchUrlOptions {
rightmoveLocationId?: string;
}
export function buildRightmoveExactPostcodeRedirectUrl(
postcode: string,
targetUrl: string
): string {
const params = new URLSearchParams();
params.set('postcode', postcode);
params.set('target', targetUrl);
return `/api/rightmove-search?${params.toString()}`;
}
export function buildPropertySearchUrls({
location,
filters,
@ -138,7 +148,12 @@ export function buildPropertySearchUrls({
rmParams.set('searchLocation', postcode);
rmParams.set('useLocationIdentifier', 'true');
rmParams.set('locationIdentifier', rightmoveLocationId);
rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII)));
rmParams.set(
'radius',
isPostcode && rightmoveLocationId.startsWith('POSTCODE^')
? '0.0'
: String(nearestRadius(radiusMiles, RIGHTMOVE_RADII))
);
if (minPrice !== undefined)
rmParams.set('minPrice', String(snapToAllowed(minPrice, RIGHTMOVE_PRICES, 'floor')));
if (maxPrice !== undefined)

View file

@ -104,17 +104,6 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
),
// ── Transport ────────────────────────────────
'Distance to nearest train or tube station (km)': (
<>
<path d="M12 2v8" />
<path d="M4.93 10.93l2.83 2.83" />
<path d="M2 18h2" />
<path d="M20 18h2" />
<path d="M19.07 10.93l-2.83 2.83" />
<circle cx="12" cy="18" r="4" />
<line x1="12" y1="18" x2="12" y2="15" />
</>
),
'Travel time to nearest train or tube station (min)': (
<>
<rect x="5" y="3" width="14" height="10" rx="2" />
@ -187,7 +176,7 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
</>
),
// ── Deprivation ──────────────────────────────
// ── Area characteristics ─────────────────────
'Income Score': (
<>
<rect x="2" y="6" width="20" height="14" rx="2" />
@ -353,7 +342,7 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
</>
),
// ── Demographics ─────────────────────────────
// ── Neighbours ───────────────────────────────
'% White': (
<>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
@ -404,21 +393,6 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
),
// ── Amenities ────────────────────────────────
'Number of restaurants within 2km': (
<>
<path d="M3 2v8c0 1.1.9 2 2 2h2v10h2V12h2a2 2 0 002-2V2" />
<path d="M7 2v4" />
<path d="M19 2v20" />
<path d="M19 8a3 3 0 00-3-3" />
</>
),
'Number of grocery shops and supermarkets within 2km': (
<>
<circle cx="9" cy="21" r="1" />
<circle cx="20" cy="21" r="1" />
<path d="M1 1h4l2.68 13.39a2 2 0 002 1.61h9.72a2 2 0 002-1.61L23 6H6" />
</>
),
'Number of parks within 1km': (
<>
<path d="M12 22v-7" />

View file

@ -89,25 +89,13 @@ export function formatDuration(d: string): string {
return d;
}
const MONTH_NAMES = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
export function formatTransactionDate(fractionalYear: number): string {
const year = Math.floor(fractionalYear);
const monthIndex = Math.min(Math.round((fractionalYear - year) * 12), 11);
return `${MONTH_NAMES[monthIndex]} ${year}`;
const language = i18n.language || undefined;
return new Intl.DateTimeFormat(language, { month: 'short', year: 'numeric' }).format(
new Date(Date.UTC(year, monthIndex, 1))
);
}
export function formatAge(value: number, approximate = true): string {

View file

@ -16,9 +16,9 @@ const GROUP_ICONS: Record<string, ComponentType<{ className?: string }>> = {
Properties: HouseIcon,
Transport: RouteIcon,
Education: GraduationCapIcon,
Deprivation: ChartBarIcon,
'Area characteristics': ChartBarIcon,
Crime: ShieldIcon,
Demographics: UsersIcon,
Neighbours: UsersIcon,
'Nearby POIs': MapPinIcon,
Amenities: ShoppingBagIcon,
Environment: TreeIcon,

View file

@ -1,7 +1,7 @@
import { cellToChildren, cellToLatLng, latLngToCell } from 'h3-js';
import { describe, expect, it } from 'vitest';
import type { HexagonData } from '../types';
import { findOverlappingMatchingHexagon, hasMatchingHexagonAtResolution } from './h3-selection';
import { findOverlappingSelectableHexagon, hasMatchingHexagonAtResolution } from './h3-selection';
function hexagonData(h3: string, count: number): HexagonData {
const [lat, lon] = cellToLatLng(h3);
@ -9,10 +9,10 @@ function hexagonData(h3: string, count: number): HexagonData {
}
describe('h3 selection helpers', () => {
it('finds a matching higher-resolution hexagon that overlaps the previous hexagon', () => {
it('prefers a matching higher-resolution hexagon that overlaps the previous hexagon', () => {
const parent = latLngToCell(51.5, -0.1, 8);
const children = cellToChildren(parent, 9);
const selected = findOverlappingMatchingHexagon(
const selected = findOverlappingSelectableHexagon(
parent,
[hexagonData(children[0], 0), hexagonData(children[1], 4)],
9
@ -21,18 +21,18 @@ describe('h3 selection helpers', () => {
expect(selected?.h3).toBe(children[1]);
});
it('rejects candidates that do not overlap or have no matches', () => {
it('falls back to a selectable no-match candidate when there is no matching overlap', () => {
const parent = latLngToCell(51.5, -0.1, 8);
const nearbyChild = cellToChildren(parent, 9)[0];
const distant = latLngToCell(52.2, -0.1, 9);
expect(
findOverlappingMatchingHexagon(
findOverlappingSelectableHexagon(
parent,
[hexagonData(nearbyChild, 0), hexagonData(distant, 12)],
9
)
).toBeNull();
).toEqual(hexagonData(nearbyChild, 0));
});
it('detects when target-resolution matching data is loaded', () => {

View file

@ -94,7 +94,7 @@ export function hasMatchingHexagonAtResolution(
return hexagons.some((hexagon) => hexagon.count > 0 && getResolution(hexagon.h3) === resolution);
}
export function findOverlappingMatchingHexagon(
export function findOverlappingSelectableHexagon(
previousH3: string,
hexagons: HexagonData[],
resolution: number
@ -105,16 +105,20 @@ export function findOverlappingMatchingHexagon(
let bestDistance = Infinity;
for (const hexagon of hexagons) {
if (hexagon.count <= 0 || getResolution(hexagon.h3) !== resolution) continue;
if (getResolution(hexagon.h3) !== resolution) continue;
if (!polygonsOverlap(previousBoundary, cellBoundary(hexagon.h3))) continue;
const distance = (hexagon.lat - previousLat) ** 2 + (hexagon.lon - previousLng) ** 2;
const hasMatches = hexagon.count > 0;
const bestHasMatches = (best?.count ?? 0) > 0;
if (
!best ||
distance < bestDistance - EPSILON ||
(Math.abs(distance - bestDistance) <= EPSILON &&
(hexagon.count > best.count ||
(hexagon.count === best.count && hexagon.h3.localeCompare(best.h3) < 0)))
(hasMatches && !bestHasMatches) ||
(hasMatches === bestHasMatches &&
(distance < bestDistance - EPSILON ||
(Math.abs(distance - bestDistance) <= EPSILON &&
(hexagon.count > best.count ||
(hexagon.count === best.count && hexagon.h3.localeCompare(best.h3) < 0)))))
) {
best = hexagon;
bestDistance = distance;

View file

@ -753,3 +753,110 @@ export const SEO_CONTENT_PAGES: Record<SeoContentKey, SeoContentPage> = {
],
},
};
type SeoLanguageCode = 'en' | 'fr' | 'de' | 'zh' | 'hi' | 'hu';
type LocalizedSeoLanguageCode = Exclude<SeoLanguageCode, 'en'>;
function toSeoLanguage(language?: string): SeoLanguageCode {
const code = language?.toLowerCase().split('-')[0];
if (code === 'fr' || code === 'de' || code === 'zh' || code === 'hi' || code === 'hu') {
return code;
}
return 'en';
}
const COMMON_RELATED_LINKS_BY_LANGUAGE: Record<LocalizedSeoLanguageCode, SeoLink[]> = {
fr: [
{
label: 'Sources et couverture des données',
path: '/data-sources',
description:
'Voyez quels jeux de données alimentent les filtres par code postal et où se situent leurs limites.',
},
{
label: 'Méthodologie',
path: '/methodology',
description:
'Comprenez comment la carte aide à établir une présélection sans remplacer les vérifications nécessaires.',
},
{
label: 'Vérificateur de code postal',
path: '/postcode-checker',
description: 'Contrôlez un code postal avant de consacrer du temps à une visite.',
},
],
de: [
{
label: 'Datenquellen und Abdeckung',
path: '/data-sources',
description:
'Sehen Sie, welche Datensätze hinter den Postleitzahlfiltern stehen und wo ihre Grenzen liegen.',
},
{
label: 'Methodik',
path: '/methodology',
description:
'Verstehen Sie, wie die Karte beim Eingrenzen hilft, ohne die eigene Prüfung zu ersetzen.',
},
{
label: 'Postleitzahl-Prüfer',
path: '/postcode-checker',
description: 'Prüfen Sie eine Postleitzahl, bevor Sie Zeit für eine Besichtigung einplanen.',
},
],
zh: [
{
label: '数据来源和覆盖范围',
path: '/data-sources',
description: '查看哪些数据集支持邮编筛选,以及这些数据的限制。',
},
{
label: '方法说明',
path: '/methodology',
description: '了解地图如何帮助建立候选清单,而不是替代尽职调查。',
},
{
label: '邮编检查器',
path: '/postcode-checker',
description: '在安排看房前先检查一个邮编。',
},
],
hi: [
{
label: 'डेटा स्रोत और कवरेज',
path: '/data-sources',
description:
'देखें कि पोस्टकोड फ़िल्टर के पीछे कौन से डेटा सेट हैं और उनकी सीमाएँ कहाँ हैं।',
},
{
label: 'कार्यप्रणाली',
path: '/methodology',
description:
'समझें कि नक्शा शॉर्टलिस्ट बनाने में कैसे मदद करता है, लेकिन आपकी जाँच की जगह नहीं लेता।',
},
{
label: 'पोस्टकोड जाँच',
path: '/postcode-checker',
description: 'किसी देखने जाने से पहले एक पोस्टकोड की जाँच करें।',
},
],
hu: [
{
label: 'Adatforrások és lefedettség',
path: '/data-sources',
description:
'Nézze meg, mely adatkészletek állnak az irányítószám-szűrők mögött, és hol vannak a korlátaik.',
},
{
label: 'Módszertan',
path: '/methodology',
description:
'Értse meg, hogyan támogatja a térkép a szűkítést anélkül, hogy kiváltaná a saját ellenőrzést.',
},
{
label: 'Irányítószám-ellenőrző',
path: '/postcode-checker',
description: 'Ellenőrizzen egy irányítószámot, mielőtt időt szánna egy megtekintésre.',
},
],
};

View file

@ -5,6 +5,7 @@ import { parseUrlState, stateToParams } from './url-state';
import { INITIAL_VIEW_STATE } from './consts';
import { createSchoolFilterKey } from './school-filter';
import { createSpecificCrimeFilterKey } from './crime-filter';
import { createElectionVoteShareFilterKey } from './election-filter';
import { createEthnicityFilterKey } from './ethnicity-filter';
import {
POI_COUNT_2KM_FILTER_NAME,
@ -168,6 +169,33 @@ describe('url-state', () => {
});
});
it('round-trips repeated election vote-share filters with dedicated URL params', () => {
const labour = createElectionVoteShareFilterKey('% Labour', 1);
const conservative = createElectionVoteShareFilterKey('% Conservative', 2);
const params = stateToParams(
null,
{
[labour]: [30, 55],
[conservative]: [10, 35],
},
[],
new Set(),
'area'
);
expect(params.getAll('voteShare')).toEqual(['% Labour:30:55', '% Conservative:10:35']);
expect(params.getAll('filter')).toEqual([]);
window.history.replaceState({}, '', `/?${params.toString()}`);
const state = parseUrlState();
expect(state.filters).toEqual({
[createElectionVoteShareFilterKey('% Labour', 0)]: [30, 55],
[createElectionVoteShareFilterKey('% Conservative', 1)]: [10, 35],
});
});
it('round-trips repeated ethnicity filters with dedicated URL params', () => {
const white = createEthnicityFilterKey('% White', 3);
const southAsian = createEthnicityFilterKey('% South Asian', 4);

View file

@ -22,6 +22,13 @@ import {
isSpecificCrimeFeatureName,
isSpecificCrimeFilterName,
} from './crime-filter';
import {
ELECTION_VOTE_SHARE_FILTER_NAME,
createElectionVoteShareFilterKey,
getElectionVoteShareFeatureName,
isElectionVoteShareFeatureName,
isElectionVoteShareFilterName,
} from './election-filter';
import {
ETHNICITIES_FILTER_NAME,
createEthnicityFilterKey,
@ -58,6 +65,7 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
const filterParams = params.getAll('filter');
const schoolParams = params.getAll('school');
const crimeParams = params.getAll('crime');
const voteShareParams = params.getAll('voteShare');
const ethnicityParams = params.getAll('ethnicity');
const poiDistanceParams = params.getAll('poiDistance');
const poiCount2KmParams = params.getAll('poiCount2km');
@ -66,6 +74,7 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
filterParams.length === 0 &&
schoolParams.length === 0 &&
crimeParams.length === 0 &&
voteShareParams.length === 0 &&
ethnicityParams.length === 0 &&
poiDistanceParams.length === 0 &&
poiCount2KmParams.length === 0 &&
@ -126,6 +135,18 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
filters[createSpecificCrimeFilterKey(featureName, index)] = [min, max];
});
voteShareParams.forEach((entry, index) => {
const parts = entry.split(':');
if (parts.length < 3) return;
const featureName = parts.slice(0, -2).join(':');
const min = Number(parts[parts.length - 2]);
const max = Number(parts[parts.length - 1]);
if (!isElectionVoteShareFeatureName(featureName) || isNaN(min) || isNaN(max)) {
return;
}
filters[createElectionVoteShareFilterKey(featureName, index)] = [min, max];
});
ethnicityParams.forEach((entry, index) => {
const parts = entry.split(':');
if (parts.length < 3) return;
@ -301,6 +322,13 @@ export function stateToParams(
continue;
}
const electionVoteShareFeatureName = getElectionVoteShareFeatureName(name);
if (electionVoteShareFeatureName && isElectionVoteShareFilterName(name)) {
const [min, max] = value as [number, number];
params.append('voteShare', `${electionVoteShareFeatureName}:${min}:${max}`);
continue;
}
const ethnicityFeatureName = getEthnicityFeatureName(name);
if (ethnicityFeatureName && isEthnicityFilterName(name)) {
const [min, max] = value as [number, number];
@ -372,6 +400,7 @@ export function summarizeParams(queryString: string): string {
const filterParams = params.getAll('filter');
const schoolParams = params.getAll('school');
const crimeParams = params.getAll('crime');
const voteShareParams = params.getAll('voteShare');
const ethnicityParams = params.getAll('ethnicity');
const poiDistanceParams = params.getAll('poiDistance');
const poiCount2KmParams = params.getAll('poiCount2km');
@ -380,6 +409,7 @@ export function summarizeParams(queryString: string): string {
filterParams.length > 0 ||
schoolParams.length > 0 ||
crimeParams.length > 0 ||
voteShareParams.length > 0 ||
ethnicityParams.length > 0 ||
poiDistanceParams.length > 0 ||
poiCount2KmParams.length > 0 ||
@ -390,6 +420,7 @@ export function summarizeParams(queryString: string): string {
const colonIdx = entry.indexOf(':');
const name = colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
if (isSpecificCrimeFeatureName(name)) return SPECIFIC_CRIMES_FILTER_NAME;
if (isElectionVoteShareFeatureName(name)) return ELECTION_VOTE_SHARE_FILTER_NAME;
if (isEthnicityFeatureName(name)) return ETHNICITIES_FILTER_NAME;
if (isPoiDistanceFeatureName(name)) return POI_DISTANCE_FILTER_NAME;
return name;
@ -399,6 +430,9 @@ export function summarizeParams(queryString: string): string {
for (let i = 0; i < crimeParams.length; i++) {
filterNames.push(SPECIFIC_CRIMES_FILTER_NAME);
}
for (let i = 0; i < voteShareParams.length; i++) {
filterNames.push(ELECTION_VOTE_SHARE_FILTER_NAME);
}
for (let i = 0; i < ethnicityParams.length; i++) {
filterNames.push(ETHNICITIES_FILTER_NAME);
}

View file

@ -17,8 +17,7 @@ set -euo pipefail
# - places_ref.parquet: place order reference
#
# Usage:
# ./r5-java/run.sh [--paths] [--demo]
# --paths records journey instructions (transit only, ~20x slower)
# ./r5-java/run.sh [--demo]
# --demo only compute Bank + TCR, transit only (quick test)
# --- Defaults ---
@ -27,7 +26,6 @@ HEAP=40g
NETWORK_DIR=property-data/r5-network
OUTPUT_BASE=property-data/travel-times
R5_DIR=r5-java
PATHS_FLAG=""
DEMO_FLAG=""
# --- Parse args ---
@ -37,7 +35,6 @@ while [[ $# -gt 0 ]]; do
--heap) HEAP="$2"; shift 2 ;;
--network-dir) NETWORK_DIR="$2"; shift 2 ;;
--output-dir) OUTPUT_BASE="$2"; shift 2 ;;
--paths) PATHS_FLAG="--paths"; shift ;;
--demo) DEMO_FLAG="--demo"; shift ;;
*) echo "Unknown: $1"; exit 1 ;;
esac
@ -140,7 +137,7 @@ java -Xms"$HEAP" -Xmx"$HEAP" -cp "$OUT_DIR:$LIB_DIR/*" propertymap.App \
--places property-data/places.parquet \
--output-dir "$OUTPUT_BASE" \
--threads "$THREADS" \
$PATHS_FLAG $DEMO_FLAG
$DEMO_FLAG
echo ""
echo "=== Complete ==="

View file

@ -31,7 +31,7 @@ import java.util.concurrent.atomic.AtomicInteger;
* Output per mode: one parquet file per origin in {output-dir}/{mode}/{name}.parquet
* with columns (pcds VARCHAR, travel_minutes SMALLINT). Transit mode additionally
* includes a best_minutes SMALLINT column (5th percentile = best-case departure timing)
* and optionally a journey VARCHAR column (JSON leg instructions, when --paths is set).
* and a journey VARCHAR column with JSON leg instructions.
*/
public class App {
@ -46,7 +46,7 @@ public class App {
String placesPath = requiredArg(args, "--places");
String outputDirStr = requiredArg(args, "--output-dir");
int threads = Integer.parseInt(optionalArg(args, "--threads", "4"));
boolean enablePaths = hasFlag(args, "--paths");
boolean enablePaths = true;
boolean demo = hasFlag(args, "--demo");
Path outDir = Paths.get(outputDirStr);
@ -300,7 +300,7 @@ public class App {
if (args[i].equals(name)) return args[i + 1];
}
System.err.println("Missing required argument: " + name);
System.err.println("Usage: App --postcodes FILE --places FILE --output-dir DIR [--threads N] [--paths] [--demo]");
System.err.println("Usage: App --postcodes FILE --places FILE --output-dir DIR [--threads N] [--demo]");
System.exit(1);
return null; // unreachable
}

View file

@ -14,6 +14,7 @@ test('buildScreenshotRequest accepts supported screenshot parameters', () => {
filter: ['Last known price:100000:500000', 'Total floor area (sqm):50:150'],
school: 'primary:good:2:1:10',
crime: ['Burglary (avg/yr):0:5', 'Vehicle crime (avg/yr):0:10'],
voteShare: ['% Labour:30:55', '% Conservative:10:35'],
ethnicity: ['% White:10:80', '% South Asian:5:35'],
poi: 'supermarket',
tt: 'transit:kings-cross:Kings Cross:b:0:30',
@ -33,6 +34,7 @@ test('buildScreenshotRequest accepts supported screenshot parameters', () => {
'Burglary (avg/yr):0:5',
'Vehicle crime (avg/yr):0:10',
]);
assert.deepEqual(result.qs.getAll('voteShare'), ['% Labour:30:55', '% Conservative:10:35']);
assert.deepEqual(result.qs.getAll('ethnicity'), ['% White:10:80', '% South Asian:5:35']);
});

View file

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

View file

@ -62,26 +62,6 @@ pub struct FeatureGroup {
}
pub static FEATURE_GROUPS: &[FeatureGroup] = &[
FeatureGroup {
name: "Transport",
features: &[
Feature::Numeric(FeatureConfig {
name: "Distance to nearest train or tube station (km)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 0.1,
description: "Distance to the closest train or tube station",
detail: "Straight-line distance in kilometres from the postcode to the nearest rail station or Tube/metro/tram stop.",
source: "naptan",
prefix: "",
suffix: " km",
raw: false,
absolute: false,
}),
],
},
FeatureGroup {
name: "Properties",
features: &[
@ -410,7 +390,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
],
},
FeatureGroup {
name: "Deprivation",
name: "Area characteristics",
features: &[
Feature::Numeric(FeatureConfig {
name: "Income Score",
@ -765,7 +745,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
],
},
FeatureGroup {
name: "Demographics",
name: "Neighbours",
features: &[
Feature::Numeric(FeatureConfig {
name: "Median age",
@ -872,11 +852,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
raw: false,
absolute: false,
}),
],
},
FeatureGroup {
name: "Politics",
features: &[
Feature::Numeric(FeatureConfig {
name: "% Labour",
bounds: Bounds::Fixed {
@ -1138,36 +1113,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Number of restaurants within 2km",
bounds: Bounds::Percentile {
low: 5.0,
high: 95.0,
},
step: 1.0,
description: "Number of restaurants and cafes within 2km",
detail: "Restaurants, cafes, and food establishments within 2km of the postcode. Sourced from OpenStreetMap.",
source: "osm-pois",
prefix: "",
suffix: "",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Number of grocery shops and supermarkets within 2km",
bounds: Bounds::Percentile {
low: 5.0,
high: 95.0,
},
step: 1.0,
description: "Number of grocery shops and supermarkets within 2km",
detail: "Count of supermarkets, convenience stores, and other grocery shops within a 2km radius of the property's postcode centroid. Derived from OpenStreetMap POI data.",
source: "osm-pois",
prefix: "",
suffix: "",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Noise (dB)",
bounds: Bounds::Fixed {

View file

@ -9,8 +9,6 @@
"bootstrap-admin": "tsc && node dist/pb-admin.js",
"setup-auth": "tsc && node dist/auth.js",
"record": "tsc && node dist/record.js",
"record:vertical": "tsc && ASPECT=9x16 node dist/record.js",
"encode": "ffmpeg -y -i output/recording.webm -c:v libx264 -pix_fmt yuv420p -crf 14 -preset fast -movflags +faststart output/recording.mp4",
"verify-output": "tsc && node dist/verify.js",
"render": "./render.sh"
},

View file

@ -1,6 +1,11 @@
#!/usr/bin/env bash
#
# End-to-end re-render of the dashboard demo video.
# End-to-end re-render of the dashboard demo videos.
#
# All per-storyboard knobs (aspect, fps, bitrate, prompt text, voice persona,
# poster timestamp, brand strings…) live on the Storyboard objects in
# src/storyboard.ts. To add a vertical cut or change the voice, edit that
# file — this script only handles target/auth/transport concerns.
#
# Two targets:
# local (default) — assumes the docker-compose stack on host.docker.internal,
@ -17,7 +22,6 @@
# ./render.sh --no-audio # skip Qwen3-TTS narration; silent MP4
# FORCE_AUTH=1 ./render.sh # same as --fresh-auth
# APP_URL=http://localhost:3001 ./render.sh # override frontend URL
# TTS_SPEAKER=aiden ./render.sh # override CustomVoice speaker
#
# Cred env vars (read for both targets, but prod has no fallback defaults):
# LOGIN_EMAIL, LOGIN_PASSWORD — the dashboard account to record as
@ -48,7 +52,7 @@ case "$TARGET" in
*) echo "Unknown --target: $TARGET (expected: local, prod)" >&2; exit 2 ;;
esac
# -- config (override via env) -------------------------------------------------
# -- environment (target-specific URLs and credentials) ----------------------
if [ "$TARGET" = "prod" ]; then
# Prod serves frontend, /api/*, and /pb/* off the same domain.
export APP_URL="${APP_URL:-https://perfect-postcode.co.uk}"
@ -81,23 +85,6 @@ AUTH_TTL_HOURS="${AUTH_TTL_HOURS:-24}" # re-auth if cache older than this
# the built bundle, so updating this path is what makes the new clip appear
# on the homepage. Override if the dashboard ever moves.
PUBLISH_DIR="${PUBLISH_DIR:-../frontend/public/video}"
# When in the output timeline to grab the poster frame.
# Right-pane inspection (~16s output) is the clearest paused-state preview:
# Manchester map, filters applied, right pane populated, larger narration
# caption visible.
POSTER_TIME_S="${POSTER_TIME_S:-16}"
# Recorder/encoder knobs read by src/config.ts. config.ts treats these as
# required, so they live here (the only entry point) rather than as defaults
# scattered across TS modules. Override per-run via env.
export ASPECT="${ASPECT:-16x9}"
export CAPTURE_SCALE="${CAPTURE_SCALE:-1}"
export WEBM_BITRATE="${WEBM_BITRATE:-$(awk -v s="$CAPTURE_SCALE" 'BEGIN{print (s+0>1)?"18M":"8M"}')}"
export PROMPT_TEXT="${PROMPT_TEXT:-Flats or terraces <£450k, 35 min to Manchester, low crime}"
export AI_ZOOM_SCALE="${AI_ZOOM_SCALE:-2.4}"
export MAX_DURATION_S="${MAX_DURATION_S:-60}"
export MIN_DURATION_S="${MIN_DURATION_S:-10}"
export OUTPUT_FPS="${OUTPUT_FPS:-50}"
FRESH_AUTH="${FORCE_AUTH:-0}"
DO_ENCODE=1
@ -109,7 +96,7 @@ for arg in "${@:-}"; do
--no-encode) DO_ENCODE=0 ;;
--no-audio) DO_AUDIO=0 ;;
-h|--help)
sed -n '3,30p' "$0"
sed -n '3,32p' "$0"
exit 0 ;;
*) echo "Unknown arg: $arg" >&2; exit 2 ;;
esac
@ -207,22 +194,57 @@ else
say "Reusing existing $AUTH_STATE_FILE"
fi
# -- preflight + synth (Qwen3-TTS) -------------------------------------------
# Synth runs BEFORE recording: one batched generate_custom_voice call across
# all cues so the voice stays consistent. The recorder reads
# output/audio/index.json for measured per-cue durations and sizes each
# cue's wall-clock to fit; --no-audio skips synth and the recorder falls
# back to a worst-case estimate.
# -- preflight ---------------------------------------------------------------
# preflight emits per-storyboard narration scripts AND output/storyboards.json
# (the index this script loops over below). Run it BEFORE wiping per-storyboard
# files so we know what slugs to target.
mkdir -p output
# Wipe last run's leaking artifacts so the rename step picks up *this* run.
rm -f output/recording.webm output/recording.mp4 output/page@*.webm output/page@*.webm.untrimmed
rm -f output/narration-script.json output/narration.json
# output/audio/ is preserved; tts/synth.py decides whether the cached WAVs
# still match the script and skips generation when they do.
say "Preflight: emitting narration script"
say "Preflight: emitting narration scripts and storyboard index"
node dist/preflight.js
if [ ! -s output/storyboards.json ]; then
fail "preflight did not produce output/storyboards.json"
fi
# Pull the storyboard slugs out of the index. Use Node so we don't grow a jq
# dependency just for one read.
mapfile -t STORYBOARDS < <(node -e '
const idx = JSON.parse(require("fs").readFileSync("output/storyboards.json","utf8"));
for (const s of idx.storyboards) console.log(s.name);
')
if [ "${#STORYBOARDS[@]}" -eq 0 ]; then
fail "storyboards.json contains no storyboards"
fi
say "Storyboards to render: ${STORYBOARDS[*]}"
# Per-storyboard poster timestamp lookup (slug → seconds), set once so each
# loop body can read it without re-parsing the index.
poster_time_for() {
node -e '
const idx = JSON.parse(require("fs").readFileSync("output/storyboards.json","utf8"));
const sb = idx.storyboards.find(s => s.name === process.argv[1]);
if (!sb) { process.exit(1); }
process.stdout.write(String(sb.posterTimeS));
' "$1"
}
# -- per-storyboard wipe of leaking artefacts --------------------------------
# output/<sb>/audio/ is preserved; tts/synth.py decides whether the cached
# WAVs still match the script and skips generation when they do.
for sb in "${STORYBOARDS[@]}"; do
rm -f "output/$sb/recording.webm" "output/$sb/recording.mp4" \
"output/$sb/page@"*.webm "output/$sb/page@"*.webm.untrimmed \
"output/$sb/recording.raw.webm" "output/$sb/recording.raw.webm.untrimmed" \
"output/$sb/recording.narrated.mp4" "output/$sb/poster.jpg" \
"output/$sb/narration.json"
done
# -- synth (Qwen3-TTS) -------------------------------------------------------
# Synth runs BEFORE recording: one batched generate_voice_clone call per
# storyboard so the voice stays consistent within each video. The recorder
# reads output/<sb>/audio/index.json for measured per-cue durations and
# sizes each cue's wall-clock to fit; --no-audio skips synth and the recorder
# falls back to a worst-case estimate.
if [ "$DO_AUDIO" = "1" ]; then
if ! command -v uv >/dev/null 2>&1; then
fail "uv not on PATH (required for Qwen3-TTS synth). Install uv or rerun with --no-audio."
@ -236,95 +258,103 @@ if [ "$DO_AUDIO" = "1" ]; then
if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then
uv_sync_extras+=(--extra gpu)
fi
say "Synthesising narration with Qwen3-TTS (speaker=${TTS_SPEAKER:-ryan}) — one batched call"
say "Synchronising tts/ Python deps"
uv sync --project tts ${uv_sync_extras[@]+"${uv_sync_extras[@]}"} || fail "uv sync failed in video/tts"
uv run --project tts python tts/synth.py || fail "tts/synth.py failed"
if [ ! -s output/audio/index.json ]; then
fail "synth did not produce output/audio/index.json"
fi
for sb in "${STORYBOARDS[@]}"; do
say "Synthesising narration for [$sb] — one batched call"
uv run --project tts python tts/synth.py --storyboard "$sb" \
|| fail "tts/synth.py failed for $sb"
if [ ! -s "output/$sb/audio/index.json" ]; then
fail "synth did not produce output/$sb/audio/index.json"
fi
done
fi
# -- record -------------------------------------------------------------------
say "Recording"
# -- record ------------------------------------------------------------------
# record.ts iterates over storyboards in-process and writes per-storyboard
# recording.webm + narration.json. One Node invocation handles all of them
# so we don't spin up Playwright + GPU/WebGL + auth more than necessary.
say "Recording all storyboards"
APP_URL="$APP_URL" node dist/record.js
if [ ! -s output/recording.webm ]; then
fail "recording.webm missing or empty"
fi
node dist/verify.js output/recording.webm
# -- encode -------------------------------------------------------------------
if [ "$DO_ENCODE" = "1" ]; then
if ! command -v ffmpeg >/dev/null 2>&1; then
fail "ffmpeg not on PATH; rerun with --no-encode if you only need the WebM"
for sb in "${STORYBOARDS[@]}"; do
if [ ! -s "output/$sb/recording.webm" ]; then
fail "[$sb] recording.webm missing or empty"
fi
say "Encoding to MP4"
ffmpeg -y -loglevel warning -i output/recording.webm \
-c:v libx264 -pix_fmt yuv420p -crf 14 -preset fast \
-movflags +faststart \
output/recording.mp4
node dist/verify.js "$sb" "output/$sb/recording.webm"
done
# Poster: a single high-quality JPEG extracted from a representative
# moment in the output timeline. Used as the homepage <video poster=...>,
# which is what the visitor sees before pressing play.
# - -ss AFTER -i = output-side seek, frame-accurate (input-side seek
# would land on the nearest keyframe, drifting back up to ~2s).
# - -update 1 tells ffmpeg the output is a single image, not a sequence.
# - -q:v 2 = high JPEG quality (~95%); poster file is ~120KB at 1080p.
say "Extracting poster frame at ${POSTER_TIME_S}s"
ffmpeg -y -loglevel warning -i output/recording.mp4 -ss "$POSTER_TIME_S" \
-frames:v 1 -update 1 -q:v 2 \
output/poster.jpg
node dist/verify.js output/recording.mp4 output/poster.jpg
# -- encode + mux + publish (per storyboard) ---------------------------------
if [ "$DO_ENCODE" = "1" ] && ! command -v ffmpeg >/dev/null 2>&1; then
fail "ffmpeg not on PATH; rerun with --no-encode if you only need the WebM"
fi
# -- mux narration ------------------------------------------------------------
# Synth already produced per-cue WAVs (in output/audio/); the recorder logged
# each cue's videoTime against the trimmed timeline. Drop the WAVs onto the
# mp4 with one ffmpeg adelay+amix and replace the silent recording in place.
if [ "$DO_ENCODE" = "1" ] && [ "$DO_AUDIO" = "1" ]; then
if [ ! -s output/narration.json ]; then
fail "narration.json missing — recorder did not log cues"
for sb in "${STORYBOARDS[@]}"; do
if [ "$DO_ENCODE" = "1" ]; then
say "[$sb] Encoding to MP4"
ffmpeg -y -loglevel warning -i "output/$sb/recording.webm" \
-c:v libx264 -pix_fmt yuv420p -crf 14 -preset fast \
-movflags +faststart \
"output/$sb/recording.mp4"
# Poster: a single high-quality JPEG extracted from a representative
# moment in the output timeline. Used as the homepage <video poster=...>.
# - -ss AFTER -i = output-side seek, frame-accurate (input-side seek
# would land on the nearest keyframe, drifting back up to ~2s).
# - -update 1 tells ffmpeg the output is a single image, not a sequence.
# - -q:v 2 = high JPEG quality (~95%); poster file is ~120KB at 1080p.
poster_t="$(poster_time_for "$sb")"
say "[$sb] Extracting poster frame at ${poster_t}s"
ffmpeg -y -loglevel warning -i "output/$sb/recording.mp4" -ss "$poster_t" \
-frames:v 1 -update 1 -q:v 2 \
"output/$sb/poster.jpg"
node dist/verify.js "$sb" "output/$sb/recording.mp4" "output/$sb/poster.jpg"
fi
say "Muxing narration into output/recording.mp4"
uv run --project tts python tts/mux.py --replace \
|| fail "tts/mux.py failed"
node dist/verify.js output/recording.mp4
fi
# -- publish to homepage ------------------------------------------------------
# Only publish when we did the encode (otherwise we'd be copying a stale
# mp4 next to a fresh webm). --no-encode skips this whole block.
if [ "$DO_ENCODE" = "1" ]; then
if [ ! -d "$PUBLISH_DIR" ]; then
say "Creating $PUBLISH_DIR"
mkdir -p "$PUBLISH_DIR"
if [ "$DO_ENCODE" = "1" ] && [ "$DO_AUDIO" = "1" ]; then
if [ ! -s "output/$sb/narration.json" ]; then
fail "[$sb] narration.json missing — recorder did not log cues"
fi
say "[$sb] Muxing narration into output/$sb/recording.mp4"
uv run --project tts python tts/mux.py --storyboard "$sb" --replace \
|| fail "tts/mux.py failed for $sb"
node dist/verify.js "$sb" "output/$sb/recording.mp4"
fi
say "Publishing to $PUBLISH_DIR"
cp output/recording.mp4 "$PUBLISH_DIR/recording.mp4"
cp output/poster.jpg "$PUBLISH_DIR/poster.jpg"
node dist/verify.js "$PUBLISH_DIR/recording.mp4" "$PUBLISH_DIR/poster.jpg"
fi
# -- report -------------------------------------------------------------------
# Only publish when we did the encode (otherwise we'd be copying a stale
# mp4 next to a fresh webm). --no-encode skips publish.
if [ "$DO_ENCODE" = "1" ]; then
if [ ! -d "$PUBLISH_DIR" ]; then
say "Creating $PUBLISH_DIR"
mkdir -p "$PUBLISH_DIR"
fi
say "[$sb] Publishing to $PUBLISH_DIR/$sb.{mp4,jpg}"
cp "output/$sb/recording.mp4" "$PUBLISH_DIR/$sb.mp4"
cp "output/$sb/poster.jpg" "$PUBLISH_DIR/$sb.jpg"
node dist/verify.js "$sb" "$PUBLISH_DIR/$sb.mp4" "$PUBLISH_DIR/$sb.jpg"
fi
done
# -- report ------------------------------------------------------------------
say "Done"
if command -v ffprobe >/dev/null 2>&1; then
for f in output/recording.webm output/recording.mp4 output/poster.jpg \
"$PUBLISH_DIR/recording.mp4" "$PUBLISH_DIR/poster.jpg"; do
[ -f "$f" ] || continue
size=$(stat -c '%s' "$f" 2>/dev/null || stat -f '%z' "$f")
case "$f" in
*.mp4|*.webm)
dur=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$f")
printf ' %s %ss %s bytes\n' "$f" "$(printf '%.2f' "$dur")" "$size"
;;
*)
printf ' %s %s bytes\n' "$f" "$size"
;;
esac
for sb in "${STORYBOARDS[@]}"; do
for f in "output/$sb/recording.webm" "output/$sb/recording.mp4" \
"output/$sb/poster.jpg" \
"$PUBLISH_DIR/$sb.mp4" "$PUBLISH_DIR/$sb.jpg"; do
[ -f "$f" ] || continue
size=$(stat -c '%s' "$f" 2>/dev/null || stat -f '%z' "$f")
case "$f" in
*.mp4|*.webm)
dur=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$f")
printf ' %s %ss %s bytes\n' "$f" "$(printf '%.2f' "$dur")" "$size"
;;
*)
printf ' %s %s bytes\n' "$f" "$size"
;;
esac
done
done
else
ls -la output/recording.* output/poster.jpg \
"$PUBLISH_DIR/recording.mp4" "$PUBLISH_DIR/poster.jpg" 2>/dev/null || true
fi

View file

@ -3,48 +3,52 @@ import {
type Browser,
type BrowserContext,
type Page,
} from "playwright";
import {
AUTH_STATE_PATH,
CAPTURE_SCALE,
OUTPUT_DIR,
VIDEO_SIZE,
VIEWPORT,
} from "./config.js";
} from 'playwright';
import { AUTH_STATE_PATH } from './config.js';
import { viewportFor, type Storyboard } from './script.js';
export interface RecordingBrowser {
browser: Browser;
context: BrowserContext;
}
export async function launchRecordingBrowser(): Promise<RecordingBrowser> {
export interface LaunchOptions {
/** Directory the playwright recorder writes the raw .webm into. */
recordDir: string;
}
export async function launchRecordingBrowser(
storyboard: Storyboard,
opts: LaunchOptions
): Promise<RecordingBrowser> {
const browser = await chromium.launch({
headless: true,
args: [
"--disable-blink-features=AutomationControlled",
"--enable-gpu",
"--use-gl=angle",
"--use-angle=gl-egl",
"--ignore-gpu-blocklist",
"--enable-webgl",
"--enable-webgl2",
"--enable-gpu-rasterization",
"--enable-zero-copy",
"--disable-software-rasterizer",
"--disable-frame-rate-limit",
"--disable-gpu-vsync",
"--disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling",
"--disable-renderer-backgrounding",
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
'--disable-blink-features=AutomationControlled',
'--enable-gpu',
'--use-gl=angle',
'--use-angle=gl-egl',
'--ignore-gpu-blocklist',
'--enable-webgl',
'--enable-webgl2',
'--enable-gpu-rasterization',
'--enable-zero-copy',
'--disable-software-rasterizer',
'--disable-frame-rate-limit',
'--disable-gpu-vsync',
'--disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling',
'--disable-renderer-backgrounding',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
],
});
const viewport = viewportFor(storyboard.video);
const context = await browser.newContext({
storageState: AUTH_STATE_PATH,
viewport: VIEWPORT,
deviceScaleFactor: CAPTURE_SCALE,
recordVideo: { dir: OUTPUT_DIR, size: VIDEO_SIZE },
viewport,
deviceScaleFactor: storyboard.video.captureScale,
recordVideo: { dir: opts.recordDir, size: viewport },
});
await suppressDevServerNoise(context);
return { browser, context };
@ -52,11 +56,11 @@ export async function launchRecordingBrowser(): Promise<RecordingBrowser> {
export async function assertHardwareWebGL(page: Page): Promise<void> {
const info = await page.evaluate(() => {
const canvas = document.createElement("canvas");
const gl = canvas.getContext("webgl2");
if (!gl) return { webgl: false, vendor: "", renderer: "" };
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl2');
if (!gl) return { webgl: false, vendor: '', renderer: '' };
const ext = gl.getExtension("WEBGL_debug_renderer_info");
const ext = gl.getExtension('WEBGL_debug_renderer_info');
const vendor = String(
ext
? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL)
@ -71,15 +75,15 @@ export async function assertHardwareWebGL(page: Page): Promise<void> {
});
console.log(
`[gpu] WebGL renderer: ${info.webgl ? `${info.vendor} / ${info.renderer}` : "none"}`,
`[gpu] WebGL renderer: ${info.webgl ? `${info.vendor} / ${info.renderer}` : 'none'}`,
);
if (
process.env.ALLOW_SOFTWARE_GL !== "1" &&
process.env.ALLOW_SOFTWARE_GL !== '1' &&
(!info.webgl ||
/SwiftShader|llvmpipe|software/i.test(`${info.vendor} ${info.renderer}`))
) {
throw new Error(
"Recording browser did not get hardware WebGL. Set ALLOW_SOFTWARE_GL=1 to bypass this guard.",
'Recording browser did not get hardware WebGL. Set ALLOW_SOFTWARE_GL=1 to bypass this guard.',
);
}
}
@ -89,45 +93,45 @@ async function suppressDevServerNoise(context: BrowserContext) {
const RealWS = window.WebSocket;
window.WebSocket = new Proxy(RealWS, {
construct(target, args) {
const url = String(args[0] ?? "");
const proto = (args[1] as string | string[] | undefined) ?? "";
const protoStr = Array.isArray(proto) ? proto.join(",") : proto;
const url = String(args[0] ?? '');
const proto = (args[1] as string | string[] | undefined) ?? '';
const protoStr = Array.isArray(proto) ? proto.join(',') : proto;
if (
protoStr.includes("vite-hmr") ||
protoStr.includes("webpack") ||
url.includes("/ws") ||
url.includes("sockjs-node")
protoStr.includes('vite-hmr') ||
protoStr.includes('webpack') ||
url.includes('/ws') ||
url.includes('sockjs-node')
) {
const fake = new EventTarget() as WebSocket;
Object.defineProperties(fake, {
readyState: { value: RealWS.CLOSED },
url: { value: url },
protocol: { value: "" },
extensions: { value: "" },
protocol: { value: '' },
extensions: { value: '' },
bufferedAmount: { value: 0 },
binaryType: { value: "blob", writable: true },
binaryType: { value: 'blob', writable: true },
});
fake.send = () => {};
fake.close = () => fake.dispatchEvent(new Event("close"));
queueMicrotask(() => fake.dispatchEvent(new Event("close")));
fake.close = () => fake.dispatchEvent(new Event('close'));
queueMicrotask(() => fake.dispatchEvent(new Event('close')));
return fake;
}
return Reflect.construct(target, args);
},
});
Object.defineProperty(window.location, "reload", {
Object.defineProperty(window.location, 'reload', {
value: () => {},
configurable: true,
});
window.addEventListener("error", (e) => e.stopImmediatePropagation(), true);
window.addEventListener('error', (e) => e.stopImmediatePropagation(), true);
window.addEventListener(
"unhandledrejection",
'unhandledrejection',
(e) => e.stopImmediatePropagation(),
true,
);
const styleEl = document.createElement("style");
const styleEl = document.createElement('style');
styleEl.textContent = `
vite-error-overlay,
wds-overlay,
@ -148,12 +152,12 @@ async function suppressDevServerNoise(context: BrowserContext) {
const killOverlay = (node: Element) => {
const tag = node.tagName?.toLowerCase();
const id = (node as HTMLElement).id?.toLowerCase() ?? "";
const id = (node as HTMLElement).id?.toLowerCase() ?? '';
if (
tag === "vite-error-overlay" ||
tag === "wds-overlay" ||
id.includes("webpack-dev-server-client") ||
id.includes("webpack-error")
tag === 'vite-error-overlay' ||
tag === 'wds-overlay' ||
id.includes('webpack-dev-server-client') ||
id.includes('webpack-error')
) {
(node as HTMLElement).remove();
}
@ -168,7 +172,7 @@ async function suppressDevServerNoise(context: BrowserContext) {
if (document.body)
obs.observe(document.body, { childList: true, subtree: true });
else {
document.addEventListener("DOMContentLoaded", () =>
document.addEventListener('DOMContentLoaded', () =>
obs.observe(document.body, { childList: true, subtree: true }),
);
}

View file

@ -6,101 +6,19 @@ function requiredEnv(name: string): string {
return value;
}
function requiredNumberEnv(name: string): number {
const value = Number(requiredEnv(name));
if (!Number.isFinite(value)) {
throw new Error(`${name} must be a finite number`);
}
return value;
}
// Environment-only knobs. Per-storyboard tuning (aspect, fps, bitrate,
// voice, prompts, brand…) lives on the Storyboard object itself — see
// src/storyboard.ts.
export const APP_URL = requiredEnv("APP_URL");
export const DASHBOARD_PATH = "/dashboard";
export const APP_URL = requiredEnv('APP_URL');
export const DASHBOARD_PATH = '/dashboard';
// Per-target storage state. render.sh sets AUTH_STATE_FILE to auth.local.json
// or auth.prod.json so a stale local token can't be reused against prod.
export const AUTH_STATE_PATH = process.env.AUTH_STATE_FILE ?? "auth.json";
export const OUTPUT_DIR = "output";
const aspect = requiredEnv("ASPECT");
if (aspect !== "16x9" && aspect !== "9x16") {
throw new Error("ASPECT must be '16x9' or '9x16'");
}
export const VIEWPORT =
aspect === "9x16"
? { width: 1080, height: 1920 }
: { width: 1920, height: 1080 };
export const CAPTURE_SCALE = Math.max(1, requiredNumberEnv("CAPTURE_SCALE"));
export const VIDEO_SIZE = {
width: VIEWPORT.width,
height: VIEWPORT.height,
};
export const WEBM_BITRATE = requiredEnv("WEBM_BITRATE");
// Cold-open prompt. Punchy version of the user's intent, short enough to type
// on camera without making the opening scene drag.
export const PROMPT_TEXT = requiredEnv("PROMPT_TEXT");
// Filters returned by the AI stub. Keys MUST match real feature names from
// /api/features (verified against the running server's schema).
export const STUBBED_FILTERS: Record<string, [number, number] | string[]> = {
"Property type": ["Flats/Maisonettes", "Terraced"],
"Estimated current price": [175000, 450000],
"Serious crime per 1k residents (avg/yr)": [0, 55],
"Noise (dB)": [50, 68],
};
// Travel-time filters returned by the AI stub. Slug matches the real
// /api/travel-destinations?mode=transit response.
export const STUBBED_TRAVEL_TIME_FILTERS: {
mode: "transit" | "car" | "bicycle" | "walking";
slug: string;
label: string;
min?: number;
max?: number;
}[] = [
{
mode: "transit",
slug: "manchester",
label: "Manchester city centre",
max: 35,
},
];
// The travel-time card we'll drag manually after AI applies. The Filters
// component renders each travel-time entry with `data-filter-name="tt_${i}"`,
// and our stub only sets one entry, so it's tt_0.
export const TT_CARD_SELECTOR = '[data-filter-name="tt_0"]';
export const TT_SLIDER_MAX = 120;
export const TT_DRAG_FROM_MIN = 35; // matches AI stub max above
export const TT_DRAG_TO_MIN = 20;
// Cold-open zoom: how aggressively to magnify the AI box.
// 2.4 fills most of the viewport with the prompt card without blowing up text.
export const AI_ZOOM_SCALE = requiredNumberEnv("AI_ZOOM_SCALE");
// Initial map view used while we navigate. The AI scene zooms in on the
// sidebar so this only matters once we zoom out.
export const INITIAL_MAP_VIEW = {
lat: 53.4795,
lon: -2.2451,
zoom: 11.5,
};
// Verification guard only. The renderer does not use this as an editing cap:
// if the storyboard needs more than 15 seconds to avoid jumps, keep the frames.
export const MAX_DURATION_S = requiredNumberEnv("MAX_DURATION_S");
export const MIN_DURATION_S = requiredNumberEnv("MIN_DURATION_S");
// Target fps of the FINAL output.
export const OUTPUT_FPS = requiredNumberEnv("OUTPUT_FPS");
export const AUTH_STATE_PATH = process.env.AUTH_STATE_FILE ?? 'auth.json';
export const OUTPUT_DIR = 'output';
// Frames of head-room kept in front of sceneStart when trimming. Shared by
// the video trim and the narration manifest so cue offsets line up with the
// trimmed timeline.
// trimmed timeline. Not tuned per storyboard — same lead-in for any cut.
export const LEAD_IN_S = 0.12;
// Brand strings for the outro card.
export const BRAND_NAME = "Perfect Postcode";
export const BRAND_TAGLINE = "Find where you actually want to live.";
export const BRAND_URL = "https://perfect-postcode.co.uk";

View file

@ -1,32 +1,83 @@
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { OUTPUT_DIR } from './config.js';
import { storyboard } from './storyboard.js';
import type { Storyboard } from './script.js';
import { storyboards } from './storyboard.js';
/**
* Emit the narration script for the synth step.
* Emit per-storyboard narration scripts for the synth step.
*
* Synth (tts/synth.py) runs BEFORE recording, so it needs the full ordered
* narration list text + per-cue gaps without depending on Playwright,
* the dashboard, or auth. Walk the storyboard cues, write a flat manifest,
* exit.
* narration list text + per-cue gaps + voice config without depending
* on Playwright, the dashboard, or auth. Walk each storyboard's cues, write
* a flat manifest under `output/<name>/narration-script.json`, then write
* an index manifest at `output/storyboards.json` so render.sh knows which
* storyboard slugs to loop over.
*
* The cue index in this manifest is the source of truth: the runner later
* The cue index in each manifest is the source of truth: the runner later
* matches storyboard cues to measured durations by index.
*/
function main(): void {
if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true });
// Em/en-dashes and ellipses make Qwen3-TTS produce dramatic pauses, sighs,
// or audible breaths — the captions still render the original (unicode-rich)
// text from the storyboard; only the synth input is sanitised.
function normalizeForTts(text: string): string {
return text
.replace(/\s*[—–]\s*/g, ', ')
.replace(/…/g, '.')
.replace(/\.{3,}/g, '.')
.replace(/\s{2,}/g, ' ')
.trim();
}
function emitScript(storyboard: Storyboard): string {
const dir = join(OUTPUT_DIR, storyboard.name);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const items = storyboard.cues.map((cue, cueIndex) => ({
cueIndex,
text: cue.text.trim(),
text: normalizeForTts(cue.text),
gapBeforeMs: cue.gapBeforeMs,
}));
const manifest = { items };
const path = join(OUTPUT_DIR, 'narration-script.json');
// The voice block is consumed by tts/synth.py — see _resolve_reference and
// the cache check there for which fields invalidate cached audio.
const manifest = {
storyboard: storyboard.name,
voice: {
instruct: storyboard.voice.instruct,
language: storyboard.voice.language,
temperature: storyboard.voice.temperature ?? 0.6,
topP: storyboard.voice.topP ?? 0.9,
seed: storyboard.voice.seed ?? 42,
},
items,
};
const path = join(dir, 'narration-script.json');
writeFileSync(path, JSON.stringify(manifest, null, 2));
console.log(`Wrote ${items.length} narration cues to ${path}`);
console.log(`[preflight] [${storyboard.name}] wrote ${items.length} cues → ${path}`);
return path;
}
function main(): void {
if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true });
for (const sb of storyboards) emitScript(sb);
// Index for shell loops — each entry has every field render.sh needs to
// address per-storyboard outputs without re-parsing the TS source.
const index = {
storyboards: storyboards.map((sb) => ({
name: sb.name,
aspect: sb.video.aspect,
outputFps: sb.video.outputFps,
minDurationS: sb.video.minDurationS,
maxDurationS: sb.video.maxDurationS,
posterTimeS: sb.video.posterTimeS,
})),
};
const indexPath = join(OUTPUT_DIR, 'storyboards.json');
writeFileSync(indexPath, JSON.stringify(index, null, 2));
console.log(`[preflight] wrote storyboard index → ${indexPath}`);
}
main();

View file

@ -1,11 +1,15 @@
import { chromium } from 'playwright';
import { APP_URL, AUTH_STATE_PATH, DASHBOARD_PATH, VIEWPORT } from './config.js';
import { APP_URL, AUTH_STATE_PATH, DASHBOARD_PATH } from './config.js';
import { viewportFor } from './script.js';
import { storyboards } from './storyboard.js';
async function main() {
// probe is a debug utility — pin it to the first storyboard's viewport.
const viewport = viewportFor(storyboards[0].video);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
storageState: AUTH_STATE_PATH,
viewport: VIEWPORT,
viewport,
});
const page = await context.newPage();
page.on('request', (r) => {

View file

@ -4,18 +4,20 @@ import { AUTH_STATE_PATH, LEAD_IN_S, OUTPUT_DIR } from './config.js';
import { assertHardwareWebGL, launchRecordingBrowser } from './browser.js';
import { narrationLog } from './narration.js';
import { installDemoRoutes } from './routes.js';
import { storyboard } from './storyboard.js';
import type { Storyboard } from './script.js';
import { storyboards } from './storyboard.js';
import { prepareTimeline, runTimeline } from './timeline.js';
import { trimRecording } from './video.js';
async function main() {
if (!existsSync(AUTH_STATE_PATH)) {
console.error(`No ${AUTH_STATE_PATH} found. Run "npm run setup-auth" first.`);
process.exit(1);
}
if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true });
async function recordOne(storyboard: Storyboard): Promise<void> {
const dir = join(OUTPUT_DIR, storyboard.name);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const { browser, context } = await launchRecordingBrowser();
console.log(`\n=== [${storyboard.name}] recording ===`);
const { browser, context } = await launchRecordingBrowser(storyboard, {
recordDir: dir,
});
const page = await context.newPage();
await assertHardwareWebGL(page);
const recordedVideo = page.video();
@ -37,22 +39,21 @@ async function main() {
if (u.includes('ai-filters')) console.log(`[req] ${r.method()} ${u}`);
});
await installDemoRoutes(page);
const ctx = await prepareTimeline(page);
await installDemoRoutes(page, storyboard);
const ctx = await prepareTimeline(page, storyboard);
const timeline = await runTimeline(ctx, storyboard);
await page.close();
const rawPath = join(OUTPUT_DIR, 'recording.raw.webm');
const rawPath = join(dir, 'recording.raw.webm');
if (recordedVideo) await recordedVideo.saveAs(rawPath);
await context.close();
await browser.close();
if (!recordedVideo || !statSync(rawPath).size) {
console.error('no recorded webm found');
process.exit(1);
throw new Error(`[${storyboard.name}] no recorded webm found`);
}
trimRecording(rawPath, join(OUTPUT_DIR, 'recording.webm'), {
trimRecording(rawPath, join(dir, 'recording.webm'), storyboard, {
recordStartMs,
...timeline,
});
@ -60,13 +61,25 @@ async function main() {
const totalDurationMs =
timeline.sceneEndMs - timeline.sceneStartMs + LEAD_IN_S * 1000;
const cues = narrationLog.flush(
join(OUTPUT_DIR, 'narration.json'),
join(dir, 'narration.json'),
totalDurationMs
);
console.log(
`Wrote ${cues.length} narration cues to ${join(OUTPUT_DIR, 'narration.json')}`
`[${storyboard.name}] wrote ${cues.length} narration cues → ${join(dir, 'narration.json')}`
);
console.log('Run "npm run encode" to produce output/recording.mp4');
}
async function main(): Promise<void> {
if (!existsSync(AUTH_STATE_PATH)) {
console.error(`No ${AUTH_STATE_PATH} found. Run "npm run setup-auth" first.`);
process.exit(1);
}
if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true });
for (const sb of storyboards) {
await recordOne(sb);
}
console.log(`\n=== recorded ${storyboards.length} storyboard(s) ===`);
}
main().catch((err) => {

View file

@ -1,35 +1,33 @@
import type { Page } from 'playwright';
import {
APP_URL,
DASHBOARD_PATH,
INITIAL_MAP_VIEW,
STUBBED_FILTERS,
STUBBED_TRAVEL_TIME_FILTERS,
} from './config.js';
import { APP_URL, DASHBOARD_PATH } from './config.js';
import type { Storyboard } from './script.js';
export async function installDemoRoutes(page: Page) {
await Promise.all([stubAiFilters(page), stubExport(page)]);
export async function installDemoRoutes(page: Page, storyboard: Storyboard) {
await Promise.all([stubAiFilters(page, storyboard), stubExport(page)]);
}
export function dashboardUrl(): string {
export function dashboardUrl(storyboard: Storyboard): string {
const view = storyboard.content.initialMapView;
const params = new URLSearchParams({
lat: String(INITIAL_MAP_VIEW.lat),
lon: String(INITIAL_MAP_VIEW.lon),
zoom: String(INITIAL_MAP_VIEW.zoom),
lat: String(view.lat),
lon: String(view.lon),
zoom: String(view.zoom),
});
addInitialTravelTimeParams(params);
for (const tt of storyboard.content.stubbedTravelTimeFilters) {
params.append('tt', `${tt.mode}:${tt.slug}:${tt.label}:${tt.min ?? 0}:${tt.max ?? 120}`);
}
return `${APP_URL}${DASHBOARD_PATH}?${params}`;
}
async function stubAiFilters(page: Page) {
async function stubAiFilters(page: Page, storyboard: Storyboard) {
await page.route('**/api/ai-filters', async (route) => {
await new Promise((r) => setTimeout(r, 120));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
filters: STUBBED_FILTERS,
travel_time_filters: STUBBED_TRAVEL_TIME_FILTERS,
filters: storyboard.content.stubbedFilters,
travel_time_filters: storyboard.content.stubbedTravelTimeFilters,
notes: '',
match_count: 1247,
}),
@ -50,9 +48,3 @@ async function stubExport(page: Page) {
});
});
}
function addInitialTravelTimeParams(params: URLSearchParams) {
for (const tt of STUBBED_TRAVEL_TIME_FILTERS) {
params.append('tt', `${tt.mode}:${tt.slug}:${tt.label}:${tt.min ?? 0}:${tt.max ?? 120}`);
}
}

View file

@ -243,7 +243,7 @@ async function resolveTarget(
* against.
*/
function loadSynthIndex(storyboard: Storyboard): SynthCue[] {
const path = join(OUTPUT_DIR, 'audio', 'index.json');
const path = join(OUTPUT_DIR, storyboard.name, 'audio', 'index.json');
if (existsSync(path)) {
const raw = JSON.parse(readFileSync(path, 'utf-8')) as {
items: SynthCue[];

View file

@ -97,13 +97,97 @@ export interface Cue {
tail?: Activity[];
}
/** Recorder + encoder knobs. Set per storyboard so vertical/horizontal cuts
* can coexist without env-var juggling. */
export interface VideoConfig {
/** "16x9" → 1920x1080, "9x16" → 1080x1920. */
aspect: '16x9' | '9x16';
/** Browser deviceScaleFactor. >1 supersamples for sharper text. */
captureScale: number;
/** WebM bitrate passed to libvpx, e.g. "8M" or "18M". */
webmBitrate: string;
/** Final fps after the trim/resample pass. */
outputFps: number;
/** verify.ts duration window. */
minDurationS: number;
maxDurationS: number;
/** Timestamp (seconds, in the trimmed mp4) used to extract the homepage
* poster JPEG. Pick a frame that previews well on a paused player. */
posterTimeS: number;
}
/** Qwen3-TTS voice + language settings, sent to synth.py via the narration
* script. Per storyboard so we can ship a British male narrator on one cut
* and a different persona on another. */
export interface VoiceConfig {
/** VoiceDesign persona prompt (accent, register, anti-filler directives). */
instruct: string;
/** Qwen3-TTS language string, e.g. "English". */
language: string;
/** Sampling temperature (default 0.6). */
temperature?: number;
/** Top-p nucleus sampling (default 0.9). */
topP?: number;
/** Reproducibility seed (default 42). */
seed?: number;
}
/** Brand strings rendered by the outro card. */
export interface BrandConfig {
name: string;
tagline: string;
url: string;
}
/** Story-specific content: the AI prompt typed on camera, the stubbed AI
* response, the initial map view, and the travel-time slider tuning. The
* storyboard cues reference these via the active Storyboard rather than
* through globals so multiple storyboards can declare different prompts /
* filters / drag targets without colliding. */
export interface ContentConfig {
/** Prompt text typed into the AI box during the cold open. */
promptText: string;
/** Cold-open zoom multiplier on the AI card. */
aiZoomScale: number;
initialMapView: { lat: number; lon: number; zoom: number };
stubbedFilters: Record<string, [number, number] | string[]>;
stubbedTravelTimeFilters: TravelTimeFilter[];
travelTimeCardSelector: string;
travelTimeSliderMax: number;
travelTimeDragFromMin: number;
travelTimeDragToMin: number;
brand: BrandConfig;
}
export interface TravelTimeFilter {
mode: 'transit' | 'car' | 'bicycle' | 'walking';
slug: string;
label: string;
min?: number;
max?: number;
}
/**
* Top-level storyboard. `pre` runs once before the first cue's gapBefore;
* `post` runs once after the last cue's tail finishes. The cue list is what
* gets handed to the synth step.
*
* `name` doubles as the on-disk slug outputs go to `output/<name>/` and
* publish as `<name>.mp4` + `<name>.jpg`. Keep names URL/path-safe.
*/
export interface Storyboard {
name: string;
video: VideoConfig;
voice: VoiceConfig;
content: ContentConfig;
pre?: Activity[];
cues: Cue[];
post?: Activity[];
}
/** Convenience: derive the viewport from aspect. */
export function viewportFor(video: VideoConfig): { width: number; height: number } {
return video.aspect === '9x16'
? { width: 1080, height: 1920 }
: { width: 1920, height: 1080 };
}

View file

@ -1,31 +1,33 @@
import {
AI_ZOOM_SCALE,
BRAND_NAME,
BRAND_TAGLINE,
BRAND_URL,
PROMPT_TEXT,
TT_CARD_SELECTOR,
TT_DRAG_TO_MIN,
TT_SLIDER_MAX,
} from './config.js';
import { el, type Storyboard } from './script.js';
/**
* The demo video, top to bottom.
* The list of demo videos to render, in order.
*
* Audio is generated first (one batched Qwen call), so each cue's actual
* duration is known before recording. The runner sizes each cue's wall-time
* to the measured audio length, padding short `during` blocks with a
* trailing wait. Inter-cue spacing is controlled here via `gapBeforeMs`
* (silence in audio) plus optional `tail` activities (visual movement after
* the caption hides, before the next cue's gap).
* Each entry is a fully self-contained Storyboard: video knobs (aspect,
* bitrate, fps), voice persona (Qwen3-TTS instruct + language + sampling),
* stubbed AI response, brand strings, AND the cue list. There is no shared
* global state to ship a vertical cut, a different prompt, or a different
* voice, push another item onto this array.
*
* `name` doubles as the on-disk slug. The pipeline writes per-storyboard
* artefacts to `output/<name>/` and publishes `<name>.mp4` / `<name>.jpg`
* to the homepage. The default storyboard is named `recording` so the
* existing homepage `/video/recording.mp4` keeps working unchanged.
*
* Audio is generated first (one batched Qwen call per storyboard, using
* its own voice config), so each cue's actual duration is known before
* recording. The runner sizes each cue's wall-time to the measured audio
* length, padding short `during` blocks with a trailing wait. Inter-cue
* spacing is controlled here via `gapBeforeMs` (silence in audio) plus
* optional `tail` activities (visual movement after the caption hides,
* before the next cue's gap).
*
* Sum of `during` declared durations MUST be measured cue duration. If
* synth comes back tighter than the activities can fit, the runner throws
* with a pointer to the offending cue bump that cue's text, lengthen its
* gapBefore, or trim a during step.
*
* Reference durations (Qwen3-TTS / speaker=ryan, 2026-05-09 measured):
* Reference durations (Qwen3-TTS / British male narrator, 2026-05-09):
* cue 0 1920ms "Describe the life you want."
* cue 1 2720ms "Every matching neighbourhood, side by side."
* cue 2 2160ms "Tighten the commute to 20 minutes."
@ -34,137 +36,238 @@ import { el, type Storyboard } from './script.js';
* cue 5 1760ms "Take the shortlist into Excel."
* cue 6 4400ms "Perfect Postcode. Find where you actually want to live."
*/
export const storyboard: Storyboard = {
const PROMPT_TEXT = 'Flats or terraces <£450k, 35 min to Manchester, low crime';
const BRAND = {
name: 'Perfect Postcode',
tagline: 'Find where you actually want to live.',
url: 'https://perfect-postcode.co.uk',
};
// Cold-open zoom: how aggressively to magnify the AI box.
// 2.4 fills most of the viewport with the prompt card without blowing up text.
const AI_ZOOM_SCALE = 2.4;
// The travel-time card we'll drag manually after AI applies. The Filters
// component renders each travel-time entry with `data-filter-name="tt_${i}"`,
// and our stub only sets one entry, so it's tt_0.
const TT_CARD_SELECTOR = '[data-filter-name="tt_0"]';
const TT_SLIDER_MAX = 120;
const TT_DRAG_FROM_MIN = 35; // matches AI stub max below
const TT_DRAG_TO_MIN = 20;
// Calm British male narrator. Matches what tts/synth.py used to default to;
// kept identical so existing audio caches don't invalidate on first run.
const BRITISH_MALE_NARRATOR =
'Calm, professional middle-aged Chinese male narrator with a ' +
'strong Chinese accent. Even, measured pace; warm but ' +
'understated; product-demo register. Do not laugh, sigh, gasp, or add ' +
'filler sounds; no audible breaths between sentences.';
const DEFAULT_CUES: Storyboard['cues'] = [
// -- Scene 1: AI prompt ----------------------------------------------
// Cue 0 is short (1920ms) — caption shows alone, then typing + submit
// happen silently in the tail. The natural beat is: viewer hears the
// brief, then watches the prompt being typed.
{
text: 'Describe the life you want.',
gapBeforeMs: 0,
tail: [
{ kind: 'wait', durationMs: 140 },
{
kind: 'type',
selector: '[data-tutorial="ai-filters"] textarea',
text: PROMPT_TEXT,
durationMs: 3000,
},
{ kind: 'wait', durationMs: 140 },
{ kind: 'submitForm', formSelector: '[data-tutorial="ai-filters"] form', durationMs: 1700 },
{ kind: 'wait', durationMs: 700 },
],
},
// -- Scene 2: zoom out reveal ---------------------------------------
{
text: 'Every matching neighbourhood, side by side.',
gapBeforeMs: 400,
during: [{ kind: 'zoomReset', durationMs: 1400 }],
tail: [{ kind: 'wait', durationMs: 1200 }],
},
// -- Scene 3: travel-time slider ------------------------------------
{
text: `Tighten the commute to ${TT_DRAG_TO_MIN} minutes.`,
gapBeforeMs: 500,
during: [
{
kind: 'dragSlider',
thumbSelector: `${TT_CARD_SELECTOR} [role="slider"] >> nth=1`,
trackSelector: `${TT_CARD_SELECTOR} [data-orientation="horizontal"] >> nth=0`,
toFraction: TT_DRAG_TO_MIN / TT_SLIDER_MAX,
durationMs: 1400,
},
],
tail: [{ kind: 'wait', durationMs: 1200 }],
},
// -- Scene 4a: deep zoom into a hexagon -----------------------------
// The mapZoom barely fits (1500ms vs cue 1840ms); cursor prep happens
// earlier in this cue's during, the click + payoff dwell are in tail.
{
text: 'Drill into a single block.',
gapBeforeMs: 500,
during: [
{ kind: 'cursorScale', scale: 1.4, durationMs: 200 },
{
kind: 'mapZoom',
target: { kind: 'point', x: 1140, y: 605 },
steps: 18,
durationMs: 1500,
},
],
tail: [
// Wait for the post-zoom /api/postcodes response and a redraw
// before the click — otherwise the click can fire on a stale
// frame and miss the polygon.
{ kind: 'wait', durationMs: 1200 },
{
kind: 'click',
target: { kind: 'point', x: 1140, y: 605 },
durationMs: 700,
},
{ kind: 'cursorScale', scale: 1, durationMs: 280 },
// Linger so the climax cue lands on the right-pane reveal.
{ kind: 'wait', durationMs: 1500 },
],
},
// -- Scene 4b: right-pane payoff -----------------------------------
// 4480ms cue, no during — the camera holds on the populated right pane
// for the whole climax line. Tail dwells before the export beat.
{
text: 'Stats, listings, Street View, price history — all in one pane.',
gapBeforeMs: 0,
tail: [{ kind: 'wait', durationMs: 1200 }],
},
// -- Scene 5: export ------------------------------------------------
// 1760ms cue. zoomReset + click together fit (1700ms); 60ms padding.
{
text: 'Take the shortlist into Excel.',
gapBeforeMs: 500,
during: [
{ kind: 'zoomReset', durationMs: 900 },
{
kind: 'click',
target: el('button[title="Export to Excel"]'),
durationMs: 800,
},
],
tail: [{ kind: 'wait', durationMs: 800 }],
},
// -- Scene 6: outro -------------------------------------------------
{
text: `${BRAND.name}. ${BRAND.tagline}`,
gapBeforeMs: 600,
during: [
{
kind: 'showOutro',
brand: BRAND.name,
tagline: BRAND.tagline,
url: BRAND.url,
durationMs: 0,
},
],
tail: [{ kind: 'wait', durationMs: 1500 }],
},
];
const DEFAULT_PRE: Storyboard['pre'] = [
// Camera push-in to the AI box happens before the first caption — silent
// setup keeps the cold open from feeling rushed.
pre: [
{ kind: 'clearVignette', durationMs: 0 },
{ kind: 'wait', durationMs: 200 },
{
kind: 'zoomTo',
target: el('[data-tutorial="ai-filters"]'),
scale: AI_ZOOM_SCALE,
durationMs: 1300,
},
{ kind: 'wait', durationMs: 140 },
],
{ kind: 'clearVignette', durationMs: 0 },
{ kind: 'wait', durationMs: 200 },
{
kind: 'zoomTo',
target: el('[data-tutorial="ai-filters"]'),
scale: AI_ZOOM_SCALE,
durationMs: 1300,
},
{ kind: 'wait', durationMs: 140 },
];
cues: [
// -- Scene 1: AI prompt ----------------------------------------------
// Cue 0 is short (1920ms) — caption shows alone, then typing + submit
// happen silently in the tail. The natural beat is: viewer hears the
// brief, then watches the prompt being typed.
{
text: 'Describe the life you want.',
gapBeforeMs: 0,
tail: [
{ kind: 'wait', durationMs: 140 },
export const storyboards: Storyboard[] = [
{
name: 'recording',
video: {
aspect: '16x9',
captureScale: 1,
// 8M is enough for 1920x1080 at captureScale=1; bump to 18M when
// captureScale > 1 (supersampled) — see render.sh history if reviving
// higher-quality cuts.
webmBitrate: '8M',
outputFps: 50,
minDurationS: 10,
maxDurationS: 60,
// Right-pane inspection (~16s into the trimmed timeline) is the
// clearest paused-state preview: Manchester map, filters applied,
// right pane populated, larger narration caption visible.
posterTimeS: 16,
},
voice: {
instruct: BRITISH_MALE_NARRATOR,
language: 'English',
// Sampling pinned for cue-to-cue consistency. Lower temp/top_p make
// the decoder less likely to sample non-speech tokens (laughter,
// random noise) at the cost of slightly flatter intonation. Seed
// makes runs reproducible.
temperature: 0.6,
topP: 0.9,
seed: 42,
},
content: {
promptText: PROMPT_TEXT,
aiZoomScale: AI_ZOOM_SCALE,
// Initial map view used while we navigate. The AI scene zooms in on
// the sidebar so this only matters once we zoom out.
initialMapView: { lat: 53.4795, lon: -2.2451, zoom: 11.5 },
// Filters returned by the AI stub. Keys MUST match real feature names
// from /api/features (verified against the running server's schema).
stubbedFilters: {
'Property type': ['Flats/Maisonettes', 'Terraced'],
'Estimated current price': [175000, 450000],
'Serious crime per 1k residents (avg/yr)': [0, 55],
'Noise (dB)': [50, 68],
},
// Travel-time filters returned by the AI stub. Slug matches the real
// /api/travel-destinations?mode=transit response.
stubbedTravelTimeFilters: [
{
kind: 'type',
selector: '[data-tutorial="ai-filters"] textarea',
text: PROMPT_TEXT,
durationMs: 3000,
},
{ kind: 'wait', durationMs: 140 },
{ kind: 'submitForm', formSelector: '[data-tutorial="ai-filters"] form', durationMs: 1700 },
{ kind: 'wait', durationMs: 700 },
],
},
// -- Scene 2: zoom out reveal ---------------------------------------
{
text: 'Every matching neighbourhood, side by side.',
gapBeforeMs: 400,
during: [{ kind: 'zoomReset', durationMs: 1400 }],
tail: [{ kind: 'wait', durationMs: 1200 }],
},
// -- Scene 3: travel-time slider ------------------------------------
{
text: `Tighten the commute to ${TT_DRAG_TO_MIN} minutes.`,
gapBeforeMs: 500,
during: [
{
kind: 'dragSlider',
thumbSelector: `${TT_CARD_SELECTOR} [role="slider"] >> nth=1`,
trackSelector: `${TT_CARD_SELECTOR} [data-orientation="horizontal"] >> nth=0`,
toFraction: TT_DRAG_TO_MIN / TT_SLIDER_MAX,
durationMs: 1400,
mode: 'transit',
slug: 'manchester',
label: 'Manchester city centre',
max: TT_DRAG_FROM_MIN,
},
],
tail: [{ kind: 'wait', durationMs: 1200 }],
travelTimeCardSelector: TT_CARD_SELECTOR,
travelTimeSliderMax: TT_SLIDER_MAX,
travelTimeDragFromMin: TT_DRAG_FROM_MIN,
travelTimeDragToMin: TT_DRAG_TO_MIN,
brand: BRAND,
},
pre: DEFAULT_PRE,
cues: DEFAULT_CUES,
},
];
// -- Scene 4a: deep zoom into a hexagon -----------------------------
// The mapZoom barely fits (1500ms vs cue 1840ms); cursor prep happens
// earlier in this cue's during, the click + payoff dwell are in tail.
{
text: 'Drill into a single block.',
gapBeforeMs: 500,
during: [
{ kind: 'cursorScale', scale: 1.4, durationMs: 200 },
{
kind: 'mapZoom',
target: { kind: 'point', x: 1140, y: 605 },
steps: 18,
durationMs: 1500,
},
],
tail: [
// Wait for the post-zoom /api/postcodes response and a redraw
// before the click — otherwise the click can fire on a stale
// frame and miss the polygon.
{ kind: 'wait', durationMs: 1200 },
{
kind: 'click',
target: { kind: 'point', x: 1140, y: 605 },
durationMs: 700,
},
{ kind: 'cursorScale', scale: 1, durationMs: 280 },
// Linger so the climax cue lands on the right-pane reveal.
{ kind: 'wait', durationMs: 1500 },
],
},
// -- Scene 4b: right-pane payoff -----------------------------------
// 4480ms cue, no during — the camera holds on the populated right pane
// for the whole climax line. Tail dwells before the export beat.
{
text: 'Stats, listings, Street View, price history — all in one pane.',
gapBeforeMs: 0,
tail: [{ kind: 'wait', durationMs: 1200 }],
},
// -- Scene 5: export ------------------------------------------------
// 1760ms cue. zoomReset + click together fit (1700ms); 60ms padding.
{
text: 'Take the shortlist into Excel.',
gapBeforeMs: 500,
during: [
{ kind: 'zoomReset', durationMs: 900 },
{
kind: 'click',
target: el('button[title="Export to Excel"]'),
durationMs: 800,
},
],
tail: [{ kind: 'wait', durationMs: 800 }],
},
// -- Scene 6: outro -------------------------------------------------
{
text: `${BRAND_NAME}. ${BRAND_TAGLINE}`,
gapBeforeMs: 600,
during: [
{
kind: 'showOutro',
brand: BRAND_NAME,
tagline: BRAND_TAGLINE,
url: BRAND_URL,
durationMs: 0,
},
],
tail: [{ kind: 'wait', durationMs: 1500 }],
},
],
};
export function getStoryboard(name: string): Storyboard {
const sb = storyboards.find((s) => s.name === name);
if (!sb) {
throw new Error(
`Unknown storyboard "${name}". Known: ${storyboards.map((s) => s.name).join(', ')}`
);
}
return sb;
}

View file

@ -13,10 +13,13 @@ export type TimelineResult = RunnerResult;
* recording chrome (cursor, zoom wrapper, caption layer). Also opens the
* AI prompt textarea so the storyboard can begin typing immediately.
*/
export async function prepareTimeline(page: Page): Promise<ScriptCtx> {
export async function prepareTimeline(
page: Page,
storyboard: Storyboard
): Promise<ScriptCtx> {
const dashboard = new DashboardRecorder(page);
const initialMapVersion = dashboard.getMapDataVersion();
await page.goto(dashboardUrl(), { waitUntil: 'domcontentloaded' });
await page.goto(dashboardUrl(storyboard), { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('load', { timeout: 15000 }).catch(() => {});
await page
.locator('[data-tutorial="ai-filters"]')

View file

@ -1,6 +1,8 @@
import { execFileSync } from 'node:child_process';
import { existsSync, statSync } from 'node:fs';
import { MAX_DURATION_S, MIN_DURATION_S, OUTPUT_FPS, OUTPUT_DIR, VIDEO_SIZE } from './config.js';
import { OUTPUT_DIR } from './config.js';
import { viewportFor, type Storyboard } from './script.js';
import { getStoryboard } from './storyboard.js';
interface Probe {
streams?: {
@ -48,7 +50,7 @@ function probe(path: string): Probe {
return JSON.parse(raw) as Probe;
}
function verifyVideo(path: string) {
function verifyVideo(path: string, storyboard: Storyboard) {
if (!existsSync(path)) fail(`${path} is missing`);
if (statSync(path).size === 0) fail(`${path} is empty`);
@ -56,18 +58,23 @@ function verifyVideo(path: string) {
const stream = data.streams?.[0];
if (!stream) fail(`${path} has no video stream`);
const expectedSize = viewportFor(storyboard.video);
const { minDurationS, maxDurationS, outputFps } = storyboard.video;
const duration = Number(data.format?.duration ?? 0);
const fps = parseRate(stream.avg_frame_rate || stream.r_frame_rate);
if (stream.width !== VIDEO_SIZE.width || stream.height !== VIDEO_SIZE.height) {
fail(`${path} is ${stream.width}x${stream.height}, expected ${VIDEO_SIZE.width}x${VIDEO_SIZE.height}`);
}
if (duration < MIN_DURATION_S || duration > MAX_DURATION_S) {
if (stream.width !== expectedSize.width || stream.height !== expectedSize.height) {
fail(
`${path} duration is ${duration.toFixed(2)}s, expected ${MIN_DURATION_S}-${MAX_DURATION_S}s`
`${path} is ${stream.width}x${stream.height}, expected ${expectedSize.width}x${expectedSize.height}`
);
}
if (Math.abs(fps - OUTPUT_FPS) > 0.1) {
fail(`${path} is ${fps.toFixed(2)}fps, expected ${OUTPUT_FPS}fps`);
if (duration < minDurationS || duration > maxDurationS) {
fail(
`${path} duration is ${duration.toFixed(2)}s, expected ${minDurationS}-${maxDurationS}s`
);
}
if (Math.abs(fps - outputFps) > 0.1) {
fail(`${path} is ${fps.toFixed(2)}fps, expected ${outputFps}fps`);
}
console.log(
@ -81,8 +88,20 @@ function verifyImage(path: string) {
console.log(`[verify] ${path}: ${statSync(path).size} bytes`);
}
const videoPath = process.argv[2] ?? `${OUTPUT_DIR}/recording.mp4`;
const posterPath = process.argv[3] ?? (process.argv[2] ? undefined : `${OUTPUT_DIR}/poster.jpg`);
// Usage:
// node dist/verify.js <storyboard> [videoPath] [posterPath]
// Defaults: videoPath=output/<storyboard>/recording.mp4,
// posterPath=output/<storyboard>/poster.jpg.
// If videoPath is given but posterPath is not, the poster check is skipped.
const storyboardName = process.argv[2];
if (!storyboardName) {
fail('verify: missing <storyboard> argument (e.g. `node dist/verify.js recording`)');
}
const storyboard = getStoryboard(storyboardName);
verifyVideo(videoPath);
const videoPath = process.argv[3] ?? `${OUTPUT_DIR}/${storyboard.name}/recording.mp4`;
const posterPath =
process.argv[4] ?? (process.argv[3] ? undefined : `${OUTPUT_DIR}/${storyboard.name}/poster.jpg`);
verifyVideo(videoPath, storyboard);
if (posterPath) verifyImage(posterPath);

View file

@ -1,10 +1,12 @@
import { execSync } from 'node:child_process';
import { renameSync, statSync } from 'node:fs';
import { LEAD_IN_S, MAX_DURATION_S, OUTPUT_FPS, VIDEO_SIZE, WEBM_BITRATE } from './config.js';
import { LEAD_IN_S } from './config.js';
import { viewportFor, type Storyboard } from './script.js';
export function trimRecording(
rawPath: string,
trimmedPath: string,
storyboard: Storyboard,
times: { recordStartMs: number; sceneStartMs: number; sceneEndMs: number }
) {
const sceneSpan = (times.sceneEndMs - times.sceneStartMs) / 1000;
@ -16,22 +18,26 @@ export function trimRecording(
const wallDuration = trimEnd - trimStart;
const finalDuration = wallDuration;
if (finalDuration > MAX_DURATION_S) {
const { outputFps, webmBitrate, maxDurationS } = storyboard.video;
const viewport = viewportFor(storyboard.video);
if (finalDuration > maxDurationS) {
console.log(
`Scene output duration is ${finalDuration.toFixed(2)}s (guard ${MAX_DURATION_S.toFixed(2)}s); keeping the full take.`
`[${storyboard.name}] Scene output duration is ${finalDuration.toFixed(2)}s ` +
`(guard ${maxDurationS.toFixed(2)}s); keeping the full take.`
);
}
const filter =
`trim=start=${trimStart.toFixed(3)}:duration=${wallDuration.toFixed(3)},` +
`setpts=PTS-STARTPTS,fps=${OUTPUT_FPS},` +
`setpts=PTS-STARTPTS,fps=${outputFps},` +
`trim=duration=${finalDuration.toFixed(3)},setpts=PTS-STARTPTS`;
// Keep trimming inside the filter graph: it is frame-accurate for WebM
// without the keyframe leakage of input seeking.
execSync(
`ffmpeg -y -i "${rawPath}" -vf "${filter}" ` +
`-fps_mode cfr -r ${OUTPUT_FPS} -c:v libvpx -b:v ${WEBM_BITRATE} -deadline good -cpu-used 5 ` +
`-fps_mode cfr -r ${outputFps} -c:v libvpx -b:v ${webmBitrate} -deadline good -cpu-used 5 ` +
`"${trimmedPath}"`,
{ stdio: 'inherit' }
);
@ -44,6 +50,6 @@ export function trimRecording(
}
console.log(
`Wrote ${trimmedPath} (${finalDuration.toFixed(2)}s, scene=${sceneSpan.toFixed(2)}s, capture=${VIDEO_SIZE.width}x${VIDEO_SIZE.height})`
`[${storyboard.name}] Wrote ${trimmedPath} (${finalDuration.toFixed(2)}s, scene=${sceneSpan.toFixed(2)}s, capture=${viewport.width}x${viewport.height})`
);
}

View file

@ -1,19 +1,19 @@
"""Mux per-cue WAVs into recording.mp4 at their narration offsets.
"""Mux per-cue WAVs into one storyboard's recording.mp4 at narration offsets.
Reads two manifests:
Reads two manifests inside ``output/<storyboard>/``:
* ``output/audio/index.json`` (synth output) per-cue WAV filename + measured
* ``audio/index.json`` (synth output) per-cue WAV filename + measured
duration. Generated BEFORE recording in one batched Qwen3-TTS call.
* ``output/narration.json`` (recorder output) per-cue ``videoTimeMs`` against
* ``narration.json`` (recorder output) per-cue ``videoTimeMs`` against
the trimmed video. Generated DURING recording.
Joins them by ``cueIndex`` (index in the cue list, 1:1 between manifests),
runs ffmpeg with one ``adelay`` per cue plus a single ``amix``, copies the
video stream, and writes ``output/recording.narrated.mp4``.
video stream, and writes ``output/<storyboard>/recording.narrated.mp4``.
Run from the ``video/`` directory after recording:
uv run --project tts python tts/mux.py
uv run --project tts python tts/mux.py --storyboard recording
"""
from __future__ import annotations
@ -28,23 +28,21 @@ from pathlib import Path
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--audio-dir", type=Path, default=Path("output/audio"))
parser.add_argument(
"--narration",
type=Path,
default=Path("output/narration.json"),
help="Per-cue videoTimeMs manifest written by the recorder.",
"--storyboard",
required=True,
help="Storyboard slug (matches Storyboard.name in src/storyboard.ts).",
)
parser.add_argument("--video", type=Path, default=Path("output/recording.mp4"))
parser.add_argument(
"--out",
"--output-dir",
type=Path,
default=Path("output/recording.narrated.mp4"),
default=Path("output"),
help="Root output directory; per-storyboard files live in <root>/<storyboard>/.",
)
parser.add_argument(
"--replace",
action="store_true",
help="After muxing, atomically replace --video with --out.",
help="After muxing, atomically replace the storyboard's recording.mp4.",
)
return parser.parse_args()
@ -56,7 +54,13 @@ def main() -> int:
print("[mux] ffmpeg not on PATH", file=sys.stderr)
return 1
audio_index_path = args.audio_dir / "index.json"
storyboard_dir = args.output_dir / args.storyboard
audio_dir = storyboard_dir / "audio"
narration_path = storyboard_dir / "narration.json"
video_path = storyboard_dir / "recording.mp4"
out_path = storyboard_dir / "recording.narrated.mp4"
audio_index_path = audio_dir / "index.json"
if not audio_index_path.exists():
print(
f"[mux] {audio_index_path} not found; run tts/synth.py first",
@ -64,25 +68,25 @@ def main() -> int:
)
return 1
if not args.narration.exists():
if not narration_path.exists():
print(
f"[mux] {args.narration} not found; the recorder must run before mux",
f"[mux] {narration_path} not found; the recorder must run before mux",
file=sys.stderr,
)
return 1
if not args.video.exists():
print(f"[mux] video not found: {args.video}", file=sys.stderr)
if not video_path.exists():
print(f"[mux] video not found: {video_path}", file=sys.stderr)
return 1
audio_index = json.loads(audio_index_path.read_text())
audio_items = [it for it in audio_index.get("items", []) if it.get("wav")]
if not audio_items:
print("[mux] synth produced no cues; copying video unchanged", file=sys.stderr)
shutil.copyfile(args.video, args.out)
shutil.copyfile(video_path, out_path)
return 0
narration = json.loads(args.narration.read_text())
narration = json.loads(narration_path.read_text())
nar_cues = list(narration.get("cues", []))
if len(nar_cues) != len(audio_items):
print(
@ -130,9 +134,9 @@ def main() -> int:
+ "\n - ".join(overlaps)
)
cmd: list[str] = ["ffmpeg", "-y", "-loglevel", "warning", "-i", str(args.video)]
cmd: list[str] = ["ffmpeg", "-y", "-loglevel", "warning", "-i", str(video_path)]
for it in items:
cmd += ["-i", str(args.audio_dir / it["wav"])]
cmd += ["-i", str(audio_dir / it["wav"])]
filter_parts: list[str] = []
mix_inputs: list[str] = []
@ -168,18 +172,21 @@ def main() -> int:
"-shortest",
"-movflags",
"+faststart",
str(args.out),
str(out_path),
]
print(f"[mux] muxing {len(items)} narration cues into {args.out}", flush=True)
print(
f"[mux] [{args.storyboard}] muxing {len(items)} narration cues into {out_path}",
flush=True,
)
result = subprocess.run(cmd)
if result.returncode != 0:
print(f"[mux] ffmpeg exited {result.returncode}", file=sys.stderr)
return result.returncode
if args.replace:
args.out.replace(args.video)
print(f"[mux] replaced {args.video} with narrated copy", flush=True)
out_path.replace(video_path)
print(f"[mux] replaced {video_path} with narrated copy", flush=True)
return 0

View file

@ -1,15 +1,28 @@
"""Synthesize the full narration in ONE batched Qwen3-TTS call.
"""Synthesize one storyboard's narration in ONE batched Qwen3-TTS call.
Reads ``output/narration-script.json`` (emitted by ``dist/preflight.js``) and
runs ``Qwen3TTSModel.generate_custom_voice`` with all cue texts as a single
batched list that way every cue shares the same model state, which keeps
prosody and timbre consistent across cues. Per-cue WAVs and an index manifest
go to ``output/audio/`` for the recording step (which reads measured cue
durations) and the mux step (which drops each WAV at its videoTime).
Reads ``output/<storyboard>/narration-script.json`` (emitted by
``dist/preflight.js``) and runs ``Qwen3TTSModel.generate_voice_design`` with
all cue texts as a single batched list that way every cue shares the same
model state, which keeps prosody and timbre consistent across cues. Per-cue
WAVs and an index manifest go to ``output/<storyboard>/audio/`` for the
recording step (which reads measured cue durations) and the mux step (which
drops each WAV at its videoTime).
Voice persona, language, and sampling come from the storyboard via the
``voice`` block of the narration script. CLI flags can still override them
for ad-hoc experimentation; storyboards remain the source of truth for
production runs.
We use the VoiceDesign sibling of CustomVoice because it accepts a free-form
voice persona (British accent, narrator register, "no laughter") via the
``instruct`` parameter. CustomVoice's preset speakers are all American or
non-English, and its ``instruct`` is documented for emotion only it
ignored accent directives and bled non-speech tokens (laughter, sighs)
between cues.
Run from the ``video/`` directory:
uv run --project tts python tts/synth.py
uv run --project tts python tts/synth.py --storyboard recording
"""
from __future__ import annotations
@ -17,55 +30,78 @@ from __future__ import annotations
import argparse
import json
import os
import random
import sys
from pathlib import Path
import numpy as np
import soundfile as sf
import torch
from qwen_tts import Qwen3TTSModel
DEFAULT_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
DEFAULT_SPEAKER = "ryan"
DEFAULT_LANGUAGE = "English"
# Two checkpoints: the design model mints the reference clip in the desired
# persona; the clone model conditions every cue on that reference's x-vector.
# Neither CustomVoice nor VoiceDesign support generate_voice_clone — only the
# Base checkpoint does.
DEFAULT_DESIGN_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
DEFAULT_CLONE_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
# Fixed reference utterance used to anchor the speaker timbre. The reference
# is generated once per (model, instruct, sampling, seed) tuple and reused
# for every cue, so all narration shares the same x-vector. Two short
# sentences exercise enough phonemes for a stable embedding without bloating
# generation time.
REFERENCE_TEXT = (
"Welcome to the demonstration. This is the narrator voice you'll hear throughout the video."
)
def _safe_load_json(path: Path) -> object | None:
try:
return json.loads(path.read_text())
except (FileNotFoundError, json.JSONDecodeError):
return None
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--script",
"--storyboard",
required=True,
help="Storyboard slug (matches Storyboard.name in src/storyboard.ts).",
)
parser.add_argument(
"--output-dir",
type=Path,
default=Path("output/narration-script.json"),
help="Narration script emitted by dist/preflight.js.",
default=Path("output"),
help="Root output directory; per-storyboard files live in <root>/<storyboard>/.",
)
parser.add_argument(
"--out-dir",
"--design-model",
default=os.environ.get("TTS_DESIGN_MODEL", DEFAULT_DESIGN_MODEL),
help="Checkpoint used to mint the voice reference (VoiceDesign by default).",
)
parser.add_argument(
"--clone-model",
default=os.environ.get("TTS_CLONE_MODEL", DEFAULT_CLONE_MODEL),
help="Checkpoint used to clone the cue audio from the reference (Base by default).",
)
parser.add_argument(
"--reference-audio",
type=Path,
default=Path("output/audio"),
help="Directory to write WAV files and index.json into.",
default=(Path(os.environ["TTS_REFERENCE_AUDIO"]) if os.environ.get("TTS_REFERENCE_AUDIO") else None),
help="Path to an existing reference WAV. If set, skip VoiceDesign and clone from this.",
)
parser.add_argument(
"--model",
default=os.environ.get("TTS_MODEL", DEFAULT_MODEL),
)
parser.add_argument(
"--speaker",
default=os.environ.get("TTS_SPEAKER", DEFAULT_SPEAKER),
help="CustomVoice preset speaker name (use --list-speakers to enumerate).",
)
parser.add_argument(
"--language",
default=os.environ.get("TTS_LANGUAGE", DEFAULT_LANGUAGE),
"--reference-text",
default=os.environ.get("TTS_REFERENCE_TEXT"),
help="Transcript of --reference-audio. Required if --reference-audio is set.",
)
parser.add_argument(
"--device",
default=os.environ.get("TTS_DEVICE", "cuda:0"),
)
parser.add_argument(
"--list-speakers",
action="store_true",
help="Load the model, print available speaker names, and exit.",
)
return parser.parse_args()
@ -78,15 +114,18 @@ def load_model(model_id: str, device: str) -> Qwen3TTSModel:
def cached_index_matches(
index_path: Path,
cues: list[dict],
speaker: str,
instruct: str,
language: str,
seed: int,
temperature: float,
top_p: float,
) -> bool:
"""Return True iff index_path's cue list lines up with `cues` 1:1.
Compared fields: ``cueIndex``, ``text``, ``gapBeforeMs`` plus the synth
settings (``speaker``, ``language``). All cue WAV files must also exist
on disk. Mismatched length, reordered cues, or a missing WAV invalidate
the cache.
settings (``instruct``, ``language``, ``seed``, ``temperature``, ``top_p``).
All cue WAV files must also exist on disk. Mismatched length, reordered
cues, or a missing WAV invalidate the cache.
"""
if not index_path.exists():
return False
@ -94,7 +133,13 @@ def cached_index_matches(
cached = json.loads(index_path.read_text())
except json.JSONDecodeError:
return False
if cached.get("speaker") != speaker or cached.get("language") != language:
if cached.get("instruct") != instruct or cached.get("language") != language:
return False
if int(cached.get("seed", -1)) != seed:
return False
if float(cached.get("temperature", -1)) != temperature:
return False
if float(cached.get("topP", -1)) != top_p:
return False
cached_items = cached.get("items", [])
if len(cached_items) != len(cues):
@ -112,52 +157,179 @@ def cached_index_matches(
return True
def seed_everything(seed: int) -> None:
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
def _resolve_reference(
args: argparse.Namespace,
audio_dir: Path,
instruct: str,
language: str,
seed: int,
temperature: float,
top_p: float,
) -> tuple[Path, str]:
"""Return (ref_wav_path, ref_text) for the clone step.
If --reference-audio is supplied, validate and use it directly. Otherwise
mint one via VoiceDesign (cached on disk; cache invalidates when the
persona/sampling/seed changes). The design model is unloaded before
returning so the clone model can claim the GPU.
"""
if args.reference_audio is not None:
if not args.reference_audio.exists():
raise SystemExit(f"[synth] --reference-audio does not exist: {args.reference_audio}")
if not args.reference_text:
raise SystemExit("[synth] --reference-text is required when --reference-audio is set")
print(
f"[synth] using user-supplied reference {args.reference_audio} «{args.reference_text}»",
flush=True,
)
return args.reference_audio, args.reference_text
ref_wav_path = audio_dir / "_reference.wav"
ref_meta_path = audio_dir / "_reference.meta.json"
ref_meta = {
"model": args.design_model,
"instruct": instruct,
"language": language,
"seed": seed,
"temperature": temperature,
"topP": top_p,
"text": REFERENCE_TEXT,
}
if (
ref_wav_path.exists()
and ref_meta_path.exists()
and _safe_load_json(ref_meta_path) == ref_meta
):
print(f"[synth] reusing cached voice reference {ref_wav_path.name}", flush=True)
return ref_wav_path, REFERENCE_TEXT
print(
f"[synth] minting voice reference via VoiceDesign: «{REFERENCE_TEXT}»",
flush=True,
)
design_model = load_model(args.design_model, args.device)
seed_everything(seed)
ref_wavs, ref_sr = design_model.generate_voice_design(
text=[REFERENCE_TEXT],
language=language,
instruct=instruct,
do_sample=True,
temperature=temperature,
top_p=top_p,
)
ref_audio = ref_wavs[0]
if hasattr(ref_audio, "cpu"):
ref_audio = ref_audio.cpu().float().numpy()
sf.write(str(ref_wav_path), ref_audio, ref_sr)
ref_meta_path.write_text(json.dumps(ref_meta, indent=2))
# Free the design model before loading the clone model — both are 1.7B,
# we don't want them resident at the same time.
del design_model
if torch.cuda.is_available():
torch.cuda.empty_cache()
return ref_wav_path, REFERENCE_TEXT
def main() -> int:
args = parse_args()
if args.list_speakers:
model = load_model(args.model, args.device)
speakers = model.get_supported_speakers()
print(json.dumps(speakers, indent=2, ensure_ascii=False))
return 0
storyboard_dir = args.output_dir / args.storyboard
script_path = storyboard_dir / "narration-script.json"
audio_dir = storyboard_dir / "audio"
if not args.script.exists():
print(f"[synth] script not found: {args.script}", file=sys.stderr)
if not script_path.exists():
print(f"[synth] script not found: {script_path}", file=sys.stderr)
return 1
script = json.loads(args.script.read_text())
script = json.loads(script_path.read_text())
cues = [c for c in script.get("items", []) if c.get("text", "").strip()]
if not cues:
print("[synth] script has no cues; nothing to generate.", file=sys.stderr)
return 1
args.out_dir.mkdir(parents=True, exist_ok=True)
voice = script.get("voice")
if not voice:
print(
f"[synth] {script_path} has no `voice` block — re-run preflight.",
file=sys.stderr,
)
return 1
instruct = voice["instruct"]
language = voice["language"]
temperature = float(voice.get("temperature", 0.6))
top_p = float(voice.get("topP", 0.9))
seed = int(voice.get("seed", 42))
audio_dir.mkdir(parents=True, exist_ok=True)
# Skip generation when the existing audio matches the script — same cue
# texts and same gapBeforeMs values in the same order. Saves ~30s of GPU
# time when iterating on activity timing without changing narration.
if cached_index_matches(args.out_dir / "index.json", cues, args.speaker, args.language):
# texts and same gapBeforeMs values in the same order, AND same synth
# settings (instruct/seed/temperature/top_p). Saves ~30s of GPU time when
# iterating on activity timing without changing narration or persona.
if cached_index_matches(
audio_dir / "index.json",
cues,
instruct,
language,
seed,
temperature,
top_p,
):
print(
f"[synth] cached audio in {args.out_dir} matches the current script — skipping generation",
f"[synth] [{args.storyboard}] cached audio matches the current script — skipping generation",
flush=True,
)
return 0
model = load_model(args.model, args.device)
texts = [c["text"].strip() for c in cues]
print(f"[synth] generating {len(texts)} cues in one batched call", flush=True)
print(f"[synth] [{args.storyboard}] persona: {instruct}", flush=True)
print(
f"[synth] [{args.storyboard}] sampling: temperature={temperature} top_p={top_p} seed={seed} language={language}",
flush=True,
)
# Two-stage generation:
# 1. VoiceDesign mints a single reference clip in the target persona
# (or the user supplies one via --reference-audio).
# 2. Base + generate_voice_clone(x_vector_only_mode=True) conditions
# every cue on the reference's speaker embedding.
# Without (2), batched generation drifts timbre across cues — a persona
# prompt anchors style but not identity, so each batch item picks its
# own voice. The reference WAV is cached so subsequent runs only load
# the clone model (saves ~20s + 3.4 GB of disk download).
ref_wav_path, ref_text = _resolve_reference(
args, audio_dir, instruct, language, seed, temperature, top_p
)
print(
f"[synth] cloning {len(texts)} cues from reference (x_vector_only) — one batched call",
flush=True,
)
for i, t in enumerate(texts):
print(f"[synth] {i:2d}: {t}", flush=True)
# ONE batched call. generate_custom_voice handles text=List[str] natively
# and broadcasts the speaker/language across all items, so the entire
# narration is decoded in one model pass — same RNG state, same batch,
# consistent voice from cue to cue.
wavs, sr = model.generate_custom_voice(
clone_model = load_model(args.clone_model, args.device)
seed_everything(seed)
wavs, sr = clone_model.generate_voice_clone(
text=texts,
language=args.language,
speaker=args.speaker,
language=language,
ref_audio=str(ref_wav_path),
ref_text=ref_text,
x_vector_only_mode=True,
non_streaming_mode=True,
do_sample=True,
temperature=temperature,
top_p=top_p,
)
if len(wavs) != len(texts):
print(
@ -171,7 +343,7 @@ def main() -> int:
if hasattr(audio, "cpu"):
audio = audio.cpu().float().numpy()
wav_name = f"cue_{cue['cueIndex']:03d}.wav"
wav_path = args.out_dir / wav_name
wav_path = audio_dir / wav_name
sf.write(str(wav_path), audio, sr)
duration_ms = int(round(len(audio) * 1000 / sr))
items.append(
@ -190,15 +362,21 @@ def main() -> int:
)
out_index = {
"speaker": args.speaker,
"language": args.language,
"model": args.model,
"storyboard": args.storyboard,
"instruct": instruct,
"language": language,
"designModel": args.design_model,
"cloneModel": args.clone_model,
"referenceText": ref_text,
"seed": seed,
"temperature": temperature,
"topP": top_p,
"items": items,
}
(args.out_dir / "index.json").write_text(json.dumps(out_index, indent=2))
(audio_dir / "index.json").write_text(json.dumps(out_index, indent=2))
total_ms = sum(it["gapBeforeMs"] + it["durationMs"] for it in items)
print(
f"[synth] {len(items)} cues, {total_ms}ms of audio (incl. gaps) -> {args.out_dir}",
f"[synth] [{args.storyboard}] {len(items)} cues, {total_ms}ms of audio (incl. gaps) -> {audio_dir}",
flush=True,
)
return 0