Add video

This commit is contained in:
Andras Schmelczer 2026-05-05 22:15:29 +01:00
parent 589de0c5ac
commit 7c36cbfdd4
18 changed files with 2292 additions and 333 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

View file

@ -492,6 +492,32 @@ export function useMapData({
return liveColorRange;
}, [dataViewFeature, frozenPreviewRange, isEyePreviewingPinnedFeature, liveColorRange]);
const canResetPreviewScale = useMemo(() => {
if (
!isEyePreviewingPinnedFeature ||
!pinnedDataViewFeature ||
!liveColorRange ||
loadedDataKey !== dataRequestKey
) {
return false;
}
if (pinnedDataViewFeature.startsWith('tt_')) return true;
return features.find((f) => f.name === pinnedDataViewFeature)?.type !== 'enum';
}, [
dataRequestKey,
features,
isEyePreviewingPinnedFeature,
liveColorRange,
loadedDataKey,
pinnedDataViewFeature,
]);
const handleResetPreviewScale = useCallback(() => {
if (!canResetPreviewScale || !pinnedDataViewFeature || !liveColorRange) return;
setFrozenPreviewRange({ feature: pinnedDataViewFeature, range: liveColorRange });
}, [canResetPreviewScale, liveColorRange, pinnedDataViewFeature]);
const handleViewChange = useCallback(
({
resolution: newRes,
@ -530,6 +556,8 @@ export function useMapData({
currentView,
usePostcodeView,
colorRange,
canResetPreviewScale,
handleResetPreviewScale,
handleViewChange,
setInitialView,
licenseRequired,

View file

@ -1,4 +1,5 @@
import i18n from 'i18next';
import { details } from './details';
/**
* Feature description translations, keyed by feature name.
@ -268,6 +269,80 @@ const descriptions: Record<string, Record<string, string>> = {
'Noise (dB)': '该邮编的道路噪音水平分贝Lden',
'Max available download speed (Mbps)': '该邮编可用的最大宽带下载速度',
},
hi: {
'Property type': 'संपत्ति प्रकार: अलग, अर्ध-स्वतंत्र, कतारबद्ध, फ्लैट या अन्य',
'Leasehold/Freehold': 'बताता है कि संपत्ति लीजहोल्ड है या फ्रीहोल्ड',
'Last known price': 'Land Registry में दर्ज अंतिम बिक्री कीमत',
'Estimated current price': 'महंगाई और स्थानीय कीमत बदलाव से समायोजित मौजूदा अनुमानित मूल्य',
'Price per sqm': 'बिक्री कीमत को कुल फर्श क्षेत्र से विभाजित किया गया',
'Est. price per sqm': 'मौजूदा अनुमानित कीमत को कुल फर्श क्षेत्र से विभाजित किया गया',
'Estimated monthly rent': 'क्षेत्र का औसत निजी मासिक किराया',
'Total floor area (sqm)': 'EPC सर्वेक्षण से लिया गया आंतरिक फर्श क्षेत्र',
'Number of bedrooms & living rooms': 'EPC सर्वेक्षण के अनुसार रहने योग्य कमरों की संख्या',
'Construction year': 'EPC के अनुसार अनुमानित निर्माण वर्ष',
'Date of last transaction': 'Land Registry में दर्ज अंतिम बिक्री की तारीख',
'Former council house': 'बताता है कि संपत्ति कभी सामाजिक आवास के रूप में दर्ज थी या नहीं',
'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+ secondary schools within 2km':
'2 किमी के भीतर Ofsted Good या Outstanding सेकेंडरी स्कूल',
'Good+ primary schools within 5km': '5 किमी के भीतर Ofsted Good या Outstanding प्राइमरी स्कूल',
'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': 'स्थानीय शिक्षा गुणवत्ता स्कोर (अधिक = बेहतर)',
'Income Score (rate)': 'आय वंचना दर, उलटी की गई (अधिक = कम वंचना)',
'Employment Score (rate)': 'रोजगार वंचना दर, उलटी की गई (अधिक = कम वंचना)',
'Health Deprivation and Disability Score': 'स्वास्थ्य और विकलांगता स्कोर (अधिक = बेहतर परिणाम)',
'Living Environment Score': 'घर और बाहरी वातावरण की गुणवत्ता (अधिक = बेहतर)',
'Indoors Sub-domain Score': 'आवास गुणवत्ता और स्थिति (अधिक = बेहतर)',
'Outdoors Sub-domain Score': 'हवा की गुणवत्ता और सड़क सुरक्षा (अधिक = बेहतर)',
'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)': 'क्षेत्र में हिंसा और यौन अपराधों का सालाना औसत',
'Burglary (avg/yr)': 'क्षेत्र में सेंधमारी का सालाना औसत',
'Robbery (avg/yr)': 'क्षेत्र में लूट का सालाना औसत',
'Vehicle crime (avg/yr)': 'क्षेत्र में वाहन अपराधों का सालाना औसत',
'Anti-social behaviour (avg/yr)': 'क्षेत्र में असामाजिक व्यवहार का सालाना औसत',
'Criminal damage and arson (avg/yr)': 'क्षेत्र में आपराधिक क्षति और आगजनी का सालाना औसत',
'Other theft (avg/yr)': 'क्षेत्र में अन्य चोरी का सालाना औसत',
'Theft from the person (avg/yr)': 'क्षेत्र में व्यक्ति से चोरी का सालाना औसत',
'Shoplifting (avg/yr)': 'क्षेत्र में दुकान से चोरी का सालाना औसत',
'Bicycle theft (avg/yr)': 'क्षेत्र में साइकिल चोरी का सालाना औसत',
'Drugs (avg/yr)': 'क्षेत्र में ड्रग अपराधों का सालाना औसत',
'Possession of weapons (avg/yr)': 'क्षेत्र में हथियार रखने के अपराधों का सालाना औसत',
'Public order (avg/yr)': 'क्षेत्र में सार्वजनिक व्यवस्था अपराधों का सालाना औसत',
'Other crime (avg/yr)': 'क्षेत्र में अन्य अपराधों का सालाना औसत',
'Median age': 'स्थानीय आबादी की मीडियन आयु',
'% White': 'श्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत',
'% South Asian': 'दक्षिण एशियाई के रूप में पहचान करने वाली आबादी का प्रतिशत',
'% Black': 'अश्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत',
'% East Asian': 'पूर्वी एशियाई के रूप में पहचान करने वाली आबादी का प्रतिशत',
'% Mixed': 'मिश्रित या कई जातीय समूहों से पहचान करने वाली आबादी का प्रतिशत',
'% Other': 'अन्य जातीय समूह के रूप में पहचान करने वाली आबादी का प्रतिशत',
'Voter turnout (%)': '2024 आम चुनाव में मतदान करने वाले पंजीकृत मतदाताओं का प्रतिशत',
'% Labour': '2024 आम चुनाव में लेबर का मत-प्रतिशत',
'% Conservative': '2024 आम चुनाव में कंज़र्वेटिव का मत-प्रतिशत',
'% Liberal Democrat': '2024 आम चुनाव में लिबरल डेमोक्रेट का मत-प्रतिशत',
'% Reform UK': '2024 आम चुनाव में Reform UK का मत-प्रतिशत',
'% Green': '2024 आम चुनाव में ग्रीन पार्टी का मत-प्रतिशत',
'% 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)': 'पोस्टकोड पर उपलब्ध अधिकतम डाउनलोड स्पीड',
},
hu: {
'Property type': 'Ingatlantípus: különálló, ikerház, sorház, lakás vagy egyéb',
'Leasehold/Freehold': 'Az ingatlan bérleti jogú vagy teljes tulajdonú',
@ -377,5 +452,5 @@ export function tsDesc(featureName: string, englishFromServer: string): string {
export function tsDetail(featureName: string, englishFromServer: string): string {
const lang = i18n.language;
if (lang === 'en') return englishFromServer;
return descriptions[lang]?.[featureName] ?? englishFromServer;
return details[lang]?.[featureName] ?? englishFromServer;
}

View file

@ -1,56 +1,124 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from './locales/en';
import de from './locales/de';
import fr from './locales/fr';
import hu from './locales/hu';
import zh from './locales/zh';
export const SUPPORTED_LANGUAGES = [
{ code: 'en', label: 'English', flag: '\uD83C\uDDEC\uD83C\uDDE7' },
{ code: 'fr', label: 'Fran\u00E7ais', flag: '\uD83C\uDDEB\uD83C\uDDF7' },
{ code: 'de', label: 'Deutsch', flag: '\uD83C\uDDE9\uD83C\uDDEA' },
{ code: 'hu', label: 'Magyar', flag: '\uD83C\uDDED\uD83C\uDDFA' },
{ code: 'zh', label: '\u4E2D\u6587', flag: '\uD83C\uDDE8\uD83C\uDDF3' },
{ code: 'hi', label: '\u0939\u093F\u0928\u094D\u0926\u0940', flag: '\uD83C\uDDEE\uD83C\uDDF3' },
{ code: 'hu', label: 'Magyar', flag: '\uD83C\uDDED\uD83C\uDDFA' },
] as const;
export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]['code'];
const supportedCodes: Set<string> = new Set(SUPPORTED_LANGUAGES.map((l) => l.code));
function detectLanguage(): string {
function toSupportedLanguage(value: string): LanguageCode | null {
const lower = value.toLowerCase();
if (supportedCodes.has(lower)) return lower as LanguageCode;
const prefix = lower.split('-')[0];
if (supportedCodes.has(prefix)) return prefix as LanguageCode;
return null;
}
function getStoredLanguage(): LanguageCode | null {
try {
const stored = localStorage.getItem('language');
return stored ? toSupportedLanguage(stored) : null;
} catch {
return null;
}
}
function getBrowserLanguages(): readonly string[] {
if (typeof navigator === 'undefined') return [];
return navigator.languages?.length ? navigator.languages : [navigator.language];
}
function detectLanguage(): LanguageCode {
// 1. Explicit user choice (persisted from the language dropdown)
const stored = localStorage.getItem('language');
if (stored && supportedCodes.has(stored)) return stored;
const stored = getStoredLanguage();
if (stored) return stored;
// 2. Browser preference (navigator.languages falls back to navigator.language)
for (const tag of navigator.languages ?? [navigator.language]) {
// Match full tag first (e.g. "zh-CN" → "zh"), then just the prefix
const lower = tag.toLowerCase();
if (supportedCodes.has(lower)) return lower;
const prefix = lower.split('-')[0];
if (supportedCodes.has(prefix)) return prefix;
for (const tag of getBrowserLanguages()) {
const language = toSupportedLanguage(tag);
if (language) return language;
}
return 'en';
}
const initialLang = detectLanguage();
export const INITIAL_LANGUAGE = detectLanguage();
i18n.use(initReactI18next).init({
resources: {
type TranslationResource = Record<string, unknown>;
const localeLoaders: Record<
Exclude<LanguageCode, 'en'>,
() => Promise<{ default: TranslationResource }>
> = {
fr: () => import('./locales/fr'),
de: () => import('./locales/de'),
hu: () => import('./locales/hu'),
zh: () => import('./locales/zh'),
hi: () => import('./locales/hi'),
};
async function getLanguageResource(
code: Exclude<LanguageCode, 'en'>
): Promise<TranslationResource> {
const module = await localeLoaders[code]();
return module.default;
}
function setDocumentLanguage(code: LanguageCode) {
if (typeof document !== 'undefined') {
document.documentElement.lang = code;
}
}
export async function loadLanguage(code: LanguageCode): Promise<void> {
if (code === 'en' || i18n.hasResourceBundle(code, 'translation')) return;
const resource = await getLanguageResource(code);
i18n.addResourceBundle(code, 'translation', resource, true, true);
}
export async function changeLanguage(code: LanguageCode): Promise<void> {
await loadLanguage(code);
await i18n.changeLanguage(code);
setDocumentLanguage(code);
}
export const i18nReady = (async () => {
const resources: Record<string, { translation: TranslationResource }> = {
en: { translation: en },
fr: { translation: fr },
de: { translation: de },
hu: { translation: hu },
zh: { translation: zh },
},
lng: initialLang,
fallbackLng: 'en',
interpolation: {
escapeValue: false, // React already escapes
},
});
};
let language = INITIAL_LANGUAGE;
if (language !== 'en') {
try {
resources[language] = { translation: await getLanguageResource(language) };
} catch (error) {
console.error(`Failed to load ${language} translations`, error);
language = 'en';
}
}
await i18n.use(initReactI18next).init({
resources,
lng: language,
fallbackLng: 'en',
interpolation: {
escapeValue: false, // React already escapes
},
});
setDocumentLanguage(language);
})();
/**
* Translate a key that is computed at runtime (not a literal).

View file

@ -0,0 +1,914 @@
import type { Translations } from './en';
const hi: Translations = {
common: {
save: 'सहेजें',
cancel: 'रद्द करें',
close: 'बंद करें',
delete: 'हटाएं',
open: 'खोलें',
share: 'साझा करें',
copy: 'कॉपी करें',
copied: 'कॉपी हो गया!',
copiedToClipboard: 'क्लिपबोर्ड पर कॉपी किया गया',
loading: 'लोड हो रहा है...',
loadMore: 'और लोड करें',
remaining: '{{count}} बाकी',
search: 'खोजें',
all: 'सभी',
none: 'कोई नहीं',
viewDataSource: 'डेटा स्रोत देखें',
total: 'कुल',
min: 'मिनट',
or: 'या',
area: 'क्षेत्र',
properties: 'संपत्तियां',
postcode: 'पोस्टकोड',
noAreaSelected: 'कोई क्षेत्र चुना नहीं गया',
noAreaSelectedDesc:
'अपराध, स्कूल, कीमतें और अधिक देखने के लिए मानचित्र पर किसी भी रंगीन क्षेत्र पर क्लिक करें',
clickForDetails: 'विवरण के लिए क्लिक करें',
property: 'संपत्ति',
propertiesPlural: 'संपत्तियां',
},
header: {
appName: 'Perfect Postcode',
dashboard: 'डैशबोर्ड',
learn: 'जानें',
pricing: 'कीमतें',
inviteFriends: 'दोस्तों को आमंत्रित करें',
saved: 'सहेजे गए',
logIn: 'लॉग इन',
createAccount: 'खाता बनाएं',
sharing: 'साझा किया जा रहा है...',
exportLabel: 'निर्यात',
exporting: 'निर्यात हो रहा है...',
exportToExcel: 'Excel में निर्यात करें',
openMenu: 'मेनू खोलें',
closeMenu: 'मेनू बंद करें',
},
userMenu: {
fullAccess: 'पूर्ण एक्सेस',
demo: 'डेमो',
themeLight: 'थीम: हल्की',
themeDark: 'थीम: गहरी',
account: 'खाता',
logOut: 'लॉग आउट',
},
mobileMenu: {
menu: 'मेनू',
home: 'होम',
},
auth: {
logIn: 'लॉग इन',
createAccount: 'खाता बनाएं',
resetPassword: 'पासवर्ड रीसेट करें',
valueProp:
'खोजें सहेजें, संपत्तियों को बुकमार्क करें और अपनी जरूरतों से मेल खाने वाले क्षेत्रों की शॉर्टलिस्ट बनाएं.',
continueWithGoogle: 'Google से जारी रखें',
email: 'ईमेल',
emailPlaceholder: 'you@example.com',
password: 'पासवर्ड',
passwordPlaceholderRegister: 'कम से कम 8 अक्षर',
passwordPlaceholderLogin: 'आपका पासवर्ड',
forgotPassword: 'पासवर्ड भूल गए?',
resetSent: 'रीसेट लिंक के लिए अपना ईमेल देखें.',
pleaseWait: 'कृपया प्रतीक्षा करें...',
sendResetLink: 'रीसेट लिंक भेजें',
backToLogin: 'लॉग इन पर वापस जाएं',
},
upgrade: {
title: 'हर मेल खाने वाला पोस्टकोड खोजें',
description:
'आप अभी डेमो क्षेत्र देख रहे हैं. इंग्लैंड के हर पोस्टकोड, हर फिल्टर और हर पड़ोस का लाइफटाइम एक्सेस पाएं. एक भुगतान, हमेशा के लिए.',
free: 'मुफ्त',
freeForEarly: 'शुरुआती उपयोगकर्ताओं के लिए मुफ्त. क्रेडिट कार्ड की जरूरत नहीं.',
oneTimePayment: 'एक बार भुगतान. लाइफटाइम एक्सेस.',
redirecting: 'रीडायरेक्ट किया जा रहा है...',
claimFreeAccess: 'मुफ्त एक्सेस लें',
upgradeFor: '{{price}} में अपग्रेड करें',
registerAndUpgrade: 'रजिस्टर करें और अपग्रेड करें',
alreadyHaveAccount: 'पहले से खाता है? लॉग इन करें',
continueWithDemo: 'डेमो जारी रखें',
backToSharedArea: 'साझा क्षेत्र पर वापस जाएं',
sharedAreaDescription:
'आप एक साझा क्षेत्र देख रहे हैं. इससे आगे देखने के लिए इंग्लैंड के हर पोस्टकोड, हर फिल्टर और हर पड़ोस का लाइफटाइम एक्सेस लें.',
checkoutFailed: 'चेकआउट विफल रहा',
},
saveSearch: {
title: 'खोज सहेजें',
saved: 'खोज सहेजी गई',
savedSuccess: 'आपकी खोज सफलतापूर्वक सहेज ली गई है.',
viewSavedSearches: 'सहेजी गई खोजें देखें',
name: 'नाम',
namePlaceholder: 'मेरी खोज',
saving: 'सहेजा जा रहा है...',
},
licenseSuccess: {
title: 'आप अंदर हैं.',
subtitle: 'आपका लाइफटाइम एक्सेस अब सक्रिय है.',
description: 'पूरे इंग्लैंड में हर फीचर और हर पोस्टकोड का पूरा एक्सेस.',
startExploring: 'खोजना शुरू करें',
},
filters: {
activeFilters: 'सक्रिय फिल्टर',
addFilter: 'फिल्टर जोड़ें',
findingPerfectPostcode: 'Perfect Postcode खोजा जा रहा है',
addFiltersHint: 'अपनी शर्तों से मेल खाने वाले क्षेत्र पाने के लिए नीचे फिल्टर जोड़ें',
upgradePrompt:
'इंग्लैंड भर में अपराध, स्कूल, शोर, ब्रॉडबैंड, कीमतें और 50+ अन्य फिल्टर से मेल खाने वाले पोस्टकोड खोजें.',
oneTimeLifetime: 'एक बार भुगतान, लाइफटाइम एक्सेस.',
upgradeToFullMap: 'पूरा मानचित्र अपग्रेड करें',
chooseFilters: 'जो फिल्टर आपके लिए मायने रखते हैं उन्हें चुनें. मानचित्र तुरंत अपडेट होता है.',
searchFeatures: 'फीचर खोजें...',
noMatchingFeatures: 'कोई मेल खाता फीचर नहीं',
tryDifferentSearch: 'कोई दूसरा खोज शब्द आजमाएं',
allFeaturesActive: 'सभी फीचर सक्रिय हैं',
removeFilterHint: 'उपलब्ध फीचर देखने के लिए कोई फिल्टर हटाएं',
featureInfo: 'फीचर जानकारी',
replayTutorial: 'इंटरैक्टिव ट्यूटोरियल फिर चलाएं',
clearAll: 'सभी साफ करें',
clearAllTitle: 'सभी फिल्टर साफ करें?',
clearAllSavePrompt: 'क्या साफ करने से पहले आप अपने मौजूदा फिल्टर सहेजना चाहेंगे?',
saveAndClear: 'सहेजें और साफ करें',
clearWithoutSaving: 'बिना सहेजे साफ करें',
},
philosophy: {
intro:
'अपनी अनिवार्य जरूरतों से शुरू करें, फिर अच्छी लगने वाली चीजें जोड़ें. जैसे-जैसे आप फिल्टर जोड़ते हैं, मानचित्र संकरा होता जाता है. बचे हुए क्षेत्र आपके सबसे अच्छे मेल हैं.',
step1Title: 'बजट और मूल बातें',
step1Desc: '(कीमत सीमा, फर्श क्षेत्र, संपत्ति प्रकार)',
step2Title: 'आवागमन',
step2Desc: '(कार, साइकिल या सार्वजनिक परिवहन से कार्यस्थल तक यात्रा समय)',
step3Title: 'सुरक्षा',
step3Desc: '(अपराध दरें, शोर स्तर, जमीन की स्थिरता)',
step4Title: 'स्कूल',
step4Desc: '(नजदीकी Ofsted-rated Good या Outstanding स्कूल)',
step5Title: 'जीवनशैली',
step5Desc: '(रेस्तरां, पार्क, ब्रॉडबैंड स्पीड)',
step6Title: 'ऊर्जा',
step6Desc: '(EPC रेटिंग, इंसुलेशन, हीटिंग लागत)',
tip: 'टिप: अगर कुछ भी मेल नहीं खाता, तो एक समय में एक शर्त ढीली करें और देखें कौन सा समझौता सबसे अधिक विकल्प खोलता है.',
},
travel: {
travelTime: 'यात्रा समय ({{mode}})',
maxTime: 'अधिकतम समय',
selectDestination: 'गंतव्य चुनें...',
bestCase: 'सर्वश्रेष्ठ स्थिति',
bestCaseTitle: 'सर्वश्रेष्ठ स्थिति यात्रा समय',
bestCaseDesc:
'सबसे तेज यथार्थवादी यात्रा समय का उपयोग करता है (अगर आप प्रस्थान का समय सही रखें और अच्छे कनेक्शन मिलें). डिफॉल्ट <strong>मीडियन</strong> का उपयोग करता है, जो आपके निकलने के समय से स्वतंत्र एक सामान्य यात्रा दिखाता है.',
previewOnMap: 'मानचित्र पर पूर्वावलोकन',
stopPreviewing: 'पूर्वावलोकन रोकें',
removeTravelTime: 'यात्रा समय हटाएं',
addTravelTime: '{{mode}} यात्रा समय जोड़ें',
clearDestination: 'गंतव्य साफ करें',
typeToFilter: 'फिल्टर करने के लिए टाइप करें...',
noDestinations: 'कोई गंतव्य नहीं मिला',
modeCar: 'कार',
modeBicycle: 'साइकिल',
modeWalking: 'पैदल',
modeTransit: 'सार्वजनिक परिवहन',
modeCarDesc: 'सबसे तेज सड़क मार्ग से ड्राइव समय',
modeBicycleDesc: 'साइकिल-अनुकूल मार्गों से साइकिल समय',
modeWalkingDesc: 'पैदल रास्तों और फुटपाथों से पैदल समय',
modeTransitDesc: 'ट्रेन, ट्यूब और बस से यात्रा समय',
},
travelInfo: {
transitDesc:
' सार्वजनिक परिवहन से (बस, रेल, ट्यूब). समय सामान्य कार्यदिवस सुबह की अवधि में गणना किया जाता है.',
carDesc: ' कार से, सामान्य सड़क गति और सड़क नेटवर्क के आधार पर.',
bicycleDesc: ' साइकिल से, साइकिल-अनुकूल मार्गों का उपयोग करके.',
walkingDesc: ' पैदल, पैदल रास्तों और फुटपाथों का उपयोग करके.',
mainDesc: 'दिखाता है कि हर क्षेत्र से चुने गए गंतव्य तक पहुंचने में कितना समय लगता है',
sliderHint: 'अपना अधिकतम आवागमन समय सेट करने के लिए स्लाइडर का उपयोग करें.',
},
aiFilter: {
describeIdealArea: 'बताएं आप कहां रहना चाहते हैं',
aiSearch: 'AI खोज',
describeHint: 'बताएं आप क्या खोज रहे हैं',
placeholder: 'जैसे 2-bed £525k से कम, काम तक 45 मिनट, शांत...',
example1: '2-bed £525k से कम, काम तक 45 मिनट',
example2: '£650k से कम अच्छे स्कूलों के पास परिवारों वाले क्षेत्र',
example3: 'समझदारी वाले आवागमन के साथ ज्यादा जगह',
analysing: 'आपकी क्वेरी का विश्लेषण हो रहा है...',
searchingDestinations: 'गंतव्य खोजे जा रहे हैं...',
generatingFilters: 'फिल्टर बनाए जा रहे हैं...',
refiningResults: 'परिणाम सुधारे जा रहे हैं...',
weeklyLimitReached:
'आप साप्ताहिक AI उपयोग सीमा तक पहुंच गए हैं. यह अगले सप्ताह अपने आप रीसेट हो जाएगी.',
},
mapLegend: {
clearColourView: 'रंग दृश्य साफ करें',
resetColourScale: 'रंग स्केल रीसेट करें',
historicalMatches: 'ऐतिहासिक संपत्ति मेल',
numberOfProperties: 'संपत्तियों की संख्या',
previewing: '“{{name}}” का पूर्वावलोकन',
},
propertyCard: {
unknownAddress: 'अज्ञात पता',
unsaveProperty: 'संपत्ति को असहेजें',
saveProperty: 'संपत्ति सहेजें',
estValue: 'अनु. मूल्य:',
type: 'प्रकार:',
builtForm: 'निर्माण रूप:',
tenure: 'टेन्योर:',
floorArea: 'फर्श क्षेत्र:',
rooms: 'कमरे:',
built: 'निर्माण:',
formerCouncil: 'पूर्व काउंसिल:',
exCouncilBadge: 'पूर्व काउंसिल',
epcRating: 'EPC रेटिंग:',
epcPotential: 'EPC संभावित:',
renovations: 'नवीनीकरण',
perSqm: '/वर्ग मी',
searchPlaceholder: 'पते या पोस्टकोड से खोजें...',
propertyData: 'संपत्ति डेटा',
propertyDataDesc:
'कीमतें HM Land Registry से आती हैं (खरीदारों ने वास्तव में क्या भुगतान किया). फर्श क्षेत्र, ऊर्जा रेटिंग, निर्माण वर्ष और टेन्योर आधिकारिक EPC सर्वेक्षणों से आते हैं. दोनों स्रोत हर पोस्टकोड के अंदर पते से मिलाए जाते हैं.',
},
areaPane: {
areaStatistics: 'क्षेत्र आंकड़े',
statsFor: 'इस {{type}} की सभी संपत्तियों के आंकड़े',
matchingFilters: ' सभी सक्रिय फिल्टर से मेल खाते हुए',
filtersAffectStats:
'बाएं पैनल के फिल्टर यहां लागू होते हैं: मान, चार्ट और संपत्ति संख्या {{count}} सक्रिय फिल्टर का उपयोग करते हैं.',
noFiltersAffectStats:
'बाएं पैनल के फिल्टर इस पैनल को अपडेट करते हैं: मेल खाने वाली संपत्तियों के लिए ये मान फिर गणना करने हेतु फिल्टर जोड़ें.',
noFilteredMatches: 'इस क्षेत्र में कोई संपत्ति आपके फिल्टर से मेल नहीं खाती.',
unfilteredAreaCount:
'फिल्टर से पहले यहां {{count}} संपत्तियां हैं, इसलिए स्थान वैध है लेकिन फिल्टर से बाहर हो गया है.',
noUnfilteredAreaProperties: 'फिल्टर से पहले इस चुने हुए क्षेत्र में कोई संपत्ति नहीं मिली.',
relaxFiltersHint: 'इस क्षेत्र की संपत्तियां देखने के लिए फिल्टर ढीले करें या साफ करें.',
viewProperties: '{{count}} संपत्तियां देखें',
priceHistory: 'कीमत इतिहास',
journeysFrom: '{{label}} से यात्राएं',
to: '{{destination}} तक',
noJourneyData: 'कोई यात्रा डेटा उपलब्ध नहीं',
viewOnGoogleMaps: 'Google Maps पर देखें',
walk: 'पैदल',
cycle: 'साइकिल',
nationalAvg: 'राष्ट्रीय औसत',
},
histogramLegend: {
tealBars: 'टील बार',
tealBarsDesc: 'इस चुने गए क्षेत्र का वितरण दिखाते हैं',
greyBars: 'ग्रे बार',
greyBarsDesc: 'सभी क्षेत्रों का कुल वितरण दिखाते हैं',
dashedLine: 'डैश्ड लाइन',
dashedLineDesc: 'राष्ट्रीय औसत दर्शाती है',
},
streetView: {
title: 'Street View',
openLarge: 'Street View बड़ा खोलें',
expandedTitle: 'विस्तारित Street View',
},
poiPane: {
pois: 'POI',
pointsOfInterest: 'रुचि के स्थान',
poiDescription:
'OpenStreetMap, NaPTAN और GEOLYTIX Grocery Retail Points से स्रोतित. परिवहन स्टॉप, दुकानें, चेन सुपरमार्केट, रेस्तरां, स्वास्थ्य सेवा, अवकाश और अधिक शामिल हैं.',
searchCategories: 'श्रेणियां खोजें...',
dataSourceInfo: 'डेटा स्रोत जानकारी',
},
externalSearch: {
searchOn: '{{radius}} पर खोजें',
exact: 'सटीक',
outcodeNotRecognised: 'आउटकोड पहचाना नहीं गया',
},
locationSearch: {
placeholder: 'स्थान या पोस्टकोड खोजें...',
postcodeNotFound: 'पोस्टकोड नहीं मिला',
lookupFailed: 'लुकअप विफल रहा',
searchLabel: 'स्थान या पोस्टकोड खोजें',
locateMe: 'मेरे स्थान पर जाएं',
geolocationUnsupported: 'आपका ब्राउजर जियोलोकेशन का समर्थन नहीं करता',
geolocationFailed: 'आपका स्थान निर्धारित नहीं किया जा सका',
},
mobileDrawer: {
closeDrawer: 'ड्रॉअर बंद करें',
},
home: {
heroEyebrow: 'उन खरीदारों के लिए जो पूछते हैं “मुझे देखना कहां शुरू करना चाहिए?”',
heroTitle1: 'वे पोस्टकोड खोजें जो',
heroTitle2: 'आपकी जिंदगी से मेल खाते हैं',
heroTitle3: 'सिर्फ वे क्षेत्र नहीं जिन्हें आप पहले से जानते हैं.',
heroSubtitle:
'लंदन बरो से लेकर कम्यूटर टाउन और क्षेत्रीय शहरों तक, इंग्लैंड में एक-एक जगह रिसर्च करने के लिए बहुत अधिक स्थान हैं.',
heroDescription:
'अपना बजट, आवागमन, स्कूल, सुरक्षा, शोर, ब्रॉडबैंड और जीवनशैली की जरूरतें सेट करें. Perfect Postcode इंग्लैंड के पोस्टकोड स्कैन करता है और वे जगहें दिखाता है जो सच में मेल खाती हैं, उन क्षेत्रों सहित जिन्हें आप किसी प्रॉपर्टी पोर्टल में कभी नहीं खोजते.',
exploreTheMap: 'मेरे मेल खाते पोस्टकोड खोजें',
seeTheDifference: 'देखें यह कैसे काम करता है',
showcaseHeader: 'यह कैसे काम करता है',
showcaseContext: 'Perfect Postcode कैसे काम करता है',
showcaseStep1Tab: 'फिल्टर',
showcaseStep1Title: 'अस्पष्ट जरूरतों को सटीक खोज में बदलें',
showcaseStep1Body:
'जो मायने रखता है उसे सेट करें और देखें कि हर जरूरत कितने गलत-फिट पोस्टकोड को आपकी खोज से बाहर रखती है.',
showcaseStep1Chip1: 'शांत सड़कें',
showcaseStep1Chip2: 'शीर्ष-रेटेड प्राइमरी',
showcaseStep1Chip3: '£500k से कम',
showcaseStep1VennCenter: 'तीनों शर्तों को पूरा करने वाले पोस्टकोड',
showcaseStep2Tab: 'मिलान',
showcaseStep2Title: 'मानचित्र को वे जगहें दिखाने दें जिन्हें आप टाइप नहीं करते',
showcaseStep2Body:
'परिचित इलाकों के नामों से शुरू करने के बजाय अपनी जरूरतों से मेल के आधार पर इंग्लैंड देखें. प्रॉपर्टी पोर्टल आपकी सोच सीमित करें, उससे पहले छिपे हुए अच्छे इलाके सामने आ जाते हैं.',
showcaseStep2Region: 'ग्रेटर लंदन',
showcaseStep2Sources: 'Land Registry · ONS · Ofsted · DfT',
showcaseStep2ClustersLabel: 'मेल खाते क्लस्टर',
showcaseStep3Tab: 'जांचें',
showcaseStep3Title: 'जांचें कि कोई पोस्टकोड क्यों चुना गया',
showcaseStep3Body:
'किसी भी मेल खाते क्षेत्र को खोलें और वहां सप्ताहांत बिताने से पहले कीमतें, सुरक्षा, स्कूल, ब्रॉडबैंड और समझौते एक ही पैनल में जांचें.',
showcaseStep3HeaderArea: 'आपका परफेक्ट पोस्टकोड',
showcaseStep3HeaderFit: 'पड़ोस का प्रमाण',
showcaseStep3Stat1Label: 'बेची गई कीमत का रुझान',
showcaseStep3Stat2Label: 'अपराध दर',
showcaseStep3Stat2Value: 'बरो औसत से कम',
showcaseStep3Stat3Label: 'मीडियन आयु',
showcaseStep3Stat4Label: 'ब्रॉडबैंड',
showcaseStep3Stat4Value: '1 Gbps उपलब्ध',
showcaseStep3Stat5Label: 'प्राइमरी स्कूल',
showcaseStep3Stat5Value: '1 मील में 3 outstanding',
showcaseStep4Tab: 'स्काउट',
showcaseStep4Title: 'खुद जाकर देखें',
showcaseStep4Body:
'तीन ठोस शुरुआती बिंदुओं को वास्तविक दुनिया में ले जाएं. सड़कें चलकर देखें, आवागमन आजमाएं और संदर्भ के साथ मकानों की देखने-समझने की यात्राओं की तुलना करें.',
showcaseStep4FileName: 'areas-to-scout.xlsx',
showcaseStep4ExportLabel: 'Excel में निर्यात करें',
showcaseStep4ColPostcode: 'पोस्टकोड',
showcaseStep4ColScore: 'फिट',
showcaseStep4ColCommute: 'आवागमन',
showcaseStep4ColPrice: 'मीडियन बिक्री',
showcaseStep4Conclusion: 'आप अपनी यात्रा यहां से शुरू कर सकते हैं. अब आप भटके हुए नहीं हैं.',
statProperties: 'ऐतिहासिक बिक्री',
statFilters: 'जोड़े जा सकने वाले फिल्टर',
statEvery: 'हर',
statPostcodeInEngland: 'इंग्लैंड का पोस्टकोड',
ourPhilosophy: 'पोस्टकोड नहीं, अपनी जिंदगी से शुरू करें',
philosophyP1:
'अधिकांश संपत्ति साइटें पूछती हैं कि आप कहां रहना चाहते हैं. लंदन में यह बहुत कठिन है, लेकिन यही समस्या पूरे इंग्लैंड में आती है: खरीदार उन कुछ जगहों में से चुनते हैं जिन्हें वे जानते हैं, फिर आवागमन टूल, Ofsted, पुलिस डेटा, Street View, ब्रॉडबैंड जांच और बेचे गए दामों को अलग-अलग टैब में मिलाते हैं.',
philosophyP2:
'Perfect Postcode खोज को उलट देता है. मानचित्र को बताएं कि क्या मायने रखता है और यह वे पोस्टकोड दिखाता है जो योग्य हैं, साथ में प्रमाण भी कि वे देखने लायक क्यों हैं. पहले डेटा, फिर माहौल खुद परखें.',
streetTitle: 'जगहें सड़क-दर-सड़क बदलती हैं',
streetIntro:
'बड़े क्षेत्र नाम वे विवरण छिपा देते हैं जो मायने रखते हैं: स्टेशन किस तरफ है, सड़क का शोर, स्कूल मिश्रण, सटीक आवागमन और समान घर वास्तव में किस कीमत पर बिके.',
streetCard1Title: 'वे क्षेत्र खोजें जो आपसे छूट सकते थे',
streetCard1Body:
'परिचित नामों, दोस्तों की सिफारिशों या “उभरते इलाके” की चर्चा पर निर्भर रहने के बजाय अपनी जरूरतों से मेल खाते पोस्टकोड सामने लाएं.',
streetCard2Title: 'मकान देखने से पहले समझौते समझें',
streetCard2Body:
'सप्ताहांत मकान देखने में बिताने से पहले कीमत, जगह, आवागमन, सुरक्षा, स्कूल, ब्रॉडबैंड, शोर और ऊर्जा रेटिंग की तुलना करें.',
othersVs: 'दूसरे बनाम',
checkMyPostcode: 'प्रॉपर्टी पोर्टल',
areaGuides: 'पोस्टकोड रिपोर्ट',
compSearchWithout: 'नाम जाने बिना क्षेत्र खोजें',
compSearchWithoutSub: '(पहले जरूरतें, बाद में स्थान)',
compAreaData: 'पोस्टकोड-स्तर पड़ोस प्रमाण',
compAreaDataSub: '(अपराध, स्कूल, शोर, ब्रॉडबैंड, सुविधाएं)',
compPropertyData: 'संपत्ति-स्तर इतिहास',
compPropertyDataSub: '(बेची कीमतें, EPC, फर्श क्षेत्र, अनुमानित मूल्य)',
compFilters: '56 फिल्टर साथ काम करते हुए',
compFiltersSub: '(एक समय में केवल एक पोस्टकोड या एक लिस्टिंग नहीं)',
ctaTitle: 'कहां खरीदना है, इसका अनुमान लगाना बंद करें.',
ctaDescription:
'उन पोस्टकोड की शॉर्टलिस्ट बनाएं जो आपकी वास्तविक जिंदगी से मेल खाते हैं, फिर उन्हें खुद जांचें.',
},
pricingPage: {
title: 'बेहतर खोज क्षेत्र के साथ खरीदें',
subtitle:
'उस मानचित्र का लाइफटाइम एक्सेस जो मकान देखने की बुकिंग से पहले यह पता लगाने में मदद करता है कि कहां देखना है.',
costContext:
'खरीदार अक्सर शामें लिस्टिंग, आवागमन जांच, स्कूल रिपोर्ट, अपराध मानचित्र, Street View और बेचे गए दामों को जोड़ने में बिताते हैं. लंदन में यह लगातार होता है, लेकिन यही शोध-समस्या पूरे इंग्लैंड में दिखाई देती है. Perfect Postcode आपके सप्ताहांत, फीस और ध्यान लगाने से पहले क्षेत्र-शोध को एक मानचित्र पर रखता है.',
lessThanSurvey: 'एक survey से कम. आपके चुनावों को दिशा देने में कहीं अधिक असरदार.',
currentTier: 'मौजूदा स्तर',
firstNUsers: 'पहले {{count}} उपयोगकर्ता',
everyoneAfter: 'उसके बाद सभी',
nextNUsers: 'अगले {{count}} उपयोगकर्ता',
lifetime: '/लाइफटाइम',
spotsRemaining: '{{count}} स्थान बाकी',
spotsRemainingPlural: '{{count}} स्थान बाकी',
filled: 'भरा हुआ',
openDashboard: 'डैशबोर्ड खोलें',
getStarted: 'शुरू करें',
getStartedPrice: 'शुरू करें - {{price}}',
noCreditCard: 'क्रेडिट कार्ड की जरूरत नहीं',
soldOut: 'बिक गया',
upcoming: 'आने वाला',
failedToLoad: 'कीमतें लोड नहीं हो सकीं. कृपया बाद में फिर कोशिश करें.',
feat1: 'इंग्लैंड भर में 56 फिल्टर',
feat2: 'आपकी जरूरतों से हर पोस्टकोड खोजने योग्य',
feat3: 'असीमित मानचित्र खोज, सहेजी गई खोजें और निर्यात',
feat4: '13M ऐतिहासिक लेनदेन और कीमत संदर्भ',
feat5: 'आवागमन, स्कूल, अपराध, शोर, ब्रॉडबैंड और अधिक',
feat6: 'भविष्य के सभी डेटा अपडेट शामिल',
},
learnPage: {
faq: 'FAQ',
dataSources: 'डेटा स्रोत',
support: 'सहायता',
dataSourcesIntro:
'यह एप्लिकेशन संपत्ति कीमतों, ऊर्जा प्रदर्शन, परिवहन, जनसांख्यिकी, अपराध, पर्यावरण और अधिक को कवर करने वाले {{count}} खुले डेटासेट को जोड़ता है.',
faqIntro:
'चाहे आप पहली बार खरीदारी की खोज संकरी कर रहे हों, किसी अनजान पोस्टकोड की जांच कर रहे हों या viewing shortlist बना रहे हों, यहां बताया गया है कि Perfect Postcode आपको कहां देखना है यह तय करने में कैसे मदद करता है.',
supportIntro: 'कोई सवाल है? हमारा FAQ देखें या सीधे संपर्क करें.',
source: 'स्रोत:',
optOut: 'सार्वजनिक प्रकटीकरण से बाहर निकलें',
attribution: 'श्रेय',
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.',
attrTfl: 'TfL Open Data द्वारा संचालित.',
attrOsm: 'डेटा शामिल है:',
attrOsmContrib: '© OpenStreetMap contributors',
attrOsmLicense: 'इसके अंतर्गत उपलब्ध:',
attrOsmLicenseLink: 'Open Data Commons Open Database License (ODbL)',
dsPricePaidName: 'Price Paid Data',
dsPricePaidOrigin: 'HM Land Registry',
dsPricePaidUse: 'इंग्लैंड के लिए पूर्ण ऐतिहासिक संपत्ति बिक्री कीमतें.',
dsEpcName: 'Energy Performance Certificates (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)',
dsNsplOrigin: 'ONS / ArcGIS',
dsNsplUse:
'पोस्टकोड को coordinates और statistical area codes से जोड़ता है, जिससे सभी area-level datasets को individual properties से लिंक किया जाता है.',
dsIodName: 'English Indices of Deprivation 2025',
dsIodOrigin: 'Ministry of Housing, Communities & Local Government',
dsIodUse:
'इंग्लैंड के हर neighbourhood के लिए income, employment, education, health, crime और living environment में relative deprivation scores.',
dsEthnicityName: 'Population by Ethnicity (2021 Census)',
dsEthnicityOrigin: 'ONS',
dsEthnicityUse:
'Local authority के अनुसार ethnic group (South Asian, East Asian, Black, Mixed, White, Other) के population percentages.',
dsCrimeName: 'Street-level Crime Data',
dsCrimeOrigin: 'data.police.uk',
dsCrimeUse:
'2023 से 2025 तक सड़क-स्तर अपराध डेटा, LSOA और अपराध प्रकार (हिंसा, सेंधमारी, असामाजिक व्यवहार, ड्रग्स, वाहन अपराध आदि) के अनुसार वार्षिक औसत में समेकित.',
dsOsmName: 'OpenStreetMap POIs',
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
dsOsmUse:
'Great Britain भर में shops, restaurants, healthcare, leisure, tourism और अधिक को cover करने वाले points of interest.',
dsGeolytixRetailName: 'GEOLYTIX Grocery Retail Points',
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',
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)',
dsNaptanOrigin: 'Department for Transport',
dsNaptanUse:
'इंग्लैंड भर में rail, bus, metro/tram, ferry और airports के station और stop locations.',
dsNoiseName: 'Defra Noise Mapping',
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',
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',
dsBroadbandOrigin: 'Ofcom',
dsBroadbandUse:
'Ofcom Connected Nations 2025 से area के अनुसार fixed broadband coverage और maximum download speeds.',
dsCouncilTaxName: 'Council Tax Levels 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',
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',
dsElectionOrigin: 'UK Parliament',
dsElectionUse:
'July 2024 UK General Election के candidate-level results. Constituency level पर aggregated: voter turnout (%) और party vote shares (%). NSPL postcode lookup से parliamentary constituency code (pcon) के जरिए properties से joined.',
faqFindingTitle: 'खोज रणनीति',
faqCommuteTitle: 'यात्रा-समय रूटिंग',
faqBudgetTitle: 'अनुमानित कीमतें',
faqSafetyTitle: 'सुरक्षा और पड़ोस',
faqFamiliesTitle: 'परिवार और स्कूल',
faqEnvironmentTitle: 'पर्यावरण और जीवन गुणवत्ता',
faqDueDiligenceTitle: 'दायरा और Due Diligence',
faqPrivacyTitle: 'गोपनीयता और डेटा संरक्षण',
faqWhyTitle: 'Perfect Postcode क्यों',
faqPricingTitle: 'एक्सेस',
faqTipsTitle: 'टिप्स और ट्रिक्स',
faqFinding1Q: 'जब स्पष्ट क्षेत्र बहुत महंगे हों तो मुझे कहां देखना चाहिए?',
faqFinding1A:
'अपना बजट, संपत्ति प्रकार, फर्श क्षेत्र, आवागमन, स्कूल, अपराध, शोर, ब्रॉडबैंड, पार्क और अन्य अनिवार्य जरूरतें सेट करें. मानचित्र उन पोस्टकोड को हटाता है जो इन कसौटियों पर खरे नहीं उतरते, इसलिए उपेक्षित क्षेत्र लिस्टिंग खोजना शुरू करने से पहले सामने आ सकते हैं.',
faqFinding2Q: 'जिन जगहों को मैं अच्छी तरह नहीं जानता, वहां अच्छे पोस्टकोड कैसे खोजूं?',
faqFinding2A:
'पूरे मानचित्र को अपनी कठोर जरूरतों से फिल्टर करें, फिर बचे हुए समूहों को जांचें. आप प्रतिष्ठा पर निर्भर रहने के बजाय अनजान पोस्टकोड की आवागमन, बेचे गए दाम, स्कूल, अपराध, ब्रॉडबैंड, शोर और सुविधाओं से तुलना कर सकते हैं.',
faqFinding3Q: 'जब मेरी खोज बहुत ज्यादा या बहुत कम क्षेत्र लौटाए तो क्या करूं?',
faqFinding3A:
'कठोर सीमाओं से शुरू करें, फिर प्रति वर्ग मी कीमत, सड़क शोर, स्कूल स्कोर या आवागमन समय जैसे समझौते से मानचित्र को रंगें. अगर मानचित्र बहुत संकरा हो जाए, एक स्लाइडर ढीला करें और आप देख सकते हैं कौन सा समझौता ज्यादा विकल्प खोलता है.',
faqCommute1Q: 'यात्रा समय कैसे गणना किए जाते हैं?',
faqCommute1A:
'यात्रा समय Conveyal R5 से पहले से गणना किए गए हैं, जो परिवहन विश्लेषण में इस्तेमाल होने वाला रूटिंग इंजन है. हर समर्थित गंतव्य के लिए हम सड़क और सार्वजनिक परिवहन नेटवर्क पर पहुंचने योग्य पोस्टकोड तक मार्ग निकालते हैं, फिर कार, साइकिल, पैदल और सार्वजनिक परिवहन के लिए संक्षिप्त पोस्टकोड यात्रा-समय फाइलें रखते हैं. इससे मानचित्र हजारों पोस्टकोड को एक-एक रूट API कॉल करने के बजाय तेजी से फिल्टर कर सकता है.',
faqCommute2Q: 'Travel-time numbers के बारे में क्या जानना चाहिए?',
faqCommute2A:
'सार्वजनिक परिवहन समय सुबह के व्यस्त प्रस्थान समय, 07:30 से 08:30, का उपयोग करते हैं. डिफॉल्ट मीडियन यात्रा समय है, जो उस अवधि का सामान्य परिणाम है; best-case टॉगल सही समय पर निकलने पर 5वें पर्सेंटाइल का उपयोग करता है. ये मॉडल किए गए तुलनात्मक समय हैं, लाइव बाधा, ट्रैफिक या प्लेटफॉर्म-परिवर्तन की भविष्यवाणी नहीं.',
faqBudget1Q: 'Estimated current price algorithm कैसे काम करता है?',
faqBudget1A:
'Proprietary estimate अंतिम HM Land Registry sale price से शुरू होता है. यह repeat-sales index से कीमत को आज तक adjust करता है, जिसे postcode sector और property type के अनुसार सीखा गया है. Sparse areas को district, area, national और hedonic fallback models की ओर shrink किया जाता है, फिर spatially smooth किया जाता है. अंत में result को nearby, recently sold, same-type homes से adjusted price per sqm और EPC floor area का उपयोग करके nearest-neighbour estimate के साथ blend किया जाता है.',
faqBudget2Q: 'Last sold price के बजाय estimated current price क्यों उपयोग करें?',
faqBudget2A:
'अंतिम बिक्री कीमत कई साल या दशकों पुरानी हो सकती है, और लाइव मांग कीमतें केवल आज सूचीबद्ध घरों को कवर करती हैं. अनुमानित मौजूदा कीमत पुराने सौदों को मौजूदा बाजार के संदर्भ में रखती है ताकि आप अधिक संपत्तियों की तुलना कर सकें, अनुमानित प्रति वर्ग मी कीमत निकाल सकें और लिस्टिंग आने से पहले अच्छी वैल्यू वाले क्षेत्रों को पहचान सकें. यह छांटने के लिए अनुमान है, औपचारिक मूल्यांकन नहीं.',
faqSafety1Q: 'इस पोस्टकोड के आसपास किस तरह का अपराध आम है?',
faqSafety1A:
'पुलिस-रिकॉर्ड अपराध प्रकार के अनुसार बंटा होता है, जिसमें हिंसा, सेंधमारी, लूट, वाहन अपराध, असामाजिक व्यवहार, दुकान से चोरी, ड्रग्स और सार्वजनिक व्यवस्था शामिल हैं. आप अस्पष्ट सुरक्षा स्कोर पर निर्भर रहने के बजाय खास जोखिमों से फिल्टर कर सकते हैं.',
faqSafety2Q: 'किसी अनजान सड़क पर viewing से पहले क्या जांचना चाहिए?',
faqSafety2A:
'मकान देखने की बुकिंग से पहले अपराध, सड़क शोर, वंचना, ब्रॉडबैंड, पार्क, किराना, स्कूल और आवागमन जांचें. लिस्टिंग फोटो उपयोगी हो सकती हैं, पर सड़क कैसी है यह पहली बार उनसे नहीं पता चलना चाहिए.',
faqFamilies1Q: 'किन क्षेत्रों में schools, space, safety और commute का सही मिश्रण है?',
faqFamilies1A:
'Ofsted रेटिंग, अपराध, पार्क, आवागमन, फर्श क्षेत्र, संपत्ति प्रकार और बजट को एक मानचित्र पर साथ रखें. परिणाम अलग-अलग स्कूल, अपराध, लिस्टिंग और परिवहन खोजों के ढेर के बजाय व्यावहारिक पारिवारिक शॉर्टलिस्ट है.',
faqFamilies2Q: 'क्या यह साबित करता है कि मैं school catchment के अंदर हूं?',
faqFamilies2A:
'नहीं. हम नजदीकी स्कूल गुणवत्ता और क्षेत्र-स्तर शिक्षा डेटा दिखाते हैं, लेकिन प्रवेश सीमाएं और प्राथमिकता नियम बदल सकते हैं. Perfect Postcode को शॉर्टलिस्ट टूल मानें, फिर स्कूल या स्थानीय प्राधिकरण से कैचमेंट और प्रवेश सत्यापित करें.',
faqEnv1Q: 'Commute या broadband quality खोए बिना noisy road से कैसे बचूं?',
faqEnv1A:
'सड़क शोर से फिल्टर करें, फिर आवागमन समय, ब्रॉडबैंड स्पीड, कीमत और संपत्ति फिल्टर सक्रिय रखें. आप एक फीचर से मानचित्र को रंग सकते हैं जबकि बाकी शॉर्टलिस्ट को वास्तविक बनाए रखते हैं.',
faqEnv2Q: 'क्या आप flood risk, subsidence या survey issues दिखाते हैं?',
faqEnv2A:
'आज लाइव फिल्टर के रूप में नहीं. हम सड़क शोर, EPC, निर्माण आयु और स्थानीय पर्यावरण संकेतकों जैसे डेटा दिखाते हैं, पर बाढ़ खोज, टाइटल जांच, संरचनात्मक समस्याएं और मॉर्गेज योग्यता के लिए अभी भी सही कन्वेयंसिंग, ऋणदाता जांच और survey चाहिए.',
faqEnv3Q: 'Viewing से पहले running-cost checks क्या कर सकता हूं?',
faqEnv3A:
'आप मकान देखने से पहले EPC रेटिंग, कुल फर्श क्षेत्र, निर्माण आयु, council tax authority, ब्रॉडबैंड और शोर की छंटाई कर सकते हैं. यह आपके सटीक बिलों की भविष्यवाणी नहीं करेगा, पर साफ तौर पर गलत विकल्पों से जल्दी बचने में मदद करेगा.',
faqDueDiligence1Q: 'क्या मुझे Rightmove देखने से पहले या बाद में इसका उपयोग करना चाहिए?',
faqDueDiligence1A:
'Perfect Postcode का उपयोग प्रॉपर्टी पोर्टल से पहले और साथ-साथ करें. Rightmove, Zoopla और OnTheMarket अभी भी लाइव उपलब्धता, फोटो, एजेंट संपर्क, देखने की बुकिंग और अलर्ट जांचने की जगह हैं. Perfect Postcode आपको पहले यह समझने में मदद करता है कि किन पोस्टकोड में खोज करना सार्थक है.',
faqDueDiligence2Q: 'मैं garden, garage या layout से filter क्यों नहीं कर सकता?',
faqDueDiligence2A:
'केवल वहां जहां जानकारी संरचित आधिकारिक डेटा में मौजूद है. Perfect Postcode फर्श क्षेत्र, संपत्ति प्रकार, टेन्योर, EPC, बेचे गए दाम और स्थानीय डेटा जैसी चीजों से फिल्टर कर सकता है. बगीचे, गैरेज, दिशा, कमरों की बनावट और एजेंट की भाषा अभी भी लिस्टिंग और मकान देखने के दौरान जांचनी होगी.',
faqDueDiligence3Q: 'क्या आप listing price cuts और time on market track करते हैं?',
faqDueDiligence3A:
'अभी नहीं. Perfect Postcode लाइव लिस्टिंग फीड के बजाय आधिकारिक बेचे गए दाम, EPC, पोस्टकोड, यात्रा-समय और पड़ोस डेटा पर बना है. आप फिर भी अंतिम लेनदेन की तारीख, बिक्री इतिहास, अनुमानित मौजूदा मूल्य और प्रति वर्ग मी कीमत से अंदाजा लगा सकते हैं कि लाइव मांग कीमत ज्यादा तो नहीं लगती.',
faqDueDiligence4Q: 'Offer करने से पहले मुझे क्या verify करना चाहिए?',
faqDueDiligence4A:
'क्षेत्र और मूल्य की समझदारी से जांच के लिए Perfect Postcode का उपयोग करें, फिर सामान्य पेशेवर प्रक्रिया से लाइव लिस्टिंग विवरण, टेन्योर, लीज शर्तें, सर्विस चार्ज, योजना इतिहास, बाढ़ जोखिम, टाइटल समस्याएं, ऋणदाता आवश्यकताएं और survey निष्कर्ष सत्यापित करें.',
faqPrivacy1Q: 'क्या आप मेरे बारे में personal data store करते हैं?',
faqPrivacy1A:
'हम संपत्ति और पड़ोस डेटासेट में व्यक्तिगत डेटा नहीं रखते. ये डेटासेट पोस्टकोड और संपत्ति शोध के लिए आधिकारिक और सार्वजनिक स्रोतों से बने हैं. अगर आप खाता बनाते हैं, तो सेवा चलाने के लिए जरूरी डेटा ही रखते हैं, जैसे ईमेल पता, लाइसेंस स्थिति, newsletter पसंद, सहेजी गई खोजें, सहेजी गई संपत्तियां और Stripe द्वारा संभाले गए भुगतान पहचानकर्ता. हम खाता डेटा को UK GDPR और Data Protection Act 2018 के तहत संभालते हैं.',
faqWhy1Q: 'यह क्या दिखाता है जो listing portals आमतौर पर नहीं दिखाते?',
faqWhy1A:
'प्रॉपर्टी पोर्टल आज बिक्री पर मौजूद घरों से शुरू करते हैं. Perfect Postcode उन जगहों से शुरू करता है जो आपके जीवन और बजट से मेल खाती हैं, बेचे गए दाम, फर्श क्षेत्र, आवागमन, स्कूल, अपराध, शोर, ब्रॉडबैंड, EPC, टेन्योर और सुविधाओं के साथ, लिस्टिंग खोलने से पहले.',
faqWhy2Q: 'यह कितनी manual research बचाता है?',
faqWhy2A:
'आप कर सकते हैं, लेकिन इसका मतलब Land Registry, EPC, पुलिस, Ofsted, Ofcom, ONS, Defra, यात्रा-समय और मानचित्र डेटा को एक-एक पोस्टकोड से जोड़ना है. Perfect Postcode इन स्रोतों को पूरे इंग्लैंड में एक जगह फिल्टर करने योग्य बनाता है.',
faqWhy3Q: 'Underlying sources कितने reliable हैं?',
faqWhy3A:
'मुख्य डेटासेट HM Land Registry, EPC रिकॉर्ड, ONS, Ofsted, Ofcom, data.police.uk, Defra, Ordnance Survey और OpenStreetMap जैसे आधिकारिक या विश्वसनीय स्रोतों से आते हैं. वे शॉर्टलिस्ट और तुलना के लिए बहुत अच्छे हैं, लेकिन किसी भी खरीद निर्णय के लिए मौजूदा जांच और पेशेवर सलाह जरूरी है.',
faqPricing1Q: 'जब postcode reports free हैं तो भुगतान क्यों करें?',
faqPricing1A:
'मुफ्त पोस्टकोड टूल तब उपयोगी हैं जब आप पहले से जानते हैं कि क्या जांचना है. Perfect Postcode इंग्लैंड के हर पोस्टकोड को आपकी कसौटियों पर स्कैन करने, फिल्टर जोड़ने, समझौतों की तुलना करने, खोजें सहेजने और देखने में सप्ताहांत लगाने से पहले शॉर्टलिस्ट निर्यात करने के लिए है.',
faqPricing2Q: 'Lifetime access का क्या मतलब है?',
faqPricing2A:
'लाइफटाइम एक्सेस का मतलब है कि एक भुगतान आपके खाते को सेवा की अवधि तक paid Perfect Postcode मानचित्र का लगातार एक्सेस देता है. यह मासिक या वार्षिक सदस्यता नहीं है, और सामान्य डेटा अपडेट शामिल हैं. आप इसे इस खोज में उपयोग कर सकते हैं, बाद में लौट सकते हैं और फिर भी एक्सेस रहेगा अगर आप फिर स्थान बदलते हैं.',
faqPricing3Q: 'Free tier पर मैं क्या access कर सकता हूं?',
faqPricing3A:
'मुफ्त उपयोगकर्ता डेमो क्षेत्र (inner London, लगभग zones 1 to 2) के अंदर सभी फीचर देख सकते हैं. England के बाकी डेटा के लिए लाइफटाइम एक्सेस चाहिए.',
faqTips1Q: 'Plain English में search कैसे describe करूं?',
faqTips1A:
'कुछ ऐसा लिखें: "freehold 3-bed under £550k, 45 minutes to work, quiet, good broadband", और AI फिल्टर समझे गए मिलान वाले फिल्टर सेट करेगा. यह आपको यह भी बताएगा जब कोई मांग, जैसे बगीचे का आकार, संरचित फिल्टर के रूप में उपलब्ध नहीं है.',
faqTips2Q: 'क्या मैं search save करके बाद में वापस आ सकता हूं?',
faqTips2A:
'सेव बटन दबाएं और सब दर्ज हो जाता है: आपके फिल्टर, zoom स्तर और कौन सी डेटा लेयर रंगी हुई है. जहां छोड़ा था वहीं से शुरू करें या लिंक अपने साथी के साथ साझा करें.',
faqTips3Q: 'क्या मैं जो data देख रहा हूं उसे export कर सकता हूं?',
faqTips3A:
'मौजूदा फिल्टर की गई संपत्तियों को स्प्रेडशीट में डाउनलोड करने के लिए export बटन उपयोग करें. Export आपके सक्रिय फिल्टर का पालन करता है, इसलिए आप पोर्टल, मकान देखने, स्प्रेडशीट या साथ में खरीद रहे किसी व्यक्ति से बातचीत के लिए साफ शॉर्टलिस्ट ले जा सकते हैं.',
},
accountPage: {
emailLabel: 'ईमेल',
subscriptionLabel: 'सदस्यता',
upgrade: 'अपग्रेड',
redirecting: 'रीडायरेक्ट किया जा रहा है...',
receiveNewsletter: 'न्यूज़लेटर ईमेल प्राप्त करें',
needHelp: 'मदद चाहिए? हमें ईमेल करें',
responseTime: 'हम आमतौर पर 24 घंटे के भीतर जवाब देते हैं.',
},
savedPage: {
searches: 'खोजें',
noSavedSearches: 'अभी कोई सहेजी गई खोज नहीं',
noSavedSearchesDesc:
'अपने फिल्टर और मानचित्र दृश्य सहेजें ताकि आप ठीक वहीं से फिर शुरू कर सकें जहां छोड़ा था.',
noSavedProperties: 'अभी कोई सहेजी गई संपत्ति नहीं',
noSavedPropertiesDesc:
'खोजते समय संपत्तियां बुकमार्क करें और बिना ट्रैक खोए अपनी शॉर्टलिस्ट बनाएं.',
openPostcode: 'पोस्टकोड खोलें',
clickToRename: 'नाम बदलने के लिए क्लिक करें',
notesPlaceholder: 'अपने विचार लिखें...',
deleteSearch: 'खोज हटाएं',
deleteSearchConfirm:
'क्या आप वाकई यह सहेजी गई खोज हटाना चाहते हैं? इसे वापस नहीं किया जा सकता.',
deleteProperty: 'संपत्ति हटाएं',
deletePropertyConfirm:
'क्या आप वाकई यह सहेजी गई संपत्ति हटाना चाहते हैं? इसे वापस नहीं किया जा सकता.',
bed: 'bed',
epc: 'EPC',
},
invitesPage: {
inviteLinksLicensed: 'आमंत्रण लिंक लाइसेंस प्राप्त उपयोगकर्ताओं के लिए उपलब्ध हैं.',
inviteAdminLabel: 'दोस्तों को आमंत्रित करें (100% छूट)',
inviteReferralLabel: 'दोस्तों को आमंत्रित करें (30% छूट)',
generateFreeInvite: 'मुफ्त आमंत्रण लिंक बनाएं',
generateReferralLink: 'रेफरल लिंक बनाएं',
copyInviteLink: 'आमंत्रण लिंक कॉपी करें',
adminInvitesTitle: 'एडमिन आमंत्रण (100% छूट)',
referralInvitesTitle: 'रेफरल आमंत्रण (30% छूट)',
yourInviteLinks: 'आपके आमंत्रण लिंक',
noInvitesYet: 'अभी कोई आमंत्रण नहीं बनाया गया',
link: 'लिंक',
status: 'स्थिति',
created: 'बनाया गया',
redeemed: 'भुनाया गया',
pending: 'लंबित',
},
invitePage: {
youreInvited: 'आप आमंत्रित हैं!',
specialOffer: 'विशेष ऑफर!',
invitedByFree: '{{name}} ने आपको मुफ्त लाइफटाइम एक्सेस लेने के लिए आमंत्रित किया है.',
invitedByDiscount: '{{name}} ने लाइफटाइम एक्सेस पर 30% छूट साझा की है.',
genericFreeInvite: 'आपको मुफ्त लाइफटाइम एक्सेस लेने के लिए आमंत्रित किया गया है.',
genericDiscount: 'किसी दोस्त ने लाइफटाइम एक्सेस पर 30% छूट साझा की है.',
exploreEvery: 'वे पोस्टकोड खोजें जो आपकी जिंदगी से मेल खाते हैं',
propertyInfo: 'कीमतें, आवागमन, स्कूल, अपराध, शोर, ब्रॉडबैंड, EPC और अधिक',
invalidInvite: 'अमान्य आमंत्रण',
inviteAlreadyUsed: 'Invite पहले ही उपयोग हो चुका है',
inviteAlreadyUsedDesc: 'यह आमंत्रण लिंक पहले ही भुनाया जा चुका है.',
invalidInviteLink: 'अमान्य आमंत्रण लिंक',
invalidInviteLinkDesc: 'यह आमंत्रण लिंक अमान्य है या समाप्त हो चुका है.',
licenseActivated: 'लाइसेंस सक्रिय हो गया!',
fullAccessGranted: 'अब आपके पास Perfect Postcode का पूरा एक्सेस है.',
activating: 'सक्रिय किया जा रहा है...',
activateLicense: 'लाइसेंस सक्रिय करें',
claimDiscount: 'छूट लें',
registerToClaim: 'लेने के लिए रजिस्टर करें',
youAlreadyHaveLicense: 'आपके पास पहले से लाइसेंस है',
accountHasFullAccess: 'आपके खाते में पहले से पूरा एक्सेस है.',
failedToValidate: 'आमंत्रण लिंक सत्यापित नहीं हो सका',
},
mapPage: {
unsavedProperty: 'असहेजें',
savedProperty: 'सहेजा गया',
},
format: {
justNow: 'अभी',
minutesAgo: '{{count}}m पहले',
hoursAgo: '{{count}}h पहले',
daysAgo: '{{count}}d पहले',
nFilters: '{{count}} फिल्टर',
noFilters: 'कोई फिल्टर नहीं',
poiCategory: '{{count}} POI श्रेणी',
poiCategories: '{{count}} POI श्रेणियां',
travelDestination: '{{count}} यात्रा-समय गंतव्य',
travelDestinations: '{{count}} यात्रा-समय गंतव्य',
propertiesMatch: '{{count}} संपत्तियां मेल खाती हैं',
setFilters: '{{count}} फिल्टर सेट करें: {{list}}',
noFiltersSet: 'कोई फिल्टर सेट नहीं',
toDestination: '{{mode}} से {{label}} तक {{bounds}}',
lessThanMin: '< {{max}} मिनट',
moreThanMin: '> {{min}} मिनट',
},
tutorial: {
step1Title: 'मानचित्र को बताएं क्या मायने रखता है',
step1Content:
'अपना बजट, आवागमन सीमा, स्कूल गुणवत्ता, अपराध सीमा, शोर सहनशीलता, ब्रॉडबैंड जरूरतें या जो भी आपके लिए मायने रखता है सेट करें. केवल मेल खाते क्षेत्र रोशन रहते हैं. किसी भी फीचर से रंगने के लिए आंख वाले आइकन का उपयोग करें.',
step2Title: 'या बस वर्णन करें',
step2Content:
'साधारण भाषा में लिखें, जैसे "quiet area near good schools under £400k", और हम आपके लिए फिल्टर सेट कर देंगे.',
step3Title: 'देखें क्या उपलब्ध है',
step3Content:
'इंग्लैंड भर में पैन और जूम करें. किसी भी रंगीन क्षेत्र पर क्लिक करें और देखें यह क्यों मेल खाता है: अपराध, स्कूल, कीमतें, ब्रॉडबैंड, शोर और अधिक.',
step4Title: 'किसी स्थान पर जाएं',
step4Content: 'सीधे वहां जाने के लिए कोई भी जगह या पोस्टकोड खोजें.',
step5Title: 'विवरण में जाएं',
step5Content:
'क्षेत्रीय आंकड़े, हिस्टोग्राम और अलग-अलग संपत्ति रिकॉर्ड देखें: कीमतें, फर्श क्षेत्र, ऊर्जा रेटिंग और अधिक.',
step6Title: 'पास में क्या है?',
step6Content:
'मानचित्र पर स्कूल, दुकानें, स्टेशन, पार्क और रेस्तरां चालू करें और देखें क्या पहुंच में है.',
},
server: {
Properties: 'संपत्तियां',
Transport: 'परिवहन',
Education: 'शिक्षा',
Deprivation: 'वंचना',
Crime: 'अपराध',
Demographics: 'जनसांख्यिकी',
Politics: 'राजनीति',
Amenities: 'सुविधाएं',
'Property type': 'संपत्ति प्रकार',
'Leasehold/Freehold': 'लीजहोल्ड/फ्रीहोल्ड',
'Last known price': 'अंतिम ज्ञात कीमत',
'Estimated current price': 'अनुमानित मौजूदा कीमत',
'Price per sqm': 'प्रति वर्ग मी कीमत',
'Est. price per sqm': 'अनु. प्रति वर्ग मी कीमत',
'Estimated monthly rent': 'अनुमानित मासिक किराया',
'Total floor area (sqm)': 'कुल फर्श क्षेत्र (वर्ग मी)',
'Number of bedrooms & living rooms': 'बेडरूम और लिविंग रूम की संख्या',
'Construction year': 'निर्माण वर्ष',
'Date of last transaction': 'अंतिम लेनदेन की तारीख',
'Former council house': 'पूर्व काउंसिल घर',
'Current energy rating': 'मौजूदा ऊर्जा रेटिंग',
'Potential energy rating': 'संभावित ऊर्जा रेटिंग',
'Interior height (m)': 'भीतरी ऊंचाई (मी)',
'Distance to nearest train or tube station (km)': 'निकटतम ट्रेन या ट्यूब स्टेशन तक दूरी (किमी)',
'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 सेकेंडरी स्कूल',
'Education, Skills and Training Score': 'शिक्षा, कौशल और प्रशिक्षण स्कोर',
'Income Score (rate)': 'आय स्कोर (दर)',
'Employment Score (rate)': 'रोजगार स्कोर (दर)',
'Health Deprivation and Disability Score': 'स्वास्थ्य वंचना और विकलांगता स्कोर',
'Living Environment Score': 'रहने के वातावरण का स्कोर',
'Indoors Sub-domain Score': 'इनडोर उप-क्षेत्र स्कोर',
'Outdoors Sub-domain Score': 'आउटडोर उप-क्षेत्र स्कोर',
'Serious crime per 1k residents (avg/yr)': 'प्रति 1k निवासियों गंभीर अपराध (औसत/वर्ष)',
'Minor crime per 1k residents (avg/yr)': 'प्रति 1k निवासियों मामूली अपराध (औसत/वर्ष)',
'Serious crime (avg/yr)': 'गंभीर अपराध (औसत/वर्ष)',
'Minor crime (avg/yr)': 'मामूली अपराध (औसत/वर्ष)',
'Violence and sexual offences (avg/yr)': 'हिंसा और यौन अपराध (औसत/वर्ष)',
'Burglary (avg/yr)': 'सेंधमारी (औसत/वर्ष)',
'Robbery (avg/yr)': 'लूट (औसत/वर्ष)',
'Vehicle crime (avg/yr)': 'वाहन अपराध (औसत/वर्ष)',
'Anti-social behaviour (avg/yr)': 'असामाजिक व्यवहार (औसत/वर्ष)',
'Criminal damage and arson (avg/yr)': 'आपराधिक क्षति और आगजनी (औसत/वर्ष)',
'Other theft (avg/yr)': 'अन्य चोरी (औसत/वर्ष)',
'Theft from the person (avg/yr)': 'व्यक्ति से चोरी (औसत/वर्ष)',
'Shoplifting (avg/yr)': 'दुकान से चोरी (औसत/वर्ष)',
'Bicycle theft (avg/yr)': 'साइकिल चोरी (औसत/वर्ष)',
'Drugs (avg/yr)': 'ड्रग्स (औसत/वर्ष)',
'Possession of weapons (avg/yr)': 'हथियार रखने के अपराध (औसत/वर्ष)',
'Public order (avg/yr)': 'सार्वजनिक व्यवस्था अपराध (औसत/वर्ष)',
'Other crime (avg/yr)': 'अन्य अपराध (औसत/वर्ष)',
'Median age': 'मीडियन आयु',
'% White': '% श्वेत',
'% South Asian': '% दक्षिण एशियाई',
'% Black': '% अश्वेत',
'% East Asian': '% पूर्वी एशियाई',
'% Mixed': '% मिश्रित',
'% Other': '% अन्य',
'Voter turnout (%)': 'मतदाता भागीदारी (%)',
'% Labour': '% लेबर',
'% Conservative': '% कंज़र्वेटिव',
'% Liberal Democrat': '% लिबरल डेमोक्रेट',
'% Reform UK': '% Reform UK',
'% Green': '% ग्रीन',
'% 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)',
Detached: 'अलग मकान',
'Semi-Detached': 'अर्ध-स्वतंत्र मकान',
Terraced: 'कतारबद्ध मकान',
'Flats/Maisonettes': 'फ्लैट/मेज़ोनेट',
Other: 'अन्य',
Freehold: 'फ्रीहोल्ड',
Leasehold: 'लीजहोल्ड',
Yes: 'हां',
No: 'नहीं',
'Serious crime': 'गंभीर अपराध',
'Minor crime': 'मामूली अपराध',
'Ethnic composition': 'जातीय संरचना',
'Political vote share': 'राजनीतिक वोट शेयर',
'Public Transport': 'सार्वजनिक परिवहन',
Leisure: 'अवकाश',
Health: 'स्वास्थ्य',
'Emergency Services': 'आपातकालीन सेवाएं',
Groceries: 'किराना',
'Local Businesses': 'स्थानीय व्यवसाय',
Culture: 'संस्कृति',
Services: 'सेवाएं',
Shops: 'दुकानें',
Airport: 'हवाई अड्डा',
Ferry: 'फेरी',
'Rail station': 'रेल स्टेशन',
'Bus stop': 'बस स्टॉप',
'Bus station': 'बस स्टेशन',
'Taxi rank': 'टैक्सी स्टैंड',
'Tube station': 'ट्यूब स्टेशन',
Café: 'कैफे',
Restaurant: 'रेस्तरां',
Pub: 'पब',
Bar: 'बार',
'Fast Food': 'फास्ट फूड',
Nightclub: 'नाइटक्लब',
Cinema: 'सिनेमा',
Theatre: 'थिएटर',
'Live Music & Events': 'लाइव संगीत और कार्यक्रम',
Park: 'पार्क',
Playground: 'खेल का मैदान',
'Sports Centre': 'खेल केंद्र',
Entertainment: 'मनोरंजन',
Supermarket: 'सुपरमार्केट',
'Convenience Store': 'कन्वीनियंस स्टोर',
Bakery: 'बेकरी',
'Butcher & Fishmonger': 'कसाई और मछली विक्रेता',
Greengrocer: 'सब्जी-फल विक्रेता',
'Off-Licence': 'शराब की दुकान',
'Deli & Specialty': 'डेली और विशेष खाद्य',
'Fashion & Clothing': 'फैशन और कपड़े',
Electronics: 'इलेक्ट्रॉनिक्स',
'Charity Shop': 'चैरिटी दुकान',
'DIY & Hardware': 'DIY और हार्डवेयर',
'Home & Garden': 'घर और बगीचा',
Bookshop: 'किताबों की दुकान',
'Pet Shop': 'पालतू पशु दुकान',
'Sports & Outdoor': 'खेल और आउटडोर',
Newsagent: 'अखबार विक्रेता',
'Department Store': 'डिपार्टमेंट स्टोर',
'Gift & Hobby': 'उपहार और शौक',
'Specialist Shop': 'विशेषज्ञ दुकान',
'Hairdresser & Beauty': 'हेयरड्रेसर और सौंदर्य',
'Gym & Fitness': 'जिम और फिटनेस',
'Dry Cleaner & Laundry': 'ड्राई क्लीनर और लॉन्ड्री',
'Car Services': 'कार सेवाएं',
'Post Office': 'डाकघर',
'Vet & Pet Care': 'पशु चिकित्सक और पालतू देखभाल',
Bank: 'बैंक',
'Travel Agent': 'ट्रैवल एजेंट',
Police: 'पुलिस',
'Fire Station': 'फायर स्टेशन',
'Ambulance Station': 'एम्बुलेंस स्टेशन',
'GP Surgery': 'GP क्लिनिक',
Dentist: 'दंत चिकित्सक',
Pharmacy: 'फार्मेसी',
'Hospital & Clinic': 'अस्पताल और क्लिनिक',
Optician: 'ऑप्टिशियन',
Physiotherapy: 'फिजियोथेरेपी',
'Counselling & Therapy': 'काउंसलिंग और थेरेपी',
'Care Home': 'देखभाल गृह',
'Medical & Mobility': 'चिकित्सा और गतिशीलता उपकरण',
Museum: 'संग्रहालय',
Gallery: 'गैलरी',
Library: 'लाइब्रेरी',
'Place of Worship': 'पूजा स्थल',
'Arts Centre': 'कला केंद्र',
Zoo: 'चिड़ियाघर',
'Tourist Attraction': 'पर्यटक आकर्षण',
School: 'स्कूल',
Hotel: 'होटल',
'Local Business': 'स्थानीय व्यवसाय',
Offices: 'कार्यालय',
'EV Charging': 'EV चार्जिंग',
'Fuel Station': 'ईंधन स्टेशन',
'Community Centre': 'सामुदायिक केंद्र',
'/mo': '/माह',
'/yr': '/वर्ष',
' sqm': ' वर्ग मी',
' km': ' किमी',
' m': ' मी',
' dB': ' dB',
' years': ' वर्ष',
' rooms': ' कमरे',
},
};
export default hi;

View file

@ -23,6 +23,11 @@ html.dark {
color-scheme: dark;
}
.home-page-scroll,
.dark .home-page-scroll {
background: linear-gradient(180deg, #080d19 0%, #080d19 50%, #16a34a 50%, #16a34a 100%);
}
/* Smooth theme transitions (scoped to avoid map performance issues) */
body,
div,
@ -65,6 +70,50 @@ h3 {
}
}
.home-content-surface {
isolation: isolate;
background: linear-gradient(180deg, #f3efe8 0%, #fafaf9 36%, #eef7f3 100%);
}
.home-content-surface::before {
content: '';
position: absolute;
inset: -120vh -120vw;
z-index: 0;
pointer-events: none;
background:
linear-gradient(rgba(20, 184, 166, 0.11) 1px, transparent 1px),
linear-gradient(90deg, rgba(20, 184, 166, 0.11) 1px, transparent 1px);
background-size:
56px 56px,
56px 56px;
transform: skew(20deg, -15deg);
transform-origin: center;
}
.home-content-surface::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0));
}
.dark .home-content-surface {
background: linear-gradient(180deg, #121827 0%, #0a0e1a 42%, #10211f 100%);
}
.dark .home-content-surface::before {
background:
linear-gradient(rgba(45, 212, 191, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(45, 212, 191, 0.1) 1px, transparent 1px);
}
.dark .home-content-surface::after {
background: linear-gradient(180deg, rgba(10, 14, 26, 0.22), rgba(10, 14, 26, 0));
}
/* Fade-in animation for homepage sections */
.fade-in-section {
opacity: 0;
@ -91,76 +140,92 @@ h3 {
.showcase-progress {
animation-name: showcase-progress;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: forwards;
}
@keyframes scout-export-click {
0%,
52%,
100% {
transform: translateY(0) scale(1);
box-shadow: 0 10px 18px rgba(15, 118, 110, 0.18);
}
58% {
transform: translateY(2px) scale(0.985);
box-shadow: 0 4px 8px rgba(15, 118, 110, 0.18);
}
66% {
transform: translateY(0) scale(1.02);
box-shadow: 0 14px 24px rgba(15, 118, 110, 0.24);
}
}
@keyframes scout-export-ripple {
0%,
54%,
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.25);
}
60% {
opacity: 0.28;
transform: translate(-50%, -50%) scale(0.8);
}
78% {
opacity: 0;
transform: translate(-50%, -50%) scale(2.4);
}
}
@keyframes scout-export-check {
0%,
62%,
100% {
opacity: 0;
transform: scale(0.65);
}
70%,
90% {
opacity: 1;
transform: scale(1);
}
}
.scout-export-action {
animation: scout-export-click 3.2s ease-in-out infinite;
}
.scout-export-ripple {
position: absolute;
left: 50%;
top: 50%;
width: 7rem;
height: 7rem;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.55);
animation: scout-export-ripple 3.2s ease-out infinite;
}
.scout-export-check {
animation: scout-export-check 3.2s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.showcase-progress {
animation: none !important;
transform: scaleX(1);
}
}
/* Aurora gradient animation for pricing hero */
@keyframes aurora-1 {
0%,
100% {
transform: translate(0, 0) scale(1);
.scout-export-action,
.scout-export-ripple,
.scout-export-check {
animation: none !important;
}
33% {
transform: translate(30px, -20px) scale(1.1);
}
66% {
transform: translate(-20px, 15px) scale(0.9);
}
}
@keyframes aurora-2 {
0%,
100% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(-40px, 20px) scale(1.15);
}
66% {
transform: translate(25px, -30px) scale(0.95);
}
}
@keyframes aurora-3 {
0%,
100% {
transform: translate(0, 0) scale(1) rotate(0deg);
}
50% {
transform: translate(20px, 25px) scale(1.1) rotate(3deg);
}
}
@keyframes aurora-4 {
0%,
100% {
transform: translate(0, 0) scale(1) rotate(0deg);
}
40% {
transform: translate(-35px, -15px) scale(1.2) rotate(-2deg);
}
70% {
transform: translate(15px, 20px) scale(0.9) rotate(1deg);
}
}
@keyframes aurora-5 {
0%,
100% {
transform: translate(0, 0) scale(1);
}
30% {
transform: translate(25px, 30px) scale(1.15);
}
60% {
transform: translate(-30px, -10px) scale(0.95);
.scout-export-check {
opacity: 1;
transform: scale(1);
}
}

View file

@ -1,6 +1,6 @@
import { createRoot, hydrateRoot } from 'react-dom/client';
import App from './App';
import './i18n';
import { INITIAL_LANGUAGE, i18nReady } from './i18n';
import './index.css';
import './hooks/usePlausible';
@ -8,9 +8,21 @@ const container = document.getElementById('root');
if (!container) {
throw new Error('Root element not found');
}
const root = container;
if (container.children.length > 0) {
hydrateRoot(container, <App />);
} else {
createRoot(container).render(<App />);
function renderApp() {
const hasPrerenderedMarkup = root.children.length > 0;
if (hasPrerenderedMarkup && INITIAL_LANGUAGE === 'en') {
hydrateRoot(root, <App />);
return;
}
if (hasPrerenderedMarkup) {
root.textContent = '';
}
createRoot(root).render(<App />);
}
void i18nReady.then(renderApp);

View file

@ -110,6 +110,13 @@ export interface PlaceResult {
city?: string;
}
export interface AddressResult {
address: string;
postcode: string;
lat: number;
lon: number;
}
export interface JourneyLeg {
mode: string;
from?: string;

View file

@ -6,10 +6,11 @@
"type": "module",
"scripts": {
"build": "tsc",
"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 16 -preset slow -movflags +faststart output/recording.mp4",
"encode": "ffmpeg -y -i output/recording.webm -c:v libx264 -pix_fmt yuv420p -crf 14 -preset fast -movflags +faststart output/recording.mp4",
"render": "./render.sh"
},
"dependencies": {

View file

@ -19,10 +19,24 @@ set -euo pipefail
APP_URL="${APP_URL:-http://host.docker.internal:3001}"
PB_URL="${PB_URL:-http://host.docker.internal:8090}"
API_URL="${API_URL:-http://host.docker.internal:8001}"
PB_ADMIN_EMAIL="${PB_ADMIN_EMAIL:-admin@propertymap.local}"
PB_ADMIN_PASSWORD="${PB_ADMIN_PASSWORD:-propertymap-dev-2024}"
PB_EMAIL="${PB_EMAIL:-demo-video@local.test}"
PB_PASSWORD="${PB_PASSWORD:-DemoVideoPass123!}"
MAX_DURATION_S="${MAX_DURATION_S:-15}"
RECORD_SCALE="${RECORD_SCALE:-2}" # 2x raw capture -> real 50fps after speed-up
OUTPUT_FPS="${OUTPUT_FPS:-50}" # matches RECORD_SCALE=2 output cadence
CAPTURE_SCALE="${CAPTURE_SCALE:-1.5}" # sharper than 1x without the 2x software-GL cost
AUTH_TTL_HOURS="${AUTH_TTL_HOURS:-24}" # re-auth if auth.json older than this
# Where the homepage <video> source lives. Vite copies frontend/public/* into
# the built bundle, so updating this path is what makes the new clip appear
# on the homepage. Override if the dashboard ever moves.
PUBLISH_DIR="${PUBLISH_DIR:-../frontend/public/video}"
# When in the *output* timeline (post-speedup) to grab the poster frame.
# Right-pane inspection (~10s output) is the clearest paused-state preview:
# Manchester map, filters applied, right pane populated, larger narration
# caption visible.
POSTER_TIME_S="${POSTER_TIME_S:-8}"
FRESH_AUTH="${FORCE_AUTH:-0}"
DO_ENCODE=1
@ -107,6 +121,7 @@ fi
if [ "$need_auth" = "1" ]; then
say "Minting fresh auth.json (user: $PB_EMAIL)"
PB_URL="$PB_URL" PB_EMAIL="$PB_EMAIL" PB_PASSWORD="$PB_PASSWORD" \
PB_ADMIN_EMAIL="$PB_ADMIN_EMAIL" PB_ADMIN_PASSWORD="$PB_ADMIN_PASSWORD" \
APP_URL="$APP_URL" \
node dist/auth.js
else
@ -119,7 +134,9 @@ 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
APP_URL="$APP_URL" MAX_DURATION_S="$MAX_DURATION_S" node dist/record.js
APP_URL="$APP_URL" MAX_DURATION_S="$MAX_DURATION_S" CAPTURE_SCALE="$CAPTURE_SCALE" \
RECORD_SCALE="$RECORD_SCALE" OUTPUT_FPS="$OUTPUT_FPS" \
node dist/record.js
if [ ! -s output/recording.webm ]; then
fail "recording.webm missing or empty"
@ -132,19 +149,54 @@ if [ "$DO_ENCODE" = "1" ]; then
fi
say "Encoding to MP4"
ffmpeg -y -loglevel warning -i output/recording.webm \
-c:v libx264 -pix_fmt yuv420p -crf 18 -movflags +faststart \
-c:v libx264 -pix_fmt yuv420p -crf 14 -preset fast \
-movflags +faststart \
output/recording.mp4
# 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
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"
fi
say "Publishing to $PUBLISH_DIR"
cp output/recording.mp4 "$PUBLISH_DIR/recording.mp4"
cp output/poster.jpg "$PUBLISH_DIR/poster.jpg"
fi
# -- report -------------------------------------------------------------------
say "Done"
if command -v ffprobe >/dev/null 2>&1; then
for f in output/recording.webm output/recording.mp4; do
for f in output/recording.webm output/recording.mp4 output/poster.jpg \
"$PUBLISH_DIR/recording.mp4" "$PUBLISH_DIR/poster.jpg"; do
[ -f "$f" ] || continue
dur=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$f")
size=$(stat -c '%s' "$f" 2>/dev/null || stat -f '%z' "$f")
printf ' %s %ss %s bytes\n' "$f" "$(printf '%.2f' "$dur")" "$size"
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
else
ls -la output/recording.* 2>/dev/null || true
ls -la output/recording.* output/poster.jpg \
"$PUBLISH_DIR/recording.mp4" "$PUBLISH_DIR/poster.jpg" 2>/dev/null || true
fi

View file

@ -1,6 +1,6 @@
import { chromium } from 'playwright';
import { writeFileSync } from 'node:fs';
import { APP_URL, AUTH_STATE_PATH } from './config.js';
import { ensureRecorderAdminUser } from './pb-admin.js';
/**
* Auth setup. Two modes:
@ -26,6 +26,10 @@ async function programmatic() {
const email = process.env.PB_EMAIL!;
const password = process.env.PB_PASSWORD!;
if (process.env.PB_BOOTSTRAP_ADMIN !== '0') {
await ensureRecorderAdminUser();
}
// Driving the login through the app itself ensures the PocketBase SDK's
// LocalAuthStore sees the token via its own write path. Hand-writing
// localStorage["pb_auth"] sometimes races with the SDK's module-time read.

View file

@ -7,44 +7,98 @@ export const OUTPUT_DIR = 'output';
const aspect = process.env.ASPECT ?? '16x9';
export const VIEWPORT =
aspect === '9x16' ? { width: 1080, height: 1920 } : { width: 1920, height: 1080 };
export const CAPTURE_SCALE = Math.max(1, Number(process.env.CAPTURE_SCALE ?? 1.5));
export const VIDEO_SIZE = {
width: VIEWPORT.width,
height: VIEWPORT.height,
};
export const WEBM_BITRATE = process.env.WEBM_BITRATE ?? (CAPTURE_SCALE > 1 ? '18M' : '8M');
// Cold-open prompt. Punchy version of the user's intent — short enough that
// the typing animation fits in the AI scene without throttling pushing past
// the trim window. Each char costs ~80ms wall under boot CPU load.
export const PROMPT_TEXT =
process.env.PROMPT_TEXT ?? 'Near Kings Cross, EPC C+, under £600k';
process.env.PROMPT_TEXT ?? 'Flats or terraces <£450k, 35 min to Manchester, low crime';
// Filter the AI stub will "return". Keys must match real feature names from
// /api/features. Pulled from the running server's schema.
// 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[]> = {
'Estimated current price': [0, 600000],
'Number of bedrooms & living rooms': [4, 6],
'Property type': ['Detached', 'Semi-Detached', 'Terraced'],
'Distance to nearest train or tube station (km)': [0, 1.0],
'Property type': ['Flats/Maisonettes', 'Terraced'],
'Estimated current price': [175000, 450000],
'Serious crime per 1k residents (avg/yr)': [0, 55],
'Noise (dB)': [50, 68],
};
// Slider we'll drag in scene 3. Must be a numeric (range) feature, and must
// already be in STUBBED_FILTERS so the card is mounted by the time we drag.
export const DRAG_FILTER_NAME =
process.env.DRAG_FILTER_NAME ?? 'Estimated current price';
// Fraction of the track to drag the right thumb to (0..1 from the left).
export const DRAG_TO_FRACTION = 0.55;
// 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,
},
];
// London-ish view used for the cold open.
export const COLD_OPEN_VIEW = '#lat=51.535&lon=-0.105&zoom=11';
// 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_MIN = 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;
// Hard cap on the trimmed output. Scene-time overhead (CDP roundtrips,
// boundingBox calls, layout settling) varies run-to-run, so we trim to a
// deterministic length even if total scene wall time exceeds it.
// 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 = Number(process.env.AI_ZOOM_SCALE ?? 2.4);
// Cluster scene: how many wheel ticks (deck.gl smooths each one) and the
// per-tick delay. ~5 ticks at -120 each gets us +2 zoom levels.
export const CLUSTER_ZOOM_TICKS = 5;
export const CLUSTER_ZOOM_DELTA = -120;
export const CLUSTER_ZOOM_TICK_MS = 90;
// 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,
};
// Postcode pre-selected on page load. The dashboard reads ?pc= and:
// 1. fetches /api/postcode/{pc}
// 2. mapFlyToRef → zoom 16 over the postcode
// 3. handleLocationSearch → opens the right pane populated with that postcode
// We use this to guarantee the right pane is open by the time the cluster
// scene plays. The visual cursor click is then ceremonial — pane is real,
// data is real, only the causation is staged.
//
// M44FZ is in Ancoats/Northern Quarter: central enough to read as Manchester,
// and it still has matching properties after the commute is tightened to 20m.
export const PRELOAD_POSTCODE = process.env.PRELOAD_POSTCODE ?? 'M44FZ';
// Hard cap on the trimmed output. Keep the homepage demo tight; the render
// trims from the outro if a dev-server hiccup stretches a scene.
export const MAX_DURATION_S = Number(process.env.MAX_DURATION_S ?? 15);
// Slow down all interactions and animations by this factor while recording,
// then speed the output back up by the same factor in ffmpeg. The visible
// animation speed in the final video is unchanged, but each visual frame had
// N× more wall time to render → fewer dropped frames, smoother motion.
//
// 1 = no slow-down (choppy on software GL)
// 2 = double recording length, ~2× more unique frames in output (recommended)
// 3-4 = even smoother, slower to produce; diminishing returns past 4
export const RECORD_SCALE = Math.max(1, Number(process.env.RECORD_SCALE ?? 3));
// Slow down all interactions while recording, then speed the output back up
// in ffmpeg. 2x gives a real 50fps final video from Playwright's 25fps raw
// recorder without making the take painfully long.
export const RECORD_SCALE = Math.max(1, Number(process.env.RECORD_SCALE ?? 2));
// Target fps of the FINAL output. We force ffmpeg to interpolate up to this
// rate so the speed-up doesn't leave gaps.
export const OUTPUT_FPS = Number(process.env.OUTPUT_FPS ?? 60);
// Target fps of the FINAL output. With RECORD_SCALE=2 this matches the real
// captured frame cadence, so the MP4 does not need synthetic interpolation.
export const OUTPUT_FPS = Number(process.env.OUTPUT_FPS ?? 50);
// 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

@ -43,6 +43,20 @@ export async function installCursor(page: Page): Promise<void> {
0% { width: 0; height: 0; opacity: 1; }
100% { width: 64px; height: 64px; opacity: 0; }
}
.__demo-focus-pulse {
position: fixed;
pointer-events: none;
z-index: 2147483644;
border: 2px solid rgba(94, 234, 212, 0.95);
border-radius: 10px;
box-shadow: 0 0 0 0 rgba(20, 184, 166, 0.45), 0 18px 44px rgba(15, 23, 42, 0.35);
animation: __demo-focus-pulse 900ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
@keyframes __demo-focus-pulse {
0% { opacity: 0; transform: scale(0.92); box-shadow: 0 0 0 0 rgba(20, 184, 166, 0.45); }
20% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(1.15); box-shadow: 0 0 0 22px rgba(20, 184, 166, 0); }
}
#__demo-vignette {
position: fixed; inset: 0;
@ -57,22 +71,25 @@ export async function installCursor(page: Page): Promise<void> {
#__demo-caption {
position: fixed;
left: 50%;
bottom: 7%;
transform: translate(-50%, 24px);
padding: 14px 22px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.78);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
bottom: 6.5%;
transform: translate(-50%, 28px);
width: max-content;
max-width: min(1160px, 78vw);
padding: 18px 28px;
border-radius: 18px;
background: rgba(15, 23, 42, 0.84);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
color: #f0fdfa;
font: 500 22px/1.2 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
letter-spacing: 0.01em;
box-shadow: 0 14px 40px rgba(0,0,0,0.35), inset 0 0 0 1px rgba(255,255,255,0.08);
font: 650 32px/1.25 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
letter-spacing: 0;
text-align: center;
box-shadow: 0 18px 54px rgba(0,0,0,0.38), inset 0 0 0 1px rgba(255,255,255,0.1);
z-index: 2147483641;
opacity: 0;
pointer-events: none;
transition: opacity 320ms ease-out, transform 320ms cubic-bezier(0.22, 1, 0.36, 1);
white-space: nowrap;
white-space: normal;
}
#__demo-caption.visible { opacity: 1; transform: translate(-50%, 0); }
@ -84,26 +101,54 @@ export async function installCursor(page: Page): Promise<void> {
pointer-events: none;
transition: background 700ms ease-out;
}
#__demo-outro.visible { background: rgba(2, 6, 23, 0.78); backdrop-filter: blur(8px); }
#__demo-outro .card {
#__demo-outro.visible {
background:
radial-gradient(circle at 50% 38%, rgba(20, 184, 166, 0.28), transparent 34%),
rgba(2, 6, 23, 0.84);
backdrop-filter: blur(10px);
}
#__demo-outro-card {
text-align: center;
color: white;
opacity: 0;
transform: translateY(12px) scale(0.98);
transition: opacity 700ms ease-out 120ms, transform 700ms cubic-bezier(0.22,1,0.36,1) 120ms;
opacity: 1;
transform: translateY(0) scale(1);
position: relative;
z-index: 1;
display: block !important;
visibility: visible !important;
animation: __demo-outro-pop 520ms cubic-bezier(0.22,1,0.36,1) both;
}
#__demo-outro.visible .card { opacity: 1; transform: translateY(0) scale(1); }
#__demo-outro h1 {
font: 700 64px/1.05 ui-sans-serif, system-ui, sans-serif;
margin: 0 0 12px;
@keyframes __demo-outro-pop {
0% { transform: translateY(10px) scale(0.985); }
100% { transform: translateY(0) scale(1); }
}
#__demo-outro-brand {
font: 760 72px/1.05 ui-sans-serif, system-ui, sans-serif;
margin: 0 0 16px;
background: linear-gradient(90deg, #5eead4, #14b8a6);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
letter-spacing: -0.02em;
letter-spacing: 0;
}
#__demo-outro-tagline {
font: 500 28px/1.4 ui-sans-serif, system-ui, sans-serif;
color: #cbd5e1;
margin: 0 0 28px;
}
#__demo-outro-url {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 16px 24px;
border-radius: 16px;
background: rgba(15, 23, 42, 0.72);
border: 1px solid rgba(94, 234, 212, 0.36);
box-shadow: 0 22px 60px rgba(0,0,0,0.32);
font: 700 34px/1 ui-sans-serif, system-ui, sans-serif;
letter-spacing: 0;
color: #99f6e4;
}
#__demo-outro p { font: 400 24px/1.4 ui-sans-serif, system-ui, sans-serif; color: #cbd5e1; margin: 0 0 18px; }
#__demo-outro .url { font: 600 22px/1 ui-sans-serif, system-ui, sans-serif; color: #5eead4; }
`,
});
@ -175,6 +220,22 @@ export async function hideCaption(page: Page): Promise<void> {
});
}
export async function flashRect(
page: Page,
rect: { x: number; y: number; width: number; height: number }
): Promise<void> {
await page.evaluate((r) => {
const el = document.createElement('div');
el.className = '__demo-focus-pulse';
el.style.left = `${r.x - 6}px`;
el.style.top = `${r.y - 6}px`;
el.style.width = `${r.width + 12}px`;
el.style.height = `${r.height + 12}px`;
document.body.appendChild(el);
setTimeout(() => el.remove(), 950);
}, rect);
}
export async function showOutro(
page: Page,
brand: string,
@ -183,19 +244,206 @@ export async function showOutro(
): Promise<void> {
await page.evaluate(
({ brand, tagline, url }) => {
document.getElementById('__demo-caption')?.classList.remove('visible');
const el = document.createElement('div');
el.id = '__demo-outro';
el.className = 'visible';
el.innerHTML = `
<div class="card">
<h1>${brand}</h1>
<p>${tagline}</p>
<div class="url">${url}</div>
<div id="__demo-outro-card">
<div id="__demo-outro-brand">${brand}</div>
<div id="__demo-outro-tagline">${tagline}</div>
<div id="__demo-outro-url">${url}</div>
</div>`;
document.body.appendChild(el);
// Force reflow so the transition fires.
void el.offsetHeight;
el.classList.add('visible');
},
{ brand, tagline, url }
);
}
/**
* Wrap #root in a transformable div so we can CSS-zoom the entire app
* without dragging the cursor/caption/outro overlays along with it.
*
* Why a wrapper and not <body>: a transformed ancestor establishes a new
* containing block for `position: fixed` descendants meaning fixed
* overlays inside the transform get scaled too. By wrapping ONLY #root
* and leaving the overlays as siblings of the wrapper, the cursor stays
* at native size while the dashboard zooms behind it.
*/
export async function installZoomWrapper(page: Page): Promise<void> {
await page.addStyleTag({
content: `
html, body { background: #111827 !important; }
#__demo-backdrop {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background:
radial-gradient(circle at 18% 16%, rgba(20, 184, 166, 0.32), transparent 26%),
radial-gradient(circle at 78% 20%, rgba(14, 165, 233, 0.2), transparent 24%),
linear-gradient(135deg, #0f172a 0%, #111827 46%, #1f2937 100%);
}
#__demo-zoom-wrap {
position: fixed; inset: 0;
z-index: 1;
transform-origin: 0 0;
transform: translate(0px, 0px) scale(1);
will-change: transform;
overflow: hidden;
background: #f8fafc;
box-shadow: 0 36px 110px rgba(0,0,0,0.36);
}
#__demo-zoom-wrap::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.16);
}
`,
});
await page.evaluate(() => {
const root = document.getElementById('root');
if (!root) return;
if (document.getElementById('__demo-zoom-wrap')) return;
const backdrop = document.createElement('div');
backdrop.id = '__demo-backdrop';
document.body.insertBefore(backdrop, document.body.firstChild);
const wrap = document.createElement('div');
wrap.id = '__demo-zoom-wrap';
root.parentElement?.insertBefore(wrap, root);
wrap.appendChild(root);
});
}
/**
* Zoom the wrapper so that (focusX, focusY) in original CSS pixels ends up
* at the centre of the viewport at the given scale.
*
* Math: we use transform-origin (0,0) so a point (x,y) maps to
* (k·x + dx, k·y + dy)
* To put (focusX, focusY) at (W/2, H/2) we set
* dx = W/2 - k·focusX, dy = H/2 - k·focusY.
* This avoids the awkward double-application you get with non-zero origins.
*
* The transition is set inline so callers can pick a per-call duration
* without restyling. After this call the wrapper animates over `durationMs`;
* sleep that long to wait it out.
*/
export async function zoomTo(
page: Page,
opts: { scale: number; focusX: number; focusY: number; durationMs?: number }
): Promise<void> {
const { scale, focusX, focusY, durationMs = 1100 } = opts;
const viewport = page.viewportSize() ?? { width: 1920, height: 1080 };
const dx = viewport.width / 2 - scale * focusX;
const dy = viewport.height / 2 - scale * focusY;
await page.evaluate(
({ dx, dy, scale, durationMs }) => {
const wrap = document.getElementById('__demo-zoom-wrap');
if (!wrap) return;
wrap.style.transition = `transform ${durationMs}ms cubic-bezier(0.22, 1, 0.36, 1)`;
wrap.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`;
},
{ dx, dy, scale, durationMs }
);
}
/**
* Zoom in such that the focus point STAYS where it is on screen only the
* surroundings expand outward. Use when the cursor is hovering over a target
* we want to keep clickable: clicks at the focus point still map to the
* same DOM/canvas pixel, both pre- and post-zoom, so deck.gl hit-tests work.
*
* Contrast with zoomTo, which translates the focus to viewport centre.
*/
export async function zoomAt(
page: Page,
opts: { scale: number; focusX: number; focusY: number; durationMs?: number }
): Promise<void> {
const { scale, focusX, focusY, durationMs = 1100 } = opts;
await page.evaluate(
({ scale, focusX, focusY, durationMs }) => {
const wrap = document.getElementById('__demo-zoom-wrap');
if (!wrap) return;
wrap.style.transformOrigin = `${focusX}px ${focusY}px`;
wrap.style.transition = `transform ${durationMs}ms cubic-bezier(0.22, 1, 0.36, 1)`;
wrap.style.transform = `scale(${scale})`;
},
{ scale, focusX, focusY, durationMs }
);
}
/** Animate the wrapper back to identity transform. */
export async function zoomReset(page: Page, durationMs = 1100): Promise<void> {
await page.evaluate((durationMs) => {
const wrap = document.getElementById('__demo-zoom-wrap');
if (!wrap) return;
wrap.style.transition = `transform ${durationMs}ms cubic-bezier(0.22, 1, 0.36, 1)`;
wrap.style.transform = `translate(0px, 0px) scale(1)`;
}, durationMs);
}
/**
* Snap-set the wrapper transform with no transition. Use for the very first
* frame so the recording opens already-zoomed instead of zooming in from 1.
*/
export async function zoomToInstant(
page: Page,
opts: { scale: number; focusX: number; focusY: number }
): Promise<void> {
const { scale, focusX, focusY } = opts;
const viewport = page.viewportSize() ?? { width: 1920, height: 1080 };
const dx = viewport.width / 2 - scale * focusX;
const dy = viewport.height / 2 - scale * focusY;
await page.evaluate(
({ dx, dy, scale }) => {
const wrap = document.getElementById('__demo-zoom-wrap');
if (!wrap) return;
wrap.style.transition = 'none';
wrap.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`;
// Force a reflow so the next assignment with a transition actually
// animates instead of being collapsed with this one.
void wrap.offsetHeight;
},
{ dx, dy, scale }
);
}
/**
* Smoothly scroll the closest scrollable ancestor of `selector` to `top`.
* Uses the browser's native smooth-scroll (compositor-driven, doesn't fight
* the recorder for CPU). If nothing scrollable is found, no-ops.
*/
export async function scrollPaneTo(
page: Page,
selector: string,
top: number
): Promise<void> {
await page.evaluate(
({ selector, top }) => {
const el = document.querySelector(selector) as HTMLElement | null;
if (!el) return;
const findScrollable = (node: HTMLElement | null): HTMLElement | null => {
let n: HTMLElement | null = node;
while (n) {
const oy = getComputedStyle(n).overflowY;
if ((oy === 'auto' || oy === 'scroll') && n.scrollHeight > n.clientHeight) return n;
n = n.parentElement;
}
return null;
};
// Look both inside (for the actual scroll container deeper in the tree)
// and outwards.
const inner =
Array.from(el.querySelectorAll<HTMLElement>('*')).find((n) => {
const oy = getComputedStyle(n).overflowY;
return (oy === 'auto' || oy === 'scroll') && n.scrollHeight > n.clientHeight;
}) ?? null;
const target = inner ?? findScrollable(el) ?? el;
target.scrollTo({ top, behavior: 'smooth' });
},
{ selector, top }
);
}

View file

@ -22,41 +22,52 @@ export const easeOutBack = (t: number): number => {
interface MoveOptions {
durationMs?: number;
ease?: (t: number) => number;
/**
* Override the per-step CDP cost used to size the loop. Default 35ms is
* right for free cursor moves. During a drag, every mouse.move fires a
* pointermove React re-render thumb position update on the same
* thread, pushing effective per-step cost to ~100ms. Pass that for drags
* so the loop's wall duration matches `durationMs * RECORD_SCALE`.
*/
stepBudgetMs?: number;
}
// Empirical Playwright→Chromium CDP roundtrip cost for a mouse.move command
// while recording the 4K, software-GL dashboard. It is much higher than a
// simple page because every move competes with map rendering and video capture.
const CDP_MOVE_MS = 90;
/**
* Move the real mouse from its current position to (x, y) along an eased path.
* The injected cursor follows via its mousemove listener no explicit visual sync needed.
* The injected cursor follows via its mousemove listener.
*
* Why no explicit sleep between steps: each `await page.mouse.move(...)` is a
* synchronous WebSocket round-trip to Chromium. Adding a setTimeout on top
* means the loop runs at `cdp_latency + sleepMs`, overshooting wallDuration
* by ~3×. We instead size `steps = wallDuration / CDP_MOVE_MS` so the loop's
* natural pace lands on the target wall duration.
*/
export async function smoothMove(
page: Page,
from: { x: number; y: number },
to: { x: number; y: number },
{ durationMs = 600, ease = easeInOut }: MoveOptions = {}
{ durationMs = 600, ease = easeInOut, stepBudgetMs = CDP_MOVE_MS }: MoveOptions = {}
): Promise<void> {
// Step count scales with RECORD_SCALE so we get more cursor positions per
// unit of visible animation — each one is a chance for the renderer to
// sample. CDP roundtrips cap us at ~60 commands/s, so 60fps × RECORD_SCALE
// is the practical ceiling.
const fps = 60;
const wallDuration = durationMs * RECORD_SCALE;
const steps = Math.max(2, Math.round((wallDuration / 1000) * fps));
const stepWaitMs = wallDuration / steps;
const steps = Math.max(2, Math.min(28, Math.round(wallDuration / stepBudgetMs)));
for (let i = 1; i <= steps; i++) {
const t = ease(i / steps);
const x = from.x + (to.x - from.x) * t;
const y = from.y + (to.y - from.y) * t;
await page.mouse.move(x, y);
// Use a non-scaling sleep here — we already factored RECORD_SCALE in.
await new Promise((r) => setTimeout(r, stepWaitMs));
}
}
/**
* "Fake" type: progressively set the textarea value from inside the browser,
* dispatching React-compatible input events. Looks identical to keyboard.type
* but runs in one CDP roundtrip instead of N (where N = char count). On a
* 37-char prompt this is ~1s instead of ~3s.
* "Fake" type: progressively set the textarea value, dispatching
* React-compatible input events. This is Node-driven instead of browser
* setInterval-driven because 4K software WebGL can starve page timers and
* stretch a two-second typing beat into a minute.
*/
export async function fakeType(
page: Page,
@ -64,34 +75,27 @@ export async function fakeType(
text: string,
delayMs: number
): Promise<void> {
// Scale browser-side typing by RECORD_SCALE too, so the typing animation
// has more wall time per character to render.
const scaledDelay = delayMs * RECORD_SCALE;
await page.evaluate(
({ selector, text, delayMs }) => {
const ta = document.querySelector(selector) as HTMLTextAreaElement | null;
if (!ta) throw new Error('textarea not found: ' + selector);
ta.focus();
// React tracks the textarea value by hooking the descriptor; we have to
// call the prototype setter directly so React sees the change.
const proto = Object.getPrototypeOf(ta);
const setValue = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
if (!setValue) throw new Error('no value setter on textarea');
return new Promise<void>((resolve) => {
let i = 0;
const id = window.setInterval(() => {
i += 1;
setValue.call(ta, text.slice(0, i));
ta.dispatchEvent(new Event('input', { bubbles: true }));
if (i >= text.length) {
window.clearInterval(id);
resolve();
}
}, delayMs);
});
},
{ selector, text, delayMs: scaledDelay }
);
const delay = delayMs * RECORD_SCALE;
const steps = Math.min(6, text.length);
for (let i = 1; i <= steps; i++) {
const end = Math.ceil((text.length * i) / steps);
await page.evaluate(
({ selector, value }) => {
const ta = document.querySelector(selector) as HTMLTextAreaElement | null;
if (!ta) throw new Error('textarea not found: ' + selector);
ta.focus();
// React tracks the textarea value by hooking the descriptor; we have to
// call the prototype setter directly so React sees the change.
const proto = Object.getPrototypeOf(ta);
const setValue = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
if (!setValue) throw new Error('no value setter on textarea');
setValue.call(ta, value);
ta.dispatchEvent(new Event('input', { bubbles: true }));
},
{ selector, value: text.slice(0, end) }
);
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
}
}
/**
@ -104,7 +108,7 @@ export async function smoothDragSliderThumb(
trackSelector: string,
fromCursor: { x: number; y: number },
toFraction: number,
durationMs = 1100
durationMs = 520
): Promise<{ x: number; y: number }> {
const thumbBox = await page.locator(thumbSelector).boundingBox();
const trackBox = await page.locator(trackSelector).boundingBox();
@ -115,13 +119,16 @@ export async function smoothDragSliderThumb(
const targetX = trackBox.x + trackBox.width * toFraction;
// smoothMove already applies RECORD_SCALE internally; pass human-time durations.
await smoothMove(page, fromCursor, { x: thumbCx, y: thumbCy }, { durationMs: 500 });
await smoothMove(page, fromCursor, { x: thumbCx, y: thumbCy }, { durationMs: 220 });
await page.mouse.down();
// Keep the drag to a few pointer updates. The map will redraw after commit;
// asking React/deck.gl for dozens of intermediate travel-time states is what
// made previous renders crawl and look stuttery.
await smoothMove(
page,
{ x: thumbCx, y: thumbCy },
{ x: targetX, y: thumbCy },
{ durationMs }
{ durationMs, stepBudgetMs: 360 }
);
await page.mouse.up();
return { x: targetX, y: thumbCy };

121
video/src/pb-admin.ts Normal file
View file

@ -0,0 +1,121 @@
interface SuperuserAuthResponse {
token: string;
}
interface UserRecord {
id: string;
email: string;
is_admin?: boolean;
subscription?: string;
}
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
return value;
}
async function pbJson<T>(
url: string,
init: RequestInit,
label: string
): Promise<T> {
const res = await fetch(url, init);
if (!res.ok) {
throw new Error(`${label} ${res.status}: ${await res.text()}`);
}
return (await res.json()) as T;
}
async function superuserToken(pbUrl: string, email: string, password: string): Promise<string> {
const data = await pbJson<SuperuserAuthResponse>(
`${pbUrl}/api/collections/_superusers/auth-with-password`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity: email, password }),
},
'PocketBase superuser auth'
);
return data.token;
}
async function findUser(pbUrl: string, token: string, email: string): Promise<UserRecord | null> {
const filter = `email="${email.replaceAll('"', '\\"')}"`;
const data = await pbJson<{ items: UserRecord[] }>(
`${pbUrl}/api/collections/users/records?filter=${encodeURIComponent(filter)}&perPage=1`,
{ headers: { Authorization: `Bearer ${token}` } },
'PocketBase user lookup'
);
return data.items[0] ?? null;
}
function recorderUserBody(email: string, password: string): Record<string, unknown> {
return {
email,
emailVisibility: true,
verified: true,
password,
passwordConfirm: password,
is_admin: true,
subscription: 'licensed',
};
}
export async function ensureRecorderAdminUser(): Promise<void> {
const pbUrl = requireEnv('PB_URL').replace(/\/$/, '');
const email = requireEnv('PB_EMAIL');
const password = requireEnv('PB_PASSWORD');
const adminEmail = process.env.PB_ADMIN_EMAIL ?? process.env.POCKETBASE_ADMIN_EMAIL;
const adminPassword = process.env.PB_ADMIN_PASSWORD ?? process.env.POCKETBASE_ADMIN_PASSWORD;
if (!adminEmail || !adminPassword) {
throw new Error('PB_ADMIN_EMAIL/PB_ADMIN_PASSWORD are required to bootstrap the recorder user');
}
const token = await superuserToken(pbUrl, adminEmail, adminPassword);
const existing = await findUser(pbUrl, token, email);
const body = recorderUserBody(email, password);
if (existing) {
await pbJson<UserRecord>(
`${pbUrl}/api/collections/users/records/${existing.id}`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
},
'PocketBase recorder user update'
);
console.log(`Updated recorder admin user ${email}.`);
return;
}
await pbJson<UserRecord>(
`${pbUrl}/api/collections/users/records`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
},
'PocketBase recorder user create'
);
console.log(`Created recorder admin user ${email}.`);
}
async function main() {
await ensureRecorderAdminUser();
}
if (process.argv[1]?.endsWith('pb-admin.js')) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}

View file

@ -1,26 +1,32 @@
import { chromium } from 'playwright';
import { execSync } from 'node:child_process';
import { existsSync, mkdirSync, renameSync, readdirSync, statSync } from 'node:fs';
import { existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
import { join } from 'node:path';
import {
APP_URL,
AUTH_STATE_PATH,
COLD_OPEN_VIEW,
CAPTURE_SCALE,
DASHBOARD_PATH,
INITIAL_MAP_VIEW,
MAX_DURATION_S,
OUTPUT_DIR,
OUTPUT_FPS,
OUTPUT_DIR,
PRELOAD_POSTCODE,
RECORD_SCALE,
STUBBED_FILTERS,
STUBBED_TRAVEL_TIME_FILTERS,
VIDEO_SIZE,
VIEWPORT,
WEBM_BITRATE,
} from './config.js';
import { installCursor } from './dom.js';
import { installCursor, installZoomWrapper } from './dom.js';
import {
sceneAiPrompt,
sceneColdOpen,
sceneOutro,
scenePropertyReveal,
sceneSliderControl,
preZoomToAiBox,
sceneAiCloseUp,
sceneClusterClick,
sceneExportAndOutro,
sceneTravelTimeSlider,
sceneZoomOutResults,
type SceneCtx,
} from './scenes.js';
import { sleep } from './motion.js';
@ -30,17 +36,20 @@ import { sleep } from './motion.js';
* 15-second video we want sub-second response so the map reacts crisply with
* the typed prompt still on screen. Returning canned filters also makes every
* recording bit-identical.
*
* The shape MUST match what useAiFilters expects (filters, travel_time_filters,
* notes, match_count) see frontend/src/hooks/useAiFilters.ts.
*/
async function stubAiFilters(page: import('playwright').Page) {
await page.route('**/api/ai-filters', async (route) => {
// Small delay so the loading indicator is visible (looks like real AI work).
await new Promise((r) => setTimeout(r, 400));
await new Promise((r) => setTimeout(r, 180));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
filters: STUBBED_FILTERS,
travel_time_filters: [],
travel_time_filters: STUBBED_TRAVEL_TIME_FILTERS,
notes: '',
match_count: 1247,
}),
@ -48,6 +57,26 @@ async function stubAiFilters(page: import('playwright').Page) {
});
}
async function stubExport(page: import('playwright').Page) {
await page.route('**/api/export?**', async (route) => {
await new Promise((r) => setTimeout(r, 160));
await route.fulfill({
status: 200,
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers: {
'content-disposition': 'attachment; filename="perfect-postcode-export.xlsx"',
},
body: Buffer.from('Perfect Postcode demo export\n'),
});
});
}
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}`);
}
}
async function main() {
if (!existsSync(AUTH_STATE_PATH)) {
console.error(
@ -82,8 +111,8 @@ async function main() {
const context = await browser.newContext({
storageState: AUTH_STATE_PATH,
viewport: VIEWPORT,
deviceScaleFactor: 2,
recordVideo: { dir: OUTPUT_DIR, size: VIEWPORT },
deviceScaleFactor: CAPTURE_SCALE,
recordVideo: { dir: OUTPUT_DIR, size: VIDEO_SIZE },
});
// Vite's dev server pushes HMR updates over a "vite-hmr" WebSocket. If a
@ -95,15 +124,30 @@ async function main() {
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;
if (protoStr.includes('vite-hmr')) {
return Object.assign(Object.create(RealWS.prototype), {
readyState: RealWS.CONNECTING,
send() {}, close() {},
addEventListener() {}, removeEventListener() {},
dispatchEvent: () => true,
if (
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: '' },
bufferedAmount: { value: 0 },
binaryType: { value: 'blob', writable: true },
});
fake.send = () => {};
fake.close = () => {
fake.dispatchEvent(new Event('close'));
};
queueMicrotask(() => fake.dispatchEvent(new Event('close')));
return fake;
}
return Reflect.construct(target, args);
},
@ -113,13 +157,84 @@ async function main() {
// transport in a later Vite version), neutralize the full-reload fallback.
const noop = () => {};
Object.defineProperty(window.location, 'reload', { value: noop, configurable: true });
// Stop runtime errors from reaching Vite's <vite-error-overlay>. We're
// recording against a dev server for fast iteration; in prod the overlay
// wouldn't exist either way. A stray deck.gl layer-pipeline error covering
// the dashboard ruins the take, so we eat the error before Vite can see it.
// capture=true → our handler runs before Vite's at the document level.
window.addEventListener(
'error',
(e) => {
e.stopImmediatePropagation();
},
true
);
window.addEventListener(
'unhandledrejection',
(e) => {
e.stopImmediatePropagation();
},
true
);
// CSS fallback covering all the bundlers' diagnostic overlays. Vite
// ships <vite-error-overlay>, webpack v5+ uses <wds-overlay> (shadow DOM)
// or <div id="webpack-dev-server-client-overlay">, webpack v4 injects an
// iframe. Compilation warnings surface as a top-level red banner that
// occludes the dashboard.
const styleEl = document.createElement('style');
styleEl.textContent = `
vite-error-overlay,
wds-overlay,
#webpack-dev-server-client-overlay,
#webpack-dev-server-client-overlay-div,
iframe[src*="webpack-dev-server"],
iframe[id*="webpack"],
[id*="webpack-dev-server-client"],
[class*="error-overlay"],
[class*="webpack-error"] {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
pointer-events: none !important;
}
`;
(document.head ?? document.documentElement).appendChild(styleEl);
// Belt-and-braces: a MutationObserver kills any newly-injected overlay
// root by name. Webpack v5 reinjects on each warning batch, so a static
// CSS rule alone occasionally races a brief flash of the banner.
const killOverlay = (node: Element) => {
const tag = node.tagName?.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')
) {
(node as HTMLElement).remove();
}
};
const obs = new MutationObserver((muts) => {
for (const m of muts)
m.addedNodes.forEach((n) => {
if (n.nodeType === 1) killOverlay(n as Element);
});
});
if (document.body) obs.observe(document.body, { childList: true, subtree: true });
else
document.addEventListener('DOMContentLoaded', () =>
obs.observe(document.body, { childList: true, subtree: true })
);
});
const page = await context.newPage();
const recordedVideo = page.video();
// recordVideo starts the moment the page is created. We want the final clip
// to begin at the cold-open scene, not include the navigation/settle phase.
// Track when the recording started and when the scenes start, so we can
// ffmpeg-trim post-hoc.
// to begin once we're zoomed on the AI prompt and ready to type — NOT
// include navigation, sidebar mount, or the AI button click. We track scene
// start vs record start and ffmpeg-trim post-hoc.
const recordStartMs = Date.now();
page.on('console', (m) => {
if (m.type() === 'error' || m.type() === 'warning') {
@ -137,70 +252,122 @@ async function main() {
if (u.includes('ai-filters')) console.log(`[req] ${r.method()} ${u}`);
});
await stubAiFilters(page);
await stubExport(page);
const url = `${APP_URL}${DASHBOARD_PATH}${COLD_OPEN_VIEW}`;
await page.goto(url, { waitUntil: 'networkidle' });
// Pre-load with ?pc= so the dashboard auto-opens the right pane and
// flies to that postcode at zoom 16 (where postcode polygons render
// individually). The cluster click later in the scene becomes purely
// visual — the pane is already there, data already loaded.
const params = new URLSearchParams({
pc: PRELOAD_POSTCODE,
lat: String(INITIAL_MAP_VIEW.lat),
lon: String(INITIAL_MAP_VIEW.lon),
zoom: String(INITIAL_MAP_VIEW.zoom),
});
addInitialTravelTimeParams(params);
const url = `${APP_URL}${DASHBOARD_PATH}?${params}`;
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('load', { timeout: 15000 }).catch(() => {});
await page
.locator('[data-tutorial="ai-filters"]')
.waitFor({ state: 'visible', timeout: 15000 });
// Settle: deck.gl tiles, postcode aggregations, sidebar mount.
await sleep(800);
// Wait for the right pane to actually mount AND its content to render.
// Without this the AI scene's browser-side setInterval (fakeType) gets
// throttled by Chromium's scheduler under boot load (deck.gl uploads,
// Street View iframe, postcode geometry fetch) and typing stretches 3×.
try {
await page
.locator('[data-tutorial="right-pane"]')
.waitFor({ state: 'visible', timeout: 15000 });
// Either the area stats or the Street View embed indicates the pane
// has finished its first render pass.
await page
.locator('[data-tutorial="right-pane"] iframe, [data-tutorial="right-pane"] canvas')
.first()
.waitFor({ state: 'attached', timeout: 2000 })
.catch(() => {});
} catch {
// Pane didn't appear (postcode may not exist on this stack); proceed
// anyway — scenes still work, just with no pane content.
console.log('[render] right-pane preload did not mount; continuing');
}
// Final settle. The dashboard's flyTo to the preloaded postcode runs a
// ~1.5s maplibre animation; deck.gl's hexagon/postcode buffers upload
// through the same window. We wait long enough that all of this completes
// before scenes start, otherwise the AI scene's browser-side setInterval
// (fakeType) gets throttled to 200ms+ ticks.
await new Promise((r) => setTimeout(r, 1200));
// Wrapper must be installed BEFORE the cursor — the cursor is appended to
// <body> and must remain a sibling of the wrapper, not a descendant.
await installZoomWrapper(page);
await installCursor(page);
// Park cursor near top-left so its first move (to the AI box) is visible.
const ctx: SceneCtx = { page, cursor: { x: 80, y: 90 } };
// Park cursor near the AI box (sidebar) at low-y so the first move is short.
const ctx: SceneCtx = { page, cursor: { x: 200, y: 240 } };
await page.mouse.move(ctx.cursor.x, ctx.cursor.y);
// Pre-flight (NOT counted in scene wall time): expand the AI prompt and
// snap the wrapper to its zoomed-on-AI starting state.
await preZoomToAiBox(ctx);
await sleep(80);
const sceneStartMs = Date.now();
await sceneColdOpen(ctx);
await sceneAiPrompt(ctx);
await sceneSliderControl(ctx);
await scenePropertyReveal(ctx);
await sceneOutro(ctx);
const t = (label: string, prev: number) => {
const now = Date.now();
console.log(`[scene] ${label}: ${((now - prev) / 1000).toFixed(2)}s wall`);
return now;
};
let mark = sceneStartMs;
await sceneAiCloseUp(ctx); mark = t('AI close-up', mark);
await sceneZoomOutResults(ctx); mark = t('Zoom out', mark);
await sceneTravelTimeSlider(ctx); mark = t('TT slider', mark);
await sceneClusterClick(ctx); mark = t('Cluster click', mark);
await sceneExportAndOutro(ctx); mark = t('Export + outro', mark);
const sceneEndMs = Date.now();
await page.close();
const rawPath = join(OUTPUT_DIR, 'recording.raw.webm');
if (recordedVideo) {
await recordedVideo.saveAs(rawPath);
}
await context.close();
await browser.close();
// Playwright names recordings by guid; rename the most recent one.
const files = readdirSync(OUTPUT_DIR)
.filter((f) => f.endsWith('.webm') && f.startsWith('page@'))
.map((f) => ({ f, t: statSync(join(OUTPUT_DIR, f)).mtimeMs }))
.sort((a, b) => b.t - a.t);
if (!files[0]) {
if (!recordedVideo || !statSync(rawPath).size) {
console.error('no recorded webm found');
process.exit(1);
}
const rawPath = join(OUTPUT_DIR, files[0].f);
const trimmedPath = join(OUTPUT_DIR, 'recording.webm');
const sceneSpan = (sceneEndMs - sceneStartMs) / 1000;
const maxFinalDuration = Math.max(0.1, MAX_DURATION_S - 0.2);
// The trim window is in *recording wall time*, which is RECORD_SCALE× the
// visible duration. After ffmpeg setpts speeds it back up, the final clip
// will be exactly MAX_DURATION_S seconds.
const wallCap = MAX_DURATION_S * RECORD_SCALE;
// will be exactly MAX_DURATION_S seconds (or sceneSpan/RECORD_SCALE if shorter).
const wallCap = maxFinalDuration * RECORD_SCALE;
const trimEnd = (sceneEndMs - recordStartMs) / 1000;
const wallDuration = Math.min(sceneSpan, wallCap);
const trimStart = trimEnd - wallDuration;
const finalDuration = wallDuration / RECORD_SCALE;
if (sceneSpan > wallCap) {
console.log(
`Scene wall time was ${sceneSpan.toFixed(2)}s (cap ${wallCap.toFixed(2)}s at scale ${RECORD_SCALE}); trimming to last ${MAX_DURATION_S}s (anchored to outro).`
`Scene wall time was ${sceneSpan.toFixed(2)}s (cap ${wallCap.toFixed(2)}s at scale ${RECORD_SCALE}); trimming to last ${maxFinalDuration.toFixed(2)}s (anchored to outro).`
);
}
// Trim AND speed up AND interpolate to OUTPUT_FPS in one pass.
// Trim + speed-up in one pass.
// - -ss + -t: trim window in raw recording's wall time.
// - setpts=PTS/RECORD_SCALE: speed up the clip back to "human time".
// - minterpolate at fps=OUTPUT_FPS: synthesize intermediate frames so the
// sped-up output runs smoothly at 60fps even if raw was 25fps.
// - libvpx-vp9 with -deadline good gives a tight WebM that the encode step
// can re-mux to MP4 quickly.
// - setpts=PTS/RECORD_SCALE: speed up the clip back to "human time". Each
// raw frame plays for 1/N as long → we get N× the effective fps for free,
// no synthetic interpolation needed.
// - fps=OUTPUT_FPS gives the MP4 encoder a stable cadence; with the default
// RECORD_SCALE=2 this matches the real captured cadence (25fps × 2).
execSync(
`ffmpeg -y -ss ${trimStart.toFixed(3)} -i "${rawPath}" -t ${wallDuration.toFixed(3)} ` +
`-vf "setpts=PTS/${RECORD_SCALE},minterpolate=fps=${OUTPUT_FPS}:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1" ` +
`-r ${OUTPUT_FPS} ` +
`-c:v libvpx-vp9 -b:v 6M -deadline good -cpu-used 2 ` +
`-vf "setpts=PTS/${RECORD_SCALE},fps=${OUTPUT_FPS},trim=duration=${finalDuration.toFixed(3)},setpts=PTS-STARTPTS" -fps_mode cfr ` +
`-r ${OUTPUT_FPS} -c:v libvpx -b:v ${WEBM_BITRATE} -deadline good -cpu-used 5 ` +
`"${trimmedPath}"`,
{ stdio: 'inherit' }
);
@ -210,7 +377,9 @@ async function main() {
} catch {
/* ignore */
}
console.log(`Wrote ${trimmedPath} (${finalDuration.toFixed(2)}s @ ${OUTPUT_FPS}fps, scale=${RECORD_SCALE})`);
console.log(
`Wrote ${trimmedPath} (${finalDuration.toFixed(2)}s, scale=${RECORD_SCALE}, capture=${VIDEO_SIZE.width}x${VIDEO_SIZE.height})`
);
console.log('Run "npm run encode" to produce output/recording.mp4');
}

View file

@ -1,14 +1,25 @@
import type { Page } from 'playwright';
import {
AI_ZOOM_SCALE,
BRAND_NAME,
BRAND_TAGLINE,
BRAND_URL,
PROMPT_TEXT,
DRAG_FILTER_NAME,
DRAG_TO_FRACTION,
TT_CARD_SELECTOR,
TT_DRAG_FROM_MIN,
TT_DRAG_TO_MIN,
TT_SLIDER_MAX,
} from './config.js';
import {
clearVignette,
flashRect,
hideCaption,
scrollPaneTo,
showCaption,
showOutro,
zoomReset,
zoomTo,
zoomToInstant,
} from './dom.js';
import { fakeType, sleep, smoothMove, smoothDragSliderThumb } from './motion.js';
@ -17,109 +28,232 @@ export interface SceneCtx {
cursor: { x: number; y: number };
}
/** Cold open. Vignette fades; cursor parks at a "natural" rest position. */
export async function sceneColdOpen(ctx: SceneCtx): Promise<void> {
await clearVignette(ctx.page);
await ctx.page.mouse.move(ctx.cursor.x, ctx.cursor.y);
await sleep(1100);
}
/**
* AI prompt scene: click the collapsed AI box, type the prompt, submit,
* watch the (stubbed) response apply.
* Scene 1: open already zoomed in on the AI prompt card. Caption fades in,
* the user types their request, and the already-preloaded filters are revealed
* behind the zoomed wrapper. Keeping this beat visual avoids slow dev-server
* data refreshes eating the 15-second timeline.
*
* Pre-conditions (set up by record.ts before scene timer starts):
* - The AI box is already expanded (textarea visible, ready to focus).
* - The wrapper is already zoomed at AI_ZOOM_SCALE on the AI box centre.
* - The vignette is up.
*/
export async function sceneAiPrompt(ctx: SceneCtx): Promise<void> {
export async function sceneAiCloseUp(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await showCaption(page, 'Describe the area you want.');
await clearVignette(page);
await showCaption(page, 'Brief: flats or terraces under £450k near central Manchester.');
await sleep(160);
const aiButton = page.locator('[data-tutorial="ai-filters"] button').first();
const btnBox = await aiButton.boundingBox();
if (!btnBox) throw new Error('AI button not found');
const target = { x: btnBox.x + btnBox.width / 2, y: btnBox.y + btnBox.height / 2 };
await smoothMove(page, ctx.cursor, target, { durationMs: 400 });
ctx.cursor = target;
await page.mouse.click(target.x, target.y);
const textarea = page.locator('[data-tutorial="ai-filters"] textarea');
await textarea.waitFor({ state: 'visible', timeout: 3000 });
await fakeType(page, '[data-tutorial="ai-filters"] textarea', PROMPT_TEXT, 14);
await sleep(120);
const taBox = await textarea.boundingBox();
if (taBox) {
const into = { x: taBox.x + 30, y: taBox.y + taBox.height / 2 };
await smoothMove(page, ctx.cursor, into, { durationMs: 220 });
ctx.cursor = into;
}
// fakeType runs the typing animation inside the browser to avoid CDP
// round-trip overhead per keystroke (which can quadruple total typing time).
await fakeType(page, '[data-tutorial="ai-filters"] textarea', PROMPT_TEXT, 35);
await sleep(180);
await page.keyboard.press('Enter');
await sleep(700);
const aiResponse = page
.waitForResponse(
(response) => response.url().includes('/api/ai-filters') && response.status() === 200,
{ timeout: 1800 }
)
.catch(() => null);
await page.evaluate(() => {
document.querySelector<HTMLFormElement>('[data-tutorial="ai-filters"] form')?.requestSubmit();
});
await aiResponse;
await showCaption(page, 'The filters are already live on the map.');
await sleep(360);
await hideCaption(page);
await sleep(150);
}
/**
* Slider scene: pan to a numeric filter's right thumb and drag it inward.
* The whole point: the user sees the map react in real time to a human action,
* driving home that AI sets a starting point but you stay in control.
* Scene 2: animate the wrapper back to scale 1 so the full dashboard is
* revealed. The map has already pan-flown to Manchester (MapPage's
* own flyTo fires when AI travel-time filters are applied), so the zoom-out
* lands on a useful, filtered view.
*/
export async function sceneSliderControl(ctx: SceneCtx): Promise<void> {
export async function sceneZoomOutResults(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await showCaption(page, 'You stay in control.');
await showCaption(page, 'Now the view lands on central Manchester with the filters already set.');
await zoomReset(page, 560);
await sleep(420);
await hideCaption(page);
await sleep(80);
}
const card = page.locator(`[data-filter-name="${DRAG_FILTER_NAME}"]`);
await card.waitFor({ state: 'visible', timeout: 3000 });
/**
* Scene 3: drag the right thumb of the AI-applied travel-time slider from
* 35 to 20 minutes. The slider has step=1 over 0120, so the 15-minute
* range crosses 15 step boundaries at our pace each one gets ~20+ recorded
* frames, so the thumb reads as a continuous slide rather than incremental.
*
* The card we drag (`tt_0`) only exists because the AI filter step inserted
* exactly one travel-time entry; if you change the AI stub's count, update
* the selector or this scene will time out.
*/
export async function sceneTravelTimeSlider(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await showCaption(
page,
`Then tighten the commute: ${TT_DRAG_FROM_MIN} minutes down to ${TT_DRAG_TO_MIN}.`
);
const card = page.locator(TT_CARD_SELECTOR);
await card.waitFor({ state: 'visible', timeout: 4000 });
await card.scrollIntoViewIfNeeded();
await sleep(120);
await sleep(60);
const thumbSelector = `[data-filter-name="${DRAG_FILTER_NAME}"] [role="slider"] >> nth=1`;
const trackSelector = `[data-filter-name="${DRAG_FILTER_NAME}"] [data-orientation="horizontal"] >> nth=0`;
// Two thumbs in a Radix range slider; the second one is the max.
const thumbSelector = `${TT_CARD_SELECTOR} [role="slider"] >> nth=1`;
// Track is the first horizontal-orientation element inside the card.
const trackSelector = `${TT_CARD_SELECTOR} [data-orientation="horizontal"] >> nth=0`;
// Slider goes 0..120, target = 20 → fraction 0.166...
const toFraction = TT_DRAG_TO_MIN / TT_SLIDER_MAX;
ctx.cursor = await smoothDragSliderThumb(
page,
thumbSelector,
trackSelector,
ctx.cursor,
DRAG_TO_FRACTION,
1100
toFraction,
520
);
await sleep(550);
await sleep(120);
await showCaption(page, 'The map redraws around the areas that still work.');
await sleep(440);
await hideCaption(page);
await sleep(150);
await sleep(60);
}
/** Property reveal: click a postcode on the map to open the side pane with charts. */
export async function scenePropertyReveal(ctx: SceneCtx): Promise<void> {
/**
* Scene 4: zoom into a cluster of filtered postcodes (using deck.gl's own
* camera, via wheel events), click one, and as the right pane fills, pan
* the framing rightward while scrolling the pane content.
*
* Why two zoom mechanisms across this scene:
* - Pre-click: native deck.gl wheel-zoom. CSS-transforming the wrapper
* changes `canvas.getBoundingClientRect()` (scaled rect) without changing
* `canvas.width`. deck.gl's hit-test uses the rect for screenbuffer
* mapping, returns a partial picked object, and React re-renders mid-paint
* leaving a null layer reference that crashes `MapboxLayer.render`.
* Native wheel-zoom recomputes deck.gl's camera in-place; layers stay coherent.
* - Post-click: CSS transform to pan the framing rightward. By this point
* the postcode is selected and layers are stable, so the transform is safe.
*/
export async function sceneClusterClick(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
const viewport = page.viewportSize() ?? { width: 1920, height: 1080 };
const target = {
x: 360 + (viewport.width - 360) * 0.55,
y: viewport.height * 0.5,
await showCaption(page, 'Open one promising area and check the detail before shortlisting.');
// Click point: roughly map centre. After AI flew the camera to Manchester, this
// sits in the densely-filtered city core where hexagons reliably cover any
// pixel. Earlier iterations wheel-zoomed first to "feel cinematic", but
// that crossed the hexagon→postcode layer-swap threshold mid-flight and
// clicks landed in a layer gap (no pane opened).
const cluster = {
x: 360 + (viewport.width - 360) * 0.5,
y: viewport.height * 0.45,
};
await smoothMove(page, ctx.cursor, target, { durationMs: 500 });
ctx.cursor = target;
await smoothMove(page, ctx.cursor, cluster, { durationMs: 260 });
ctx.cursor = cluster;
await sleep(70);
await page.mouse.click(target.x, target.y);
await sleep(1300);
}
// The right pane was opened at page load via ?pc= — no need to drive a
// real selection through deck.gl's hit-test, which is flaky in headless
// Chromium. The mouse.click here is purely for the visible cursor ripple
// animation; the pane is already populated with real postcode data.
await page.mouse.click(cluster.x, cluster.y);
await sleep(130);
/** Outro: full-screen logo card with brand + URL. */
export async function sceneOutro(ctx: SceneCtx): Promise<void> {
await showOutro(
ctx.page,
'Perfect Postcodes',
'Find where you actually want to live.',
'perfectpostcodes.com'
// NOW zoom in toward the cluster, pan rightward to centre the right pane,
// and scroll the pane content — all in parallel. Layers are stable so the
// CSS transform is safe.
const rightShift = 240;
await Promise.all([
zoomTo(page, {
scale: 1.35,
focusX: cluster.x + rightShift,
focusY: cluster.y,
durationMs: 520,
}),
scrollPaneTo(page, '[data-tutorial="right-pane"]', 480),
]);
await sleep(320);
await showCaption(
page,
'This is the useful pause: local stats, matching homes, and street context together.'
);
await sleep(1800);
await sleep(520);
await hideCaption(page);
}
/** Export the current shortlist, then reveal the URL. */
export async function sceneExportAndOutro(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await showCaption(page, 'When the shortlist feels right, export the exact filtered view.');
await zoomReset(page, 460);
await sleep(240);
const exportButton = page.locator('button[title="Export to Excel"]').first();
await exportButton.waitFor({ state: 'visible', timeout: 4000 });
const box = await exportButton.boundingBox();
if (!box) throw new Error('Export button has no bounding box');
const target = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
await smoothMove(page, ctx.cursor, target, { durationMs: 360 });
ctx.cursor = target;
await sleep(70);
const download = page.waitForEvent('download', { timeout: 4000 }).catch(() => null);
await page.mouse.click(target.x, target.y);
await flashRect(page, box);
await sleep(280);
await hideCaption(page);
await showOutro(ctx.page, BRAND_NAME, BRAND_TAGLINE, BRAND_URL);
void download;
await sleep(2400);
}
/**
* Helper used by record.ts: after navigation but BEFORE the scene timer
* starts, click the AI-prompt button so its textarea is mounted, then snap
* the wrapper to its zoomed-on-AI starting state.
*
* Splitting this out keeps the scene timer honest: the textarea's mount
* animation and the zoom snap don't eat into the 15s budget.
*/
export async function preZoomToAiBox(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
// Open the AI prompt. The collapsed state shows a single button; clicking
// it expands the form and reveals the textarea.
const aiRoot = page.locator('[data-tutorial="ai-filters"]').first();
await aiRoot.waitFor({ state: 'visible', timeout: 15000 });
const textarea = page.locator('[data-tutorial="ai-filters"] textarea');
if (!(await textarea.isVisible().catch(() => false))) {
const aiButton = aiRoot.locator('button').first();
await aiButton.waitFor({ state: 'visible', timeout: 8000 });
const btnBox = await aiButton.boundingBox();
if (btnBox) await page.mouse.click(btnBox.x + btnBox.width / 2, btnBox.y + btnBox.height / 2);
}
if (!(await textarea.isVisible().catch(() => false))) {
await page.evaluate(() => {
document.querySelector<HTMLElement>('[data-tutorial="ai-filters"] button')?.click();
});
}
await textarea.waitFor({ state: 'visible', timeout: 15000 });
await sleep(100);
// Snap-zoom to the AI card centre. The recording opens already zoomed in
// — there's no awkward "from 1× to 2.4×" intro animation.
const aiCard = page.locator('[data-tutorial="ai-filters"]');
const cardBox = await aiCard.boundingBox();
if (!cardBox) throw new Error('AI card has no bounding box');
const focusX = cardBox.x + cardBox.width / 2;
const focusY = cardBox.y + cardBox.height / 2;
await zoomToInstant(page, { scale: AI_ZOOM_SCALE, focusX, focusY });
}