Hacky demo changes

This commit is contained in:
Andras Schmelczer 2026-05-06 19:36:04 +01:00
parent 7cba369308
commit ea7afd618c
39 changed files with 2041 additions and 745 deletions

View file

@ -23,6 +23,7 @@ step "Python unit tests" uv run pytest \
step "Frontend lint: ESLint" npm run lint
step "Frontend format check: Prettier" npm run format:check
step "Frontend typecheck: TypeScript" npm run typecheck
step "Frontend i18n completeness" npm run check:i18n
step "Frontend unit tests: Vitest" npm run test
)

View file

@ -11,7 +11,8 @@
"lint": "eslint src --ext .ts,.tsx",
"lint:fix": "eslint src --ext .ts,.tsx --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\""
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"",
"check:i18n": "node scripts/check-translations.mjs"
},
"dependencies": {
"@deck.gl/core": "^9.0.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

After

Width:  |  Height:  |  Size: 370 KiB

Before After
Before After

Binary file not shown.

View file

@ -0,0 +1,259 @@
#!/usr/bin/env node
// Validates that every translation file under src/i18n is complete and consistent.
//
// Checks:
// 1. Locales declared in SUPPORTED_LANGUAGES (index.ts) match the files in locales/
// and the language records in descriptions.ts / details.ts.
// 2. Every leaf key in en.ts is present and non-empty in every other locale.
// 3. Every {{placeholder}} and HTML tag in an English string also appears,
// with the same multiset, in the translated string.
// 4. descriptions.ts and details.ts: the union of feature-name keys across
// languages is treated as canonical; every language must cover all of them.
//
// The script parses the TypeScript source with the compiler API and walks the
// AST — no runtime import, no transpilation, no temp files. Run it with:
// node frontend/scripts/check-translations.mjs
import { readFileSync, readdirSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import ts from 'typescript';
const __dirname = dirname(fileURLToPath(import.meta.url));
const I18N_DIR = join(__dirname, '..', 'src', 'i18n');
const LOCALES_DIR = join(I18N_DIR, 'locales');
const PLACEHOLDER_RE = /\{\{\s*[a-zA-Z_][\w]*\s*\}\}/g;
const HTML_TAG_RE = /<\/?[a-zA-Z][\w]*\b[^>]*>/g;
const errors = [];
const warnings = [];
const fail = (msg) => errors.push(msg);
const warn = (msg) => warnings.push(msg);
function parseFile(path) {
const src = readFileSync(path, 'utf8');
return ts.createSourceFile(path, src, ts.ScriptTarget.Latest, true);
}
// Recursively turn a TS literal expression into a plain JS value.
// Returns undefined for nodes we don't understand — callers must check.
function literalToJs(node) {
if (!node) return undefined;
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
if (ts.isAsExpression(node) || ts.isParenthesizedExpression(node)) {
return literalToJs(node.expression);
}
if (ts.isObjectLiteralExpression(node)) {
const out = {};
for (const prop of node.properties) {
if (!ts.isPropertyAssignment(prop)) continue;
const k = prop.name;
let key;
if (ts.isIdentifier(k)) key = k.text;
else if (ts.isStringLiteral(k) || ts.isNoSubstitutionTemplateLiteral(k)) key = k.text;
else continue;
out[key] = literalToJs(prop.initializer);
}
return out;
}
if (ts.isArrayLiteralExpression(node)) {
return node.elements.map((e) => literalToJs(e));
}
return undefined;
}
function findVarInitializer(sourceFile, name) {
let result;
function visit(node) {
if (ts.isVariableStatement(node)) {
for (const decl of node.declarationList.declarations) {
if (ts.isIdentifier(decl.name) && decl.name.text === name) {
result = decl.initializer;
}
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return result;
}
function readSupportedLanguages() {
const sf = parseFile(join(I18N_DIR, 'index.ts'));
const init = findVarInitializer(sf, 'SUPPORTED_LANGUAGES');
if (!init) throw new Error('Could not find SUPPORTED_LANGUAGES in index.ts');
const arr = literalToJs(init);
if (!Array.isArray(arr)) throw new Error('SUPPORTED_LANGUAGES is not an array literal');
return arr.map((entry) => entry.code);
}
function readLocale(code) {
const path = join(LOCALES_DIR, `${code}.ts`);
const sf = parseFile(path);
const init = findVarInitializer(sf, code);
if (!init) throw new Error(`Could not find const ${code} in ${path}`);
const obj = literalToJs(init);
if (!obj || typeof obj !== 'object') throw new Error(`${code}.ts: not an object literal`);
return obj;
}
function readNamedRecord(file, varName) {
const sf = parseFile(join(I18N_DIR, file));
const init = findVarInitializer(sf, varName);
if (!init) throw new Error(`Could not find ${varName} in ${file}`);
const obj = literalToJs(init);
if (!obj || typeof obj !== 'object') throw new Error(`${file}: ${varName} is not an object`);
return obj;
}
function flatten(obj, prefix = '', out = new Map()) {
for (const [k, v] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
flatten(v, path, out);
} else {
out.set(path, v);
}
}
return out;
}
function tokenMultiset(s, re) {
const matches = String(s).match(re) || [];
// Normalise whitespace inside placeholders so '{{ count }}' == '{{count}}'.
return matches.map((t) => t.replace(/\s+/g, '')).sort();
}
function multisetsEqual(a, b) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
function checkLeafConsistency(path, enValue, trValue, lang) {
if (typeof trValue !== 'string') {
fail(`[${lang}] ${path}: missing translation`);
return;
}
if (trValue.trim() === '') {
fail(`[${lang}] ${path}: empty translation`);
return;
}
for (const [re, label] of [
[PLACEHOLDER_RE, 'placeholder'],
[HTML_TAG_RE, 'HTML tag'],
]) {
const want = tokenMultiset(enValue, re);
const got = tokenMultiset(trValue, re);
if (!multisetsEqual(want, got)) {
fail(
`[${lang}] ${path}: ${label} mismatch — en=${JSON.stringify(want)} ` +
`${lang}=${JSON.stringify(got)}`
);
}
}
}
function checkLocales(supportedCodes) {
const localeFiles = readdirSync(LOCALES_DIR)
.filter((f) => f.endsWith('.ts'))
.map((f) => f.replace(/\.ts$/, ''));
for (const code of supportedCodes) {
if (!localeFiles.includes(code)) {
fail(`SUPPORTED_LANGUAGES lists "${code}" but locales/${code}.ts is missing`);
}
}
for (const code of localeFiles) {
if (!supportedCodes.includes(code)) {
warn(`locales/${code}.ts exists but is not listed in SUPPORTED_LANGUAGES`);
}
}
const en = flatten(readLocale('en'));
for (const code of supportedCodes) {
if (code === 'en') continue;
if (!localeFiles.includes(code)) continue;
const tr = flatten(readLocale(code));
for (const [path, enValue] of en) {
checkLeafConsistency(path, enValue, tr.get(path), code);
}
for (const path of tr.keys()) {
if (!en.has(path)) warn(`[${code}] ${path}: extra key not in en.ts`);
}
}
}
function checkRecordCoverage(file, varName, supportedCodes, serverKeys) {
const record = readNamedRecord(file, varName);
const expected = supportedCodes.filter((c) => c !== 'en');
const present = Object.keys(record);
for (const code of expected) {
if (!present.includes(code)) {
fail(`${file}: missing language record "${code}"`);
}
}
for (const code of present) {
if (!expected.includes(code)) {
warn(`${file}: unexpected language record "${code}"`);
}
}
// Use the union of feature-name keys across languages as canonical.
const union = new Set();
for (const code of expected) {
if (record[code]) for (const k of Object.keys(record[code])) union.add(k);
}
for (const code of expected) {
const langKeys = new Set(Object.keys(record[code] ?? {}));
for (const key of union) {
if (!langKeys.has(key)) {
fail(`${file} [${code}]: missing translation for "${key}"`);
} else {
const v = record[code][key];
if (typeof v !== 'string' || v.trim() === '') {
fail(`${file} [${code}]: empty translation for "${key}"`);
}
}
}
}
// Every key here must also be a translatable feature name in en.ts > server.
// Otherwise the description is unreachable — ts() looks up server.${name}.
for (const key of union) {
if (!serverKeys.has(key)) {
fail(`${file}: key "${key}" has no matching entry in en.ts > server`);
}
}
}
function main() {
let supportedCodes;
try {
supportedCodes = readSupportedLanguages();
} catch (e) {
console.error(`fatal: ${e.message}`);
process.exit(2);
}
checkLocales(supportedCodes);
const en = readLocale('en');
const serverKeys = new Set(Object.keys(en.server ?? {}));
checkRecordCoverage('descriptions.ts', 'descriptions', supportedCodes, serverKeys);
checkRecordCoverage('details.ts', 'details', supportedCodes, serverKeys);
for (const w of warnings) console.warn(`warn: ${w}`);
if (errors.length > 0) {
for (const e of errors) console.error(`error: ${e}`);
console.error(`\n${errors.length} translation error(s).`);
process.exit(1);
}
console.log(
`i18n OK — ${supportedCodes.length} languages, ${warnings.length} warning(s).`
);
}
main();

View file

@ -15,11 +15,13 @@ import { TickerValue } from '../ui/TickerValue';
import {
ChartBarIcon,
CheckIcon,
ChevronIcon,
ClipboardIcon,
DownloadIcon,
FilterIcon,
LogoIcon,
MapPinIcon,
PlayIcon,
RouteIcon,
} from '../ui/icons';
import { trackEvent } from '../../lib/analytics';
@ -84,7 +86,9 @@ function highlightBrandText(text: string) {
function ProductDemoVideo() {
const sectionRef = useRef<HTMLDivElement | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const [shouldLoadVideo, setShouldLoadVideo] = useState(false);
const [isVideoPlaying, setIsVideoPlaying] = useState(false);
useEffect(() => {
const section = sectionRef.current;
@ -108,14 +112,30 @@ function ProductDemoVideo() {
return () => observer.disconnect();
}, [shouldLoadVideo]);
const playVideo = () => {
const video = videoRef.current;
setShouldLoadVideo(true);
if (!video) return;
if (!video.getAttribute('src')) {
video.src = PRODUCT_DEMO_VIDEO_SRC;
video.load();
}
void video.play().catch(() => {
setIsVideoPlaying(false);
});
};
return (
<div
id={PRODUCT_DEMO_SECTION_ID}
ref={sectionRef}
className={`${HOME_SECTION_CONTAINER_CLASS} pt-8 md:pt-12 pb-2`}
>
<div className="overflow-hidden rounded-lg border border-warm-200 bg-navy-950 shadow-sm dark:border-warm-700">
<div className="relative overflow-hidden rounded-lg border border-warm-200 bg-navy-950 shadow-sm dark:border-warm-700">
<video
ref={videoRef}
src={shouldLoadVideo ? PRODUCT_DEMO_VIDEO_SRC : undefined}
poster={PRODUCT_DEMO_POSTER_SRC}
controls
@ -123,7 +143,22 @@ function ProductDemoVideo() {
preload={shouldLoadVideo ? 'metadata' : 'none'}
className="block aspect-video w-full bg-navy-950 object-contain"
aria-label="Perfect Postcode product demo"
onPlay={() => setIsVideoPlaying(true)}
onPause={() => setIsVideoPlaying(false)}
onEnded={() => setIsVideoPlaying(false)}
/>
{!isVideoPlaying && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-navy-950/15 transition-colors">
<button
type="button"
onClick={playVideo}
className="pointer-events-auto group flex h-20 w-20 items-center justify-center rounded-full bg-white/95 text-coral-500 shadow-2xl shadow-navy-950/40 ring-1 ring-white/60 transition-transform hover:scale-105 focus:outline-none focus-visible:scale-105 focus-visible:ring-4 focus-visible:ring-teal-300/75 md:h-24 md:w-24"
aria-label="Play Perfect Postcode product demo"
>
<PlayIcon className="h-11 w-11 -translate-x-0.5 md:h-14 md:w-14" />
</button>
</div>
)}
</div>
</div>
);
@ -131,7 +166,7 @@ function ProductDemoVideo() {
const DEMO_FEATURES: FeatureMeta[] = [
{
name: 'Last known price',
name: 'Estimated price',
type: 'numeric',
group: 'Properties',
min: 0,
@ -165,13 +200,13 @@ const DEMO_FEATURES: FeatureMeta[] = [
suffix: ' dB',
},
{
name: 'Distance to nearest train or tube station (km)',
name: 'Travel time to nearest train or tube station (min)',
type: 'numeric',
group: 'Transport',
min: 0,
max: 5,
step: 0.1,
suffix: ' km',
max: 60,
step: 1,
suffix: ' min',
},
];
@ -234,15 +269,6 @@ const HOUSE_PRICE_BREAKDOWN = [
{ label: '£/sq ft', value: '£872', helper: 'local median' },
];
const RECENT_SALES = [
{ address: '2-bed flat, Lexham Gardens', price: '£612k', meta: 'Mar 2025 · 710 sq ft' },
{ address: '1-bed flat, Earl\'s Court Road', price: '£485k', meta: 'Feb 2025 · 544 sq ft' },
{ address: '3-bed maisonette, Warwick Road', price: '£918k', meta: 'Jan 2025 · 1,020 sq ft' },
{ address: 'Studio, Courtfield Gardens', price: '£365k', meta: 'Dec 2024 · 361 sq ft' },
{ address: '2-bed flat, Collingham Place', price: '£755k', meta: 'Nov 2024 · 814 sq ft' },
{ address: '4-bed terrace, Nevern Square', price: '£1.64m', meta: 'Oct 2024 · 1,780 sq ft' },
];
const PRICE_SIGNALS = [
['5-year change', '+22%'],
['Last 12 months', '+2.1%'],
@ -295,9 +321,9 @@ const ENGLAND_SHOWCASE_POLYGON: number[][] = [
];
const SHOWCASE_MAP_START_VIEW: ViewState = {
longitude: -1.52,
latitude: 52.92,
zoom: 5.55,
longitude: -1.7,
latitude: 52.7,
zoom: 6.6,
pitch: 0,
bearing: 0,
};
@ -386,10 +412,10 @@ function interpolateViewState(progress: number): ViewState {
}
function demoSliderStep(feature: FeatureMeta): number {
if (feature.name === 'Last known price') return 1000;
if (feature.name === 'Estimated price') return 1000;
if (feature.name === 'Noise (dB)') return 0.05;
if (feature.name === 'Good+ primary schools within 2km') return 0.01;
if (feature.name === 'Distance to nearest train or tube station (km)') return 0.01;
if (feature.name === 'Travel time to nearest train or tube station (min)') return 0.1;
return feature.step ?? 1;
}
@ -420,7 +446,7 @@ function FilterPreviewRow({
feature,
value,
rangeLabel,
withoutLabel,
withoutCount,
index,
isTightened,
onValueChange,
@ -428,19 +454,21 @@ function FilterPreviewRow({
feature: FeatureMeta;
value: [number, number];
rangeLabel: string;
withoutLabel: string;
withoutCount: number;
index: number;
isTightened: boolean;
onValueChange: (value: [number, number]) => void;
}) {
const { t } = useTranslation();
const style = FILTER_ROW_STYLES[index % FILTER_ROW_STYLES.length];
const shortLabel =
{
'Last known price': 'Price',
'Noise (dB)': 'Noise',
'Good+ primary schools within 2km': 'Schools',
'Distance to nearest train or tube station (km)': 'Station',
}[feature.name] ?? feature.name;
const shortLabelKeys = {
'Estimated price': 'home.showcaseFeaturePriceShort',
'Noise (dB)': 'home.showcaseFeatureNoiseShort',
'Good+ primary schools within 2km': 'home.showcaseFeatureSchoolsShort',
'Travel time to nearest train or tube station (min)': 'home.showcaseFeatureTravelShort',
} as const;
const shortLabelKey = shortLabelKeys[feature.name as keyof typeof shortLabelKeys];
const shortLabel = shortLabelKey ? t(shortLabelKey) : undefined;
return (
<div
@ -453,7 +481,7 @@ function FilterPreviewRow({
<div className="min-w-0">
<FeatureLabel feature={feature} size="sm" className="hidden sm:flex" />
<div className="flex items-center gap-2 text-sm font-semibold text-warm-700 dark:text-warm-300 sm:hidden">
<FeatureLabel feature={{ ...feature, name: shortLabel }} size="sm" />
<FeatureLabel feature={feature} size="sm" label={shortLabel} />
</div>
<div className="mt-1 text-sm font-medium text-warm-500 dark:text-warm-400">
{rangeLabel}
@ -462,7 +490,9 @@ function FilterPreviewRow({
<span
className={`w-fit shrink-0 rounded-md px-2.5 py-1 text-xs font-bold leading-none ${style.chip}`}
>
{withoutLabel}
+
<span className="font-mono tabular-nums">{withoutCount.toLocaleString()}</span>
{' without this filter'}
</span>
</div>
<div className="mt-3 px-2 pl-4 sm:mt-5">
@ -484,7 +514,7 @@ function formatCompactCurrency(value: number): string {
}
function formatDemoRange(feature: FeatureMeta, value: [number, number]): string {
if (feature.name === 'Last known price') {
if (feature.name === 'Estimated price') {
return `${formatCompactCurrency(value[0])} - ${formatCompactCurrency(value[1])}`;
}
if (feature.name === 'Noise (dB)') {
@ -493,8 +523,8 @@ function formatDemoRange(feature: FeatureMeta, value: [number, number]): string
if (feature.name === 'Good+ primary schools within 2km') {
return `${Math.round(value[0])}+ good primaries nearby`;
}
if (feature.name === 'Distance to nearest train or tube station (km)') {
return `Within ${value[1].toFixed(1)} km of rail`;
if (feature.name === 'Travel time to nearest train or tube station (min)') {
return `Within ${Math.round(value[1])} min of rail`;
}
return `${value[0]} - ${value[1]}`;
}
@ -539,32 +569,36 @@ function FilterOnlyScreen({ isActive }: { isActive: boolean }) {
[240000, 535000],
[135000, 485000],
[285000, 610000],
[0, 650000],
] as [number, number][],
without: [41820, 50622, 24860, 18645, 29796],
without: [41820, 50622, 24860, 18645, 29796, 41820],
},
{
feature: DEMO_FEATURES[3],
values: [
[40, 58],
[43, 52],
[40, 58],
] as [number, number][],
without: [19412, 8706],
without: [19412, 8706, 19412],
},
{
feature: DEMO_FEATURES[4],
values: [
[0, 60],
[5, 25],
[0, 60],
] as [number, number][],
without: [11209, 4118, 11209],
},
{
feature: DEMO_FEATURES[2],
values: [
[1, 8],
[2, 6],
[1, 8],
] as [number, number][],
without: [13608, 6944],
},
{
feature: DEMO_FEATURES[4],
values: [
[0, 1.4],
[0.25, 0.9],
] as [number, number][],
without: [11209, 4118],
without: [13608, 6944, 13608],
},
],
[]
@ -617,15 +651,15 @@ function FilterOnlyScreen({ isActive }: { isActive: boolean }) {
};
return (
<div className="h-full overflow-y-auto bg-white scrollbar-hide dark:bg-navy-950">
<div className="mx-auto grid min-h-full max-w-3xl content-start gap-2 p-2.5 sm:content-between sm:gap-3 sm:p-4">
<div className="h-full overflow-y-auto bg-white scrollbar-hide dark:bg-navy-950/50 dark:backdrop-blur-sm">
<div className="mx-auto grid min-h-full max-w-3xl content-start gap-2 p-2.5 sm:content-center sm:gap-3 sm:p-4">
{rows.map((row, index) => (
<div key={row.feature.name} className={index >= 3 ? 'hidden sm:block' : undefined}>
<FilterPreviewRow
feature={row.feature}
value={filterState[index].value}
rangeLabel={formatDemoRange(row.feature, filterState[index].value)}
withoutLabel={`+${filterState[index].without.toLocaleString()} without this filter`}
withoutCount={filterState[index].without}
index={index}
isTightened
onValueChange={(value) => updateFilter(index, value)}
@ -671,7 +705,7 @@ function EnglandHexMapScreen({ isActive }: { isActive: boolean }) {
}, [isActive]);
return (
<div className="pointer-events-none relative h-full overflow-hidden bg-warm-100 dark:bg-navy-950">
<div className="pointer-events-none relative h-full overflow-hidden bg-warm-100 dark:bg-navy-950/50">
<ProductMap
data={SHOWCASE_MAP_DATA}
postcodeData={EMPTY_SHOWCASE_POSTCODES}
@ -758,9 +792,9 @@ function RightPaneOnlyScreen({
};
return (
<div className="h-full overflow-hidden bg-white dark:bg-navy-900">
<div className="h-full overflow-hidden bg-white dark:bg-navy-900/60 dark:backdrop-blur-sm">
<div className="flex h-full flex-col overflow-hidden">
<div className="bg-white px-3 py-3 shadow-sm dark:bg-navy-900 sm:px-5 sm:py-4">
<div className="bg-white px-3 py-3 shadow-sm dark:bg-navy-900/65 sm:px-5 sm:py-4">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-teal-50 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300 sm:h-9 sm:w-9">
<MapPinIcon className="h-4 w-4 sm:h-5 sm:w-5" />
@ -771,22 +805,6 @@ function RightPaneOnlyScreen({
</div>
</div>
</div>
<div className="mt-3 grid grid-cols-3 divide-x divide-warm-200 rounded-md bg-warm-50 text-center dark:divide-navy-700 dark:bg-navy-950 sm:mt-4">
{[
['Sales', '1,284'],
['Median sold', '£492k'],
['Scout rank', '#3'],
].map(([label, value]) => (
<div key={label} className="px-1.5 py-1.5 sm:px-2 sm:py-2">
<div className="text-xs font-bold uppercase text-warm-400">
{label}
</div>
<div className="mt-0.5 text-sm font-black text-navy-950 dark:text-warm-100">
{value}
</div>
</div>
))}
</div>
</div>
<div
ref={scrollerRef}
@ -795,7 +813,7 @@ function RightPaneOnlyScreen({
onTouchStart={markUserScrolled}
onWheel={markUserScrolled}
>
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950 sm:p-4">
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950/50 sm:p-4">
<div className="mb-1 flex items-center justify-between gap-3">
<FeatureLabel feature={DEMO_FEATURES[0]} size="sm" />
<span className="shrink-0 text-xs font-black text-teal-700 dark:text-teal-300">
@ -807,7 +825,7 @@ function RightPaneOnlyScreen({
{HOUSE_PRICE_BREAKDOWN.map((item) => (
<div
key={item.label}
className="rounded-md bg-white px-2.5 py-2 shadow-sm shadow-navy-950/5 dark:bg-navy-900 sm:px-3"
className="rounded-md bg-white px-2.5 py-2 shadow-sm shadow-navy-950/5 dark:bg-navy-900/60 sm:px-3"
>
<div className="text-xs font-bold uppercase text-warm-400">
{item.label}
@ -823,35 +841,7 @@ function RightPaneOnlyScreen({
</div>
))}
</div>
<div className="mt-3 rounded-md bg-white p-3 shadow-sm shadow-navy-950/5 dark:bg-navy-900">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-xs font-black text-navy-950 dark:text-warm-100">
Recent sold prices
</span>
<span className="text-xs font-bold uppercase text-warm-400">within 0.5 mi</span>
</div>
<div className="space-y-2">
{RECENT_SALES.map((sale, index) => (
<div
key={sale.address}
className={`items-baseline justify-between gap-3 ${
index >= 3 ? 'hidden sm:flex' : 'flex'
}`}
>
<div className="min-w-0">
<div className="truncate text-xs font-semibold text-warm-700 dark:text-warm-300">
{sale.address}
</div>
<div className="text-xs font-medium text-warm-400">{sale.meta}</div>
</div>
<div className="shrink-0 text-xs font-black text-navy-950 dark:text-warm-100">
{sale.price}
</div>
</div>
))}
</div>
</div>
<div className="mt-3 grid grid-cols-3 divide-x divide-warm-200 rounded-md bg-white text-center shadow-sm shadow-navy-950/5 dark:divide-navy-700 dark:bg-navy-900">
<div className="mt-3 grid grid-cols-3 divide-x divide-warm-200 rounded-md bg-white text-center shadow-sm shadow-navy-950/5 dark:divide-navy-700 dark:bg-navy-900/60">
{PRICE_SIGNALS.map(([label, value]) => (
<div key={label} className="px-2 py-2">
<div className="text-xs font-bold uppercase text-warm-400">{label}</div>
@ -862,7 +852,7 @@ function RightPaneOnlyScreen({
))}
</div>
</div>
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950 sm:p-4">
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950/50 sm:p-4">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-warm-700 dark:text-warm-300">
<RouteIcon className="h-4 w-4 text-teal-600 dark:text-teal-400" />
<span>Journey routes</span>
@ -876,7 +866,7 @@ function RightPaneOnlyScreen({
showGoogleMapsLink={false}
/>
</div>
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950 sm:p-4">
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950/50 sm:p-4">
<div className="mb-2 flex items-center justify-between gap-3">
<FeatureLabel feature={DEMO_FEATURES[2]} size="sm" />
<span className="shrink-0 text-xs font-black text-teal-700 dark:text-teal-300">
@ -892,14 +882,14 @@ function RightPaneOnlyScreen({
formatLabel={(value) => value.toFixed(0)}
/>
</div>
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950 sm:p-4">
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950/50 sm:p-4">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-warm-700 dark:text-warm-300">
<ChartBarIcon className="h-4 w-4 text-teal-600 dark:text-teal-400" />
<span>{t('home.showcaseStep3Stat2Label')}</span>
</div>
<StackedBarChart segments={CRIME_SEGMENTS} total={82} />
</div>
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950 sm:p-4">
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950/50 sm:p-4">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold text-warm-700 dark:text-warm-300">
<ChartBarIcon className="h-4 w-4 shrink-0 text-teal-600 dark:text-teal-400" />
@ -928,10 +918,10 @@ function ScoutScreen() {
const { t } = useTranslation();
return (
<div className="relative flex h-full min-h-0 flex-col overflow-hidden bg-[#f7f3ed] p-3 dark:bg-navy-950 sm:p-5">
<div className="relative z-10 mt-5 rounded-lg bg-white p-3 shadow-2xl shadow-navy-950/10 dark:bg-navy-900 sm:mt-9 sm:p-5">
<div className="relative flex h-full min-h-0 flex-col overflow-hidden bg-[#f7f3ed] p-3 dark:bg-navy-950/45 sm:p-5">
<div className="relative z-10 mt-5 rounded-lg bg-white p-3 shadow-2xl shadow-navy-950/10 dark:bg-navy-900/65 dark:backdrop-blur-sm sm:mt-9 sm:p-5">
<div className="grid grid-cols-2 gap-2 sm:gap-4">
<div className="cursor-default select-none rounded-lg border border-warm-200 bg-warm-50 p-3 shadow-sm dark:border-navy-700 dark:bg-navy-950 sm:p-4">
<div className="cursor-default select-none rounded-lg border border-warm-200 bg-warm-50 p-3 shadow-sm dark:border-navy-700 dark:bg-navy-950/50 sm:p-4">
<div className="flex items-center gap-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-teal-50 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300 sm:h-10 sm:w-10">
<ClipboardIcon className="h-4 w-4 sm:h-5 sm:w-5" />
@ -973,13 +963,16 @@ function ScoutScreen() {
aria-hidden="true"
>
<path
d="M50 4 L50 78"
d="M50 4 L50 92"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeDasharray="4 4"
/>
<polygon points="50,92 39,76 61,76" fill="currentColor" />
<polygon
points="50,92 44.5,84 55.5,84"
className="fill-teal-500 dark:fill-teal-300"
/>
</svg>
<svg
className="pointer-events-none absolute right-14 top-[8.75rem] z-20 h-36 w-20 text-teal-500/80 dark:text-teal-300/80 sm:right-12 sm:top-[11rem] sm:h-56 sm:w-72"
@ -988,21 +981,20 @@ function ScoutScreen() {
aria-hidden="true"
>
<path
d="M50 4 L50 78"
d="M50 4 L50 92"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeDasharray="4 4"
/>
<polygon points="50,92 39,76 61,76" fill="currentColor" />
<polygon
points="50,92 44.5,84 55.5,84"
className="fill-teal-500 dark:fill-teal-300"
/>
</svg>
<div className="relative z-10 mt-auto rounded-lg bg-navy-950 p-4 text-white shadow-2xl shadow-navy-950/20 sm:p-5">
<div className="inline-flex items-center gap-2 rounded-md bg-teal-400/10 px-2.5 py-1 text-xs font-bold uppercase text-teal-200">
<RouteIcon className="h-3.5 w-3.5" />
{t('home.showcaseStep4Title')}
</div>
<div className="mt-3 text-lg font-black leading-tight sm:mt-4">
<div className="relative z-10 mt-auto rounded-lg bg-navy-950/55 p-4 text-white shadow-2xl shadow-navy-950/20 backdrop-blur-sm sm:p-5">
<div className="text-lg font-black leading-tight">
{t('home.showcaseStep4Conclusion')}
</div>
<div className="mt-4 grid gap-2 text-xs font-medium leading-relaxed text-warm-300 sm:mt-5 sm:gap-3 sm:text-sm">
@ -1045,9 +1037,9 @@ function DashboardShowcase({
const showStageHeader = activeStep !== 3;
return (
<div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-warm-100 text-navy-950 dark:bg-navy-950 dark:text-warm-100">
<div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-warm-100 text-navy-950 dark:bg-navy-950/45 dark:text-warm-100">
{showStageHeader && (
<div className="shrink-0 bg-navy-950 px-3 py-2 text-white shadow-sm sm:px-4 sm:py-3">
<div className="shrink-0 bg-navy-950/55 px-3 py-2 text-white shadow-sm backdrop-blur-sm sm:px-4 sm:py-3">
<div className="flex items-start gap-2 sm:gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-teal-400/10 text-teal-200 sm:h-9 sm:w-9">
<ActiveIcon className="h-4 w-4" />
@ -1086,23 +1078,9 @@ function DashboardShowcase({
function HeroProductShowcase() {
const { t } = useTranslation();
const [activeStep, setActiveStep] = useState(0);
const isStagePausedRef = useRef(false);
const [isStagePaused, setIsStagePaused] = useState(false);
const inspectUserScrolledRef = useRef(false);
useEffect(() => {
const prefersReducedMotion =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) return;
const timer = window.setInterval(() => {
if (isStagePausedRef.current) return;
setActiveStep((step) => (step + 1) % SHOWCASE_STEP_COUNT);
}, SHOWCASE_INTERVAL_MS);
return () => window.clearInterval(timer);
}, []);
const steps: ShowcaseStep[] = [
{
tab: t('home.showcaseStep1Tab'),
@ -1134,22 +1112,14 @@ function HeroProductShowcase() {
return (
<div
className="dark relative w-full min-w-0 max-w-[58rem]"
onMouseEnter={() => {
isStagePausedRef.current = true;
}}
onMouseLeave={() => {
isStagePausedRef.current = false;
}}
onFocus={() => {
isStagePausedRef.current = true;
}}
onBlur={() => {
isStagePausedRef.current = false;
}}
className="dark relative w-full min-w-0 max-w-[58rem] justify-self-center lg:max-w-none lg:justify-self-stretch"
onMouseEnter={() => setIsStagePaused(true)}
onMouseLeave={() => setIsStagePaused(false)}
onFocus={() => setIsStagePaused(true)}
onBlur={() => setIsStagePaused(false)}
aria-label={t('home.showcaseContext')}
>
<div className="flex h-[34rem] flex-col overflow-hidden rounded-lg bg-[#080d19] shadow-2xl shadow-black/40 sm:h-[46rem] md:h-[50rem] lg:h-[45.5rem] xl:h-[43rem]">
<div className="flex h-[34rem] flex-col overflow-hidden rounded-lg bg-navy-950/50 shadow-2xl shadow-black/40 backdrop-blur-sm ring-1 ring-white/10 sm:h-[46rem] md:h-[50rem] lg:h-[47rem] xl:h-[46rem]">
<div className="shrink-0 bg-white/[0.035] p-1.5 sm:p-2 md:p-3">
<div className="grid grid-cols-4 gap-1 sm:gap-2">
{steps.map((step, index) => {
@ -1184,7 +1154,11 @@ function HeroProductShowcase() {
className="showcase-progress block h-full origin-left bg-teal-400"
style={{
animationDuration: `${SHOWCASE_INTERVAL_MS}ms`,
animationPlayState: isStagePaused ? 'paused' : 'running',
}}
onAnimationEnd={() =>
setActiveStep((step) => (step + 1) % SHOWCASE_STEP_COUNT)
}
/>
)}
</span>
@ -1255,15 +1229,39 @@ export default function HomePage({
return () => clearTimeout(timer);
}, []);
const scrollToProductDemoVideo = () => {
const target = document.getElementById(PRODUCT_DEMO_SECTION_ID);
if (!target) return;
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
if (!scroller) return;
const start = scroller.scrollTop;
const end =
start +
target.getBoundingClientRect().top -
scroller.getBoundingClientRect().top +
24;
const distance = end - start;
const duration = 1200;
let startTime: number;
const step = (time: number) => {
if (!startTime) startTime = time;
const p = Math.min((time - startTime) / duration, 1);
const ease = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
scroller.scrollTop = start + distance * ease;
if (p < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
};
return (
<div className="flex-1 overflow-y-auto overflow-x-hidden bg-warm-50 dark:bg-navy-950 relative">
<div className="relative" style={{ zIndex: 1 }}>
{/* Hero */}
<div className="relative overflow-hidden bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 dark:from-navy-950 dark:via-navy-900 dark:to-navy-950 min-h-[calc(100dvh-3rem)] flex flex-col">
<HexCanvas isDark={theme === 'dark'} animated={false} />
<div className="relative z-10 max-w-[96rem] mx-auto w-full px-6 md:px-10 pt-6 pb-8 md:pt-24 lg:pb-0 backdrop-blur-[2px] flex-1 flex flex-col">
<div className="grid gap-x-8 gap-y-6 lg:grid-cols-[0.85fr_1.15fr] lg:gap-12 items-center">
<div className="min-w-0 max-w-4xl">
<div className="relative z-10 mx-auto flex w-full max-w-[104rem] flex-1 flex-col px-6 pb-8 pt-6 backdrop-blur-[2px] md:px-10 md:py-10 lg:py-12">
<div className="hero-roomy-lift grid flex-1 items-center gap-x-8 gap-y-6 lg:grid-cols-[minmax(0,0.82fr)_minmax(38rem,1.18fr)] lg:gap-12 xl:grid-cols-[minmax(0,0.78fr)_minmax(44rem,1.22fr)] xl:gap-16">
<div className="min-w-0 max-w-4xl lg:max-w-[42rem] xl:max-w-[45rem]">
<p className="text-sm font-semibold text-teal-300 mb-3">{t('home.heroEyebrow')}</p>
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-4 leading-[1.1]">
{t('home.heroTitle1')}{' '}
@ -1290,27 +1288,7 @@ export default function HomePage({
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'see_difference' });
const target = document.getElementById(PRODUCT_DEMO_SECTION_ID);
if (!target) return;
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
if (!scroller) return;
const start = scroller.scrollTop;
const end =
start +
target.getBoundingClientRect().top -
scroller.getBoundingClientRect().top -
48;
const distance = end - start;
const duration = 1200;
let startTime: number;
const step = (time: number) => {
if (!startTime) startTime = time;
const p = Math.min((time - startTime) / duration, 1);
const ease = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
scroller.scrollTop = start + distance * ease;
if (p < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
scrollToProductDemoVideo();
}}
className="px-7 py-3 border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base text-center"
>
@ -1340,8 +1318,18 @@ export default function HomePage({
</div>
<HeroProductShowcase />
</div>
<div className="flex-1" />
</div>
<button
type="button"
className="hero-scroll-chevron absolute bottom-4 left-1/2 z-20 -translate-x-1/2 items-center justify-center rounded-full text-white transition-colors hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-white/50"
aria-label="Scroll to product demo"
onClick={() => {
trackEvent('CTA Click', { location: 'hero_chevron', label: 'scroll_down' });
scrollToProductDemoVideo();
}}
>
<ChevronIcon direction="down" className="h-14 w-14" />
</button>
</div>
<div className="home-content-surface relative overflow-hidden">

View file

@ -67,6 +67,7 @@ interface MapProps {
currentLocation?: { lat: number; lng: number } | null;
bounds?: Bounds | null;
hideLegend?: boolean;
hideLocationSearch?: boolean;
travelTimeEntries?: TravelTimeEntry[];
densityLabel?: string;
totalCount?: number;
@ -173,6 +174,7 @@ export default memo(function Map({
currentLocation,
bounds: viewportBounds,
hideLegend = false,
hideLocationSearch = false,
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
densityLabel: densityLabelProp,
totalCount: totalCountProp,
@ -231,6 +233,7 @@ export default memo(function Map({
}, [viewState, dimensions, onViewChange]);
const handleMove = useCallback((evt: { viewState: ViewState }) => {
if (window.__demoRecording) window.__demoMapIdle = false;
setInternalViewState((prev) => {
const next = evt.viewState;
// Skip re-render when viewport values haven't changed (e.g. container resize
@ -249,6 +252,14 @@ export default memo(function Map({
});
}, []);
const handleIdle = useCallback(() => {
if (screenshotMode) window.__map_idle = true;
if (window.__demoRecording) {
window.__demoMapIdle = true;
window.__demoMapIdleVersion = (window.__demoMapIdleVersion ?? 0) + 1;
}
}, [screenshotMode]);
const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => {
setInternalViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
}, []);
@ -293,13 +304,7 @@ export default memo(function Map({
{...viewState}
onMove={handleMove}
onLoad={undefined}
onIdle={
screenshotMode
? () => {
window.__map_idle = true;
}
: undefined
}
onIdle={handleIdle}
mapStyle={mapStyle}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
@ -362,12 +367,14 @@ export default memo(function Map({
) : (
<>
<div className="absolute top-3 left-3 right-3 z-[60] flex flex-wrap items-start justify-between gap-2 pointer-events-none">
<LocationSearch
onFlyTo={handleFlyTo}
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
onMouseEnter={handleMouseLeave}
/>
{!hideLocationSearch && (
<LocationSearch
onFlyTo={handleFlyTo}
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
onMouseEnter={handleMouseLeave}
/>
)}
{!hideLegend &&
(viewFeature && colorRange ? (
viewFeature.startsWith('tt_') ? (

View file

@ -33,6 +33,7 @@ import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../
import { useFilterCounts } from '../../hooks/useFilterCounts';
import { ts } from '../../i18n/server';
import { trackEvent } from '../../lib/analytics';
import { canWheelScrollInsideTarget } from '../../lib/dom-scroll';
import { INITIAL_VIEW_STATE } from '../../lib/consts';
import { getSchoolBackendFeatureName } from '../../lib/school-filter';
import { useLicense } from '../../hooks/useLicense';
@ -54,6 +55,18 @@ const MapPageSelectionPane = lazy(() =>
const UpgradeModal = lazy(() => import('../ui/UpgradeModal'));
const Joyride = lazy(() => import('react-joyride'));
declare global {
interface Window {
__demoRecording?: boolean;
__demoOpenBestHexagon?: () => string | null;
__demoMapSettled?: boolean;
__demoMapSettleVersion?: number;
__demoMapIdle?: boolean;
__demoMapIdleVersion?: number;
__demoSelectionReady?: boolean;
}
}
function MapFallback() {
return (
<div className="flex h-full w-full items-center justify-center bg-warm-100 dark:bg-navy-950">
@ -218,6 +231,9 @@ export default function MapPage({
travelTimeEntries: entries,
shareCode,
});
const demoMapHasData = mapData.usePostcodeView
? mapData.postcodeData.length > 0
: mapData.data.length > 0;
const handleAiFilterSubmit = useCallback(
async (query: string) => {
@ -416,6 +432,48 @@ export default function MapPage({
setRightPaneTab(initialTab);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!window.__demoRecording) return;
void import('./MapPageSelectionPane');
void import('./AreaPane');
void import('./PropertiesPane');
}, []);
useEffect(() => {
if (!window.__demoRecording) return;
window.__demoMapSettled = !mapData.loading && demoMapHasData;
if (window.__demoMapSettled) {
window.__demoMapSettleVersion = (window.__demoMapSettleVersion ?? 0) + 1;
}
return () => {
window.__demoMapSettled = false;
};
}, [demoMapHasData, mapData.loading]);
useEffect(() => {
if (!window.__demoRecording) return;
window.__demoSelectionReady = Boolean(selectedHexagon && areaStats && !loadingAreaStats);
return () => {
window.__demoSelectionReady = false;
};
}, [areaStats, loadingAreaStats, selectedHexagon]);
useEffect(() => {
if (!window.__demoRecording) return;
window.__demoOpenBestHexagon = () => {
const best = mapData.data.reduce<(typeof mapData.data)[number] | null>((winner, item) => {
if (!winner || item.count > winner.count) return item;
return winner;
}, null);
if (!best) return null;
handleHexagonClick(best.h3);
return best.h3;
};
return () => {
delete window.__demoOpenBestHexagon;
};
}, [handleHexagonClick, mapData.data]);
// Navigate to a specific postcode on mount (e.g. from saved properties)
useEffect(() => {
if (!initialPostcode) return;
@ -451,7 +509,10 @@ export default function MapPage({
// Prevent browser back/forward navigation from horizontal trackpad swipes
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
if (
Math.abs(e.deltaX) > Math.abs(e.deltaY) &&
!canWheelScrollInsideTarget(e.target, e.deltaX, e.deltaY)
) {
e.preventDefault();
}
};
@ -873,6 +934,7 @@ export default function MapPage({
currentLocation={currentLocation}
bounds={mapData.bounds}
hideLegend
hideLocationSearch={mobileDrawerOpen && !!selectedHexagon}
travelTimeEntries={entries}
/>
</Suspense>
@ -898,7 +960,7 @@ export default function MapPage({
</button>
{poiPaneOpen && (
<div className="absolute top-14 right-3 left-3 z-20 max-h-[45dvh] overflow-hidden flex flex-col rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900">
<div className="absolute top-14 right-3 left-3 z-20 flex h-[45dvh] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
{renderPOIPane()}
</div>
)}
@ -1045,7 +1107,7 @@ export default function MapPage({
</button>
{/* Floating POI panel */}
{poiPaneOpen && (
<div className="absolute bottom-14 right-4 z-10 w-80 max-h-[60vh] overflow-hidden flex flex-col rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900">
<div className="absolute bottom-14 right-4 z-10 flex h-[60vh] min-h-0 w-80 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
{renderPOIPane()}
</div>
)}
@ -1062,6 +1124,7 @@ export default function MapPage({
onClose={handleCloseSelection}
renderAreaPane={renderAreaPane}
renderPropertiesPane={renderPropertiesPane}
demoReady={Boolean(areaStats && !loadingAreaStats)}
/>
</Suspense>
)}

View file

@ -18,6 +18,7 @@ interface MapPageSelectionPaneProps {
onClose: () => void;
renderAreaPane: () => ReactNode;
renderPropertiesPane: () => ReactNode;
demoReady?: boolean;
}
export function MapPageSelectionPane({
@ -29,10 +30,12 @@ export function MapPageSelectionPane({
onClose,
renderAreaPane,
renderPropertiesPane,
demoReady = false,
}: MapPageSelectionPaneProps) {
return (
<div
data-tutorial="right-pane"
data-demo-ready={demoReady ? 'true' : 'false'}
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
style={{ width }}
>

View file

@ -90,7 +90,7 @@ export default function POIPane({
const selectedCount = selectedCategories.size;
return (
<div className="flex flex-col h-full bg-white dark:bg-warm-900 shadow-lg overflow-hidden">
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-white shadow-lg dark:bg-warm-900">
<div className="flex-shrink-0 px-3 pt-3 pb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide">
@ -150,7 +150,7 @@ export default function POIPane({
)}
</div>
<div className="flex-1 overflow-y-auto border-t border-warm-200 dark:border-warm-700">
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain border-t border-warm-200 dark:border-warm-700">
<div className="px-3 pt-2 pb-1">
<SearchInput
value={searchTerm}

View file

@ -11,6 +11,7 @@ interface FeatureLabelProps {
className?: string;
size?: 'xs' | 'sm';
description?: string;
label?: string;
hideIconOnMobile?: boolean;
}
@ -20,6 +21,7 @@ export function FeatureLabel({
className = '',
size = 'xs',
description,
label,
hideIconOnMobile,
}: FeatureLabelProps) {
const { t } = useTranslation();
@ -29,7 +31,7 @@ export function FeatureLabel({
const featureIcon = getFeatureIcon(feature.name, iconClass);
const GroupIcon = !featureIcon && feature.group ? getGroupIcon(feature.group) : null;
const translatedName = ts(feature.name);
const translatedName = label ?? ts(feature.name);
const translatedDesc = description ? tsDesc(feature.name, description) : undefined;
const nameContent = (

View file

@ -8,7 +8,7 @@ interface PillGroupProps {
export function PillGroup({ children, className = '' }: PillGroupProps) {
return (
<div
className={`flex flex-nowrap overflow-x-auto gap-1.5 md:flex-wrap md:overflow-x-visible scrollbar-hide ${className}`}
className={`flex min-w-0 max-w-full flex-nowrap gap-1.5 overflow-x-auto overscroll-x-contain touch-pan-x touch-pan-y scrollbar-hide md:flex-wrap md:overflow-x-visible ${className}`}
>
{children}
</div>

View file

@ -0,0 +1,11 @@
interface IconProps {
className?: string;
}
export function PlayIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M6.75 4.35v15.3a1.05 1.05 0 0 0 1.6.9l12.55-7.65a1.05 1.05 0 0 0 0-1.8L8.35 3.45a1.05 1.05 0 0 0-1.6.9z" />
</svg>
);
}

View file

@ -20,6 +20,7 @@ export { LogoIcon } from './LogoIcon';
export { MapPinIcon } from './MapPinIcon';
export { MenuIcon } from './MenuIcon';
export { MoonIcon } from './MoonIcon';
export { PlayIcon } from './PlayIcon';
export { PlusIcon } from './PlusIcon';
export { RouteIcon } from './RouteIcon';
export { SearchIcon } from './SearchIcon';

View file

@ -348,6 +348,10 @@ const de: Translations = {
seeTheDifference: 'So funktioniert es',
showcaseHeader: 'So funktioniert es',
showcaseContext: 'So funktioniert Perfect Postcode',
showcaseFeaturePriceShort: 'Preis',
showcaseFeatureNoiseShort: 'Lärm',
showcaseFeatureSchoolsShort: 'Schulen',
showcaseFeatureTravelShort: 'Fahrzeit',
showcaseStep1Tab: 'Filtern',
showcaseStep1Title: 'Aus vagen Wünschen eine präzise Suche machen',
showcaseStep1Body:
@ -801,6 +805,7 @@ const de: Translations = {
'Property type': 'Immobilientyp',
'Leasehold/Freehold': 'Erbbaurecht/Volleigentum',
'Last known price': 'Letzter bekannter Preis',
'Estimated price': 'Geschätzter Preis',
'Estimated current price': 'Geschätzter aktueller Preis',
'Price per sqm': 'Preis pro m²',
'Est. price per sqm': 'Gesch. Preis pro m²',
@ -817,6 +822,8 @@ const de: Translations = {
// ─ Feature names (Transport) ─
'Distance to nearest train or tube station (km)':
'Entfernung zum nächsten Bahn- oder U-Bahnhof (km)',
'Travel time to nearest train or tube station (min)':
'Fahrzeit zum nächsten Bahn- oder U-Bahnhof (Min.)',
// ─ Feature names (Education) ─
'Good+ primary schools within 2km': 'Gute+ Grundschulen im Umkreis von 2 km',

View file

@ -343,6 +343,10 @@ const en = {
seeTheDifference: 'See how it works',
showcaseHeader: 'How it works',
showcaseContext: 'How Perfect Postcode works',
showcaseFeaturePriceShort: 'Price',
showcaseFeatureNoiseShort: 'Noise',
showcaseFeatureSchoolsShort: 'Schools',
showcaseFeatureTravelShort: 'Travel',
showcaseStep1Tab: 'Filter',
showcaseStep1Title: 'Turn vague needs into a tight search',
showcaseStep1Body:
@ -788,6 +792,7 @@ const en = {
'Property type': 'Property type',
'Leasehold/Freehold': 'Leasehold/Freehold',
'Last known price': 'Last known price',
'Estimated price': 'Estimated price',
'Estimated current price': 'Estimated current price',
'Price per sqm': 'Price per sqm',
'Est. price per sqm': 'Est. price per sqm',
@ -804,6 +809,8 @@ const en = {
// ─ Feature names (Transport) ─
'Distance to nearest train or tube station (km)':
'Distance to nearest train or tube station (km)',
'Travel time to nearest train or tube station (min)':
'Travel time to nearest train or tube station (min)',
// ─ Feature names (Education) ─
'Good+ primary schools within 2km': 'Good+ primary schools within 2km',

View file

@ -351,6 +351,10 @@ const fr: Translations = {
seeTheDifference: 'Voir comment ça marche',
showcaseHeader: 'Comment ça marche',
showcaseContext: 'Comment fonctionne Perfect Postcode',
showcaseFeaturePriceShort: 'Prix',
showcaseFeatureNoiseShort: 'Bruit',
showcaseFeatureSchoolsShort: 'Écoles',
showcaseFeatureTravelShort: 'Trajet',
showcaseStep1Tab: 'Filtrer',
showcaseStep1Title: 'Transformez des besoins vagues en recherche précise',
showcaseStep1Body:
@ -802,6 +806,7 @@ const fr: Translations = {
'Property type': 'Type de bien',
'Leasehold/Freehold': 'Bail/Pleine propriété',
'Last known price': 'Dernier prix connu',
'Estimated price': 'Prix estimé',
'Estimated current price': 'Prix actuel estimé',
'Price per sqm': 'Prix au m²',
'Est. price per sqm': 'Prix estimé au m²',
@ -818,6 +823,8 @@ const fr: Translations = {
// ─ Feature names (Transport) ─
'Distance to nearest train or tube station (km)':
'Distance à la gare ou station de métro la plus proche (km)',
'Travel time to nearest train or tube station (min)':
'Temps de trajet jusquà la gare ou station de métro la plus proche (min)',
// ─ Feature names (Education) ─
'Good+ primary schools within 2km': 'Écoles primaires Bien+ dans un rayon de 2 km',

View file

@ -323,6 +323,10 @@ const hi: Translations = {
seeTheDifference: 'देखें यह कैसे काम करता है',
showcaseHeader: 'यह कैसे काम करता है',
showcaseContext: 'Perfect Postcode कैसे काम करता है',
showcaseFeaturePriceShort: 'कीमत',
showcaseFeatureNoiseShort: 'शोर',
showcaseFeatureSchoolsShort: 'स्कूल',
showcaseFeatureTravelShort: 'यात्रा',
showcaseStep1Tab: 'फिल्टर',
showcaseStep1Title: 'अस्पष्ट जरूरतों को सटीक खोज में बदलें',
showcaseStep1Body:
@ -738,6 +742,7 @@ const hi: Translations = {
'Property type': 'संपत्ति प्रकार',
'Leasehold/Freehold': 'लीजहोल्ड/फ्रीहोल्ड',
'Last known price': 'अंतिम ज्ञात कीमत',
'Estimated price': 'अनुमानित कीमत',
'Estimated current price': 'अनुमानित मौजूदा कीमत',
'Price per sqm': 'प्रति वर्ग मी कीमत',
'Est. price per sqm': 'अनु. प्रति वर्ग मी कीमत',
@ -751,6 +756,8 @@ const hi: Translations = {
'Potential energy rating': 'संभावित ऊर्जा रेटिंग',
'Interior height (m)': 'भीतरी ऊंचाई (मी)',
'Distance to nearest train or tube station (km)': 'निकटतम ट्रेन या ट्यूब स्टेशन तक दूरी (किमी)',
'Travel time to nearest train or tube station (min)':
'निकटतम ट्रेन या ट्यूब स्टेशन तक यात्रा समय (मिनट)',
'Good+ primary schools within 2km': '2 किमी के अंदर Good+ प्राथमिक स्कूल',
'Good+ secondary schools within 2km': '2 किमी के अंदर Good+ सेकेंडरी स्कूल',
'Good+ primary schools within 5km': '5 किमी के अंदर Good+ प्राथमिक स्कूल',

View file

@ -345,6 +345,10 @@ const hu: Translations = {
seeTheDifference: 'Így működik',
showcaseHeader: 'Így működik',
showcaseContext: 'Így működik a Perfect Postcode',
showcaseFeaturePriceShort: 'Ár',
showcaseFeatureNoiseShort: 'Zaj',
showcaseFeatureSchoolsShort: 'Iskolák',
showcaseFeatureTravelShort: 'Utazás',
showcaseStep1Tab: 'Szűrés',
showcaseStep1Title: 'A homályos igényekből pontos keresés lesz',
showcaseStep1Body:
@ -796,6 +800,7 @@ const hu: Translations = {
'Property type': 'Ingatlantípus',
'Leasehold/Freehold': 'Bérleti/Tulajdonjog',
'Last known price': 'Utolsó ismert ár',
'Estimated price': 'Becsült ár',
'Estimated current price': 'Becsült jelenlegi ár',
'Price per sqm': 'Ár per nm',
'Est. price per sqm': 'Becsült ár per nm',
@ -812,6 +817,8 @@ const hu: Translations = {
// ─ Feature names (Transport) ─
'Distance to nearest train or tube station (km)':
'Távolság a legközelebbi vonat- vagy metróállomástól (km)',
'Travel time to nearest train or tube station (min)':
'Utazási idő a legközelebbi vonat- vagy metróállomásig (perc)',
// ─ Feature names (Education) ─
'Good+ primary schools within 2km': 'Jó+ általános iskolák 2 km-en belül',

View file

@ -340,6 +340,10 @@ const zh: Translations = {
seeTheDifference: '查看使用方式',
showcaseHeader: '工作原理',
showcaseContext: 'Perfect Postcode 的工作流程',
showcaseFeaturePriceShort: '价格',
showcaseFeatureNoiseShort: '噪声',
showcaseFeatureSchoolsShort: '学校',
showcaseFeatureTravelShort: '出行',
showcaseStep1Tab: '筛选',
showcaseStep1Title: '把模糊需求变成精准搜索',
showcaseStep1Body:
@ -772,6 +776,7 @@ const zh: Translations = {
'Property type': '房产类型',
'Leasehold/Freehold': '租赁产权/永久产权',
'Last known price': '上次成交价',
'Estimated price': '估计价格',
'Estimated current price': '估计当前价格',
'Price per sqm': '每平方米价格',
'Est. price per sqm': '估计每平方米价格',
@ -787,6 +792,7 @@ const zh: Translations = {
// ─ Feature names (Transport) ─
'Distance to nearest train or tube station (km)': '到最近火车或地铁站的距离(公里)',
'Travel time to nearest train or tube station (min)': '到最近火车或地铁站的出行时间(分钟)',
// ─ Feature names (Education) ─
'Good+ primary schools within 2km': '2公里内良好+小学数量',

View file

@ -135,10 +135,42 @@ h3 {
.showcase-progress {
animation-name: showcase-progress;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-iteration-count: 1;
animation-fill-mode: forwards;
}
.hero-roomy-lift {
transform: translateY(0);
transition: transform 0.25s ease;
}
.hero-scroll-chevron {
display: none;
bottom: 0.75rem;
width: 5rem;
height: 5rem;
}
@media (min-width: 1024px) and (min-height: 900px) {
.hero-roomy-lift {
transform: translateY(-2.5rem);
}
.hero-scroll-chevron {
display: flex;
}
}
@media (min-width: 1280px) and (min-height: 1040px) {
.hero-roomy-lift {
transform: translateY(-3.5rem);
}
.hero-scroll-chevron {
bottom: 0.9rem;
}
}
@keyframes scout-export-click {
0%,
52%,

View file

@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { canWheelScrollInsideTarget } from './dom-scroll';
function setElementMetrics(
element: HTMLElement,
metrics: Partial<{
clientHeight: number;
clientWidth: number;
scrollHeight: number;
scrollWidth: number;
}>
) {
for (const [key, value] of Object.entries(metrics)) {
Object.defineProperty(element, key, { configurable: true, value });
}
}
describe('canWheelScrollInsideTarget', () => {
it('allows horizontal wheel gestures inside a horizontal scroller', () => {
const scroller = document.createElement('div');
scroller.style.overflowX = 'auto';
scroller.scrollLeft = 20;
setElementMetrics(scroller, { clientWidth: 100, scrollWidth: 240 });
const child = document.createElement('button');
scroller.appendChild(child);
document.body.appendChild(scroller);
expect(canWheelScrollInsideTarget(child, 40, 0)).toBe(true);
scroller.remove();
});
it('allows vertical wheel gestures inside a vertical scroller', () => {
const scroller = document.createElement('div');
scroller.style.overflowY = 'auto';
scroller.scrollTop = 20;
setElementMetrics(scroller, { clientHeight: 100, scrollHeight: 240 });
const child = document.createElement('button');
scroller.appendChild(child);
document.body.appendChild(scroller);
expect(canWheelScrollInsideTarget(child, 60, 20)).toBe(true);
scroller.remove();
});
it('does not allow horizontal gestures at the edge of a scroller', () => {
const scroller = document.createElement('div');
scroller.style.overflowX = 'auto';
scroller.scrollLeft = 0;
setElementMetrics(scroller, { clientWidth: 100, scrollWidth: 240 });
document.body.appendChild(scroller);
expect(canWheelScrollInsideTarget(scroller, -40, 0)).toBe(false);
scroller.remove();
});
});

View file

@ -0,0 +1,45 @@
const SCROLLABLE_OVERFLOW = new Set(['auto', 'scroll', 'overlay']);
function canScrollHorizontally(element: HTMLElement, deltaX: number): boolean {
if (deltaX === 0) return false;
const style = window.getComputedStyle(element);
if (!SCROLLABLE_OVERFLOW.has(style.overflowX)) return false;
if (element.scrollWidth <= element.clientWidth) return false;
const maxScrollLeft = element.scrollWidth - element.clientWidth;
if (deltaX < 0) return element.scrollLeft > 0;
return element.scrollLeft < maxScrollLeft - 1;
}
function canScrollVertically(element: HTMLElement, deltaY: number): boolean {
if (deltaY === 0) return false;
const style = window.getComputedStyle(element);
if (!SCROLLABLE_OVERFLOW.has(style.overflowY)) return false;
if (element.scrollHeight <= element.clientHeight) return false;
const maxScrollTop = element.scrollHeight - element.clientHeight;
if (deltaY < 0) return element.scrollTop > 0;
return element.scrollTop < maxScrollTop - 1;
}
export function canWheelScrollInsideTarget(
target: EventTarget | null,
deltaX: number,
deltaY: number
): boolean {
let element = target instanceof Element ? target : null;
while (element && element !== document.body && element !== document.documentElement) {
if (
element instanceof HTMLElement &&
(canScrollHorizontally(element, deltaX) || canScrollVertically(element, deltaY))
) {
return true;
}
element = element.parentElement;
}
return false;
}

View file

@ -19,6 +19,12 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<polyline points="15 6 21 6 21 12" />
</>
),
'Estimated price': (
<>
<polyline points="4 16 8 12 13 15 20 6" />
<polyline points="15 6 21 6 21 12" />
</>
),
'Price per sqm': (
<>
<rect x="3" y="3" width="7" height="7" />
@ -109,6 +115,18 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<line x1="12" y1="18" x2="12" y2="15" />
</>
),
'Travel time to nearest train or tube station (min)': (
<>
<rect x="5" y="3" width="14" height="10" rx="2" />
<path d="M8 17h8" />
<path d="M9 13l-2 4" />
<path d="M15 13l2 4" />
<circle cx="8.5" cy="8" r="1" fill="currentColor" />
<circle cx="15.5" cy="8" r="1" fill="currentColor" />
<path d="M12 18v3" />
<circle cx="12" cy="21" r="1.5" />
</>
),
// ── Education ────────────────────────────────
'Education, Skills and Training Score': (

View file

@ -31,9 +31,11 @@ module.exports = {
950: '#003330',
},
coral: {
50: '#fff7ed',
400: '#fb923c',
500: '#f97316',
600: '#ea580c',
700: '#c2410c',
},
warm: {
50: '#fafaf9',

View file

@ -2409,3 +2409,569 @@
2026-05-05T21:26:06.465961Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:26:06.773040Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=11318 parallel=false cells_before_filter=380 cells_after_filter=363 truncated=false bounds=50.9954,-1.3207,51.0982,-1.0405 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=0.3 json_ms=0.3 total_ms=0.6
2026-05-05T21:26:08.686005Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=8 rows=291392 parallel=true cells_before_filter=2485 cells_after_filter=2481 truncated=false bounds=50.8884,-1.6525,51.2556,-0.6513 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=3.8 json_ms=1.6 total_ms=5.3
2026-05-05T21:35:19.535962Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:35:19.536004Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:35:20.928669Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:35:20.943703Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:35:21.260328Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:35:21.263080Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:35:22.518336Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:35:22.519413Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:35:22.552338Z INFO property_map_server::routes::postcodes: GET /api/postcode/M44FZ postcode=M4 4FZ
2026-05-05T21:35:22.579079Z INFO property_map_server::routes::postcode_stats: GET /api/postcode-stats postcode=M4 4FZ total_count=144 filters=0 filters_raw="-" ms=0.4
2026-05-05T21:35:22.579978Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.6
2026-05-05T21:35:22.595218Z INFO property_map_server::routes::postcodes: GET /api/postcode/M4 4FZ postcode=M4 4FZ
2026-05-05T21:35:22.684822Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=881951b74bfffff resolution=8 total_count=2958 filters=0 filters_raw="-" ms=1.1
2026-05-05T21:35:23.324836Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b74a3ffff resolution=9 total_count=1241 filters=0 filters_raw="-" ms=0.6
2026-05-05T21:35:23.507421Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=163 cells_after_filter=163 truncated=false bounds=53.3675,-2.4548,53.5912,-2.0354 filters=0 filters_raw="-" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.9 json_ms=0.1 total_ms=3.1
2026-05-05T21:35:23.866219Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=1 travel=1 total=41544 filters_raw="-" ms=11.3
2026-05-05T21:35:32.108784Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.7
2026-05-05T21:35:32.135015Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b74a3ffff resolution=9 total_count=310 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=0.3
2026-05-05T21:35:32.136509Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.7
2026-05-05T21:35:33.790810Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.0 json_ms=0.2 total_ms=4.1
2026-05-05T21:35:33.818308Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=5 travel=1 total=20439 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=20.4
2026-05-05T21:35:37.753394Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=2156 cells_after_filter=2155 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=7.4 json_ms=2.0 total_ms=9.4
2026-05-05T21:35:39.940308Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.9 json_ms=0.1 total_ms=4.0
2026-05-05T21:35:40.981422Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.6 json_ms=0.1 total_ms=4.7
2026-05-05T21:35:41.681395Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=23.1
2026-05-05T21:36:50.130617Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:36:50.137912Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:37:36.248757Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:37:36.255038Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:37:36.856875Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:37:36.856933Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:37:36.873429Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:37:36.880771Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:40:03.751467Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:40:09.196030Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:40:10.589453Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:40:10.589833Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:40:10.622151Z INFO property_map_server::routes::postcodes: GET /api/postcode/M44FZ postcode=M4 4FZ
2026-05-05T21:40:10.650954Z INFO property_map_server::routes::postcode_stats: GET /api/postcode-stats postcode=M4 4FZ total_count=144 filters=0 filters_raw="-" ms=0.3
2026-05-05T21:40:10.651985Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.6
2026-05-05T21:40:10.699279Z INFO property_map_server::routes::postcodes: GET /api/postcode/M4 4FZ postcode=M4 4FZ
2026-05-05T21:40:10.712262Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=881951b74bfffff resolution=8 total_count=2958 filters=0 filters_raw="-" ms=1.1
2026-05-05T21:40:10.882037Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b74a3ffff resolution=9 total_count=1241 filters=0 filters_raw="-" ms=0.6
2026-05-05T21:40:11.183074Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=163 cells_after_filter=163 truncated=false bounds=53.3675,-2.4548,53.5912,-2.0354 filters=0 filters_raw="-" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.5 json_ms=0.1 total_ms=3.6
2026-05-05T21:40:11.556734Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=1 travel=1 total=41544 filters_raw="-" ms=12.9
2026-05-05T21:40:20.767960Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.5
2026-05-05T21:40:20.796799Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b74a3ffff resolution=9 total_count=310 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=0.4
2026-05-05T21:40:20.798737Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.5
2026-05-05T21:40:21.381833Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=7.7 json_ms=0.2 total_ms=7.9
2026-05-05T21:40:21.399664Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=5 travel=1 total=20439 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=20.6
2026-05-05T21:40:24.897526Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=2156 cells_after_filter=2155 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=10.6 json_ms=1.9 total_ms=12.5
2026-05-05T21:40:25.086271Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.0 json_ms=0.1 total_ms=4.1
2026-05-05T21:40:27.093792Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.6 json_ms=0.1 total_ms=2.7
2026-05-05T21:40:27.109216Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=17.8
2026-05-05T21:41:49.677712Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:41:51.102585Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:41:51.103971Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:41:51.136237Z INFO property_map_server::routes::postcodes: GET /api/postcode/M44FZ postcode=M4 4FZ
2026-05-05T21:41:51.164096Z INFO property_map_server::routes::postcode_stats: GET /api/postcode-stats postcode=M4 4FZ total_count=144 filters=0 filters_raw="-" ms=0.3
2026-05-05T21:41:51.165332Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.8
2026-05-05T21:41:51.181286Z INFO property_map_server::routes::postcodes: GET /api/postcode/M4 4FZ postcode=M4 4FZ
2026-05-05T21:41:51.269962Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=881951b74bfffff resolution=8 total_count=2958 filters=0 filters_raw="-" ms=1.1
2026-05-05T21:41:51.920774Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b74a3ffff resolution=9 total_count=1241 filters=0 filters_raw="-" ms=0.5
2026-05-05T21:41:52.111652Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=163 cells_after_filter=163 truncated=false bounds=53.3675,-2.4548,53.5912,-2.0354 filters=0 filters_raw="-" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.5 json_ms=0.1 total_ms=3.6
2026-05-05T21:41:52.396733Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=1 travel=1 total=41544 filters_raw="-" ms=12.1
2026-05-05T21:42:01.835682Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.9
2026-05-05T21:42:01.856925Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b74a3ffff resolution=9 total_count=310 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=0.4
2026-05-05T21:42:01.861410Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=4.9
2026-05-05T21:42:02.025694Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.4548,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=11.1 json_ms=0.1 total_ms=11.3
2026-05-05T21:42:03.370679Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.5 json_ms=0.2 total_ms=3.7
2026-05-05T21:42:03.390285Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=5 travel=1 total=20439 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=23.1
2026-05-05T21:42:07.747532Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=2156 cells_after_filter=2155 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=7.0 json_ms=2.2 total_ms=9.3
2026-05-05T21:42:07.971145Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.8 json_ms=0.1 total_ms=4.9
2026-05-05T21:42:10.063960Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.0 json_ms=0.1 total_ms=4.1
2026-05-05T21:42:13.030016Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=22.1
2026-05-05T21:43:23.988313Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:43:23.989906Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:43:38.937614Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:43:40.739899Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:43:40.741294Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:43:40.836366Z INFO property_map_server::routes::postcodes: GET /api/postcode/M44FZ postcode=M4 4FZ
2026-05-05T21:43:40.871628Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.7
2026-05-05T21:43:40.871677Z INFO property_map_server::routes::postcode_stats: GET /api/postcode-stats postcode=M4 4FZ total_count=144 filters=0 filters_raw="-" ms=0.3
2026-05-05T21:43:40.963658Z INFO property_map_server::routes::postcodes: GET /api/postcode/M4 4FZ postcode=M4 4FZ
2026-05-05T21:43:40.972553Z INFO property_map_server::routes::postcodes: GET /api/postcode/M4 4FZ postcode=M4 4FZ
2026-05-05T21:43:41.228623Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=163 cells_after_filter=163 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=0 filters_raw="-" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.8 json_ms=0.2 total_ms=3.0
2026-05-05T21:43:41.589568Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b74a3ffff resolution=9 total_count=1241 filters=0 filters_raw="-" ms=0.9
2026-05-05T21:43:41.611940Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=1 travel=1 total=41544 filters_raw="-" ms=15.0
2026-05-05T21:43:42.106439Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=163 cells_after_filter=163 truncated=false bounds=53.3675,-2.4548,53.5912,-2.0354 filters=0 filters_raw="-" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.9 json_ms=0.2 total_ms=5.1
2026-05-05T21:43:42.148975Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=1 travel=1 total=41544 filters_raw="-" ms=12.9
2026-05-05T21:43:50.925280Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.7
2026-05-05T21:43:50.950197Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b74a3ffff resolution=9 total_count=310 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=0.4
2026-05-05T21:43:50.952039Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.3
2026-05-05T21:43:51.901986Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.9 json_ms=0.1 total_ms=3.1
2026-05-05T21:43:51.952950Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=5 travel=1 total=20439 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=23.4
2026-05-05T21:43:56.136201Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=2156 cells_after_filter=2155 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=6.7 json_ms=1.6 total_ms=8.3
2026-05-05T21:43:56.549574Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.0 json_ms=0.2 total_ms=4.1
2026-05-05T21:43:58.005257Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.6 json_ms=0.1 total_ms=3.7
2026-05-05T21:43:58.316362Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=21.2
2026-05-05T21:43:59.526091Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:43:59.526363Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:44:05.897144Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:44:05.899027Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:44:23.329571Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=223412 parallel=true cells_before_filter=740 cells_after_filter=698 truncated=false bounds=51.4896,-0.2000,51.5404,-0.0600 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=1.6 json_ms=0.4 total_ms=2.0
2026-05-05T21:45:36.770349Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:45:36.770355Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:46:55.780524Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:46:57.151058Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:46:57.152528Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:46:57.183975Z INFO property_map_server::routes::postcodes: GET /api/postcode/M44FZ postcode=M4 4FZ
2026-05-05T21:46:57.210662Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.4
2026-05-05T21:46:57.210978Z INFO property_map_server::routes::postcode_stats: GET /api/postcode-stats postcode=M4 4FZ total_count=144 filters=0 filters_raw="-" ms=0.3
2026-05-05T21:46:57.229878Z INFO property_map_server::routes::postcodes: GET /api/postcode/M4 4FZ postcode=M4 4FZ
2026-05-05T21:46:57.313945Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=881951b74bfffff resolution=8 total_count=2958 filters=0 filters_raw="-" ms=1.1
2026-05-05T21:46:57.497295Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b74a3ffff resolution=9 total_count=1241 filters=0 filters_raw="-" ms=0.8
2026-05-05T21:46:57.674945Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=163 cells_after_filter=163 truncated=false bounds=53.3675,-2.4548,53.5912,-2.0354 filters=0 filters_raw="-" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.9 json_ms=0.1 total_ms=3.0
2026-05-05T21:46:58.291511Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=1 travel=1 total=41544 filters_raw="-" ms=12.0
2026-05-05T21:47:08.638543Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:47:08.640080Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:47:08.693213Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:47:08.696020Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:47:08.698555Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:47:08.698706Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:47:08.708540Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:47:08.712376Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:47:08.881143Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.7
2026-05-05T21:47:08.922170Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.5
2026-05-05T21:47:08.922265Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b74a3ffff resolution=9 total_count=310 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=0.4
2026-05-05T21:47:09.053354Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.4548,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.7 json_ms=0.1 total_ms=2.8
2026-05-05T21:47:09.585970Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.7 json_ms=0.1 total_ms=2.8
2026-05-05T21:47:09.606306Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=5 travel=1 total=20439 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=23.2
2026-05-05T21:47:14.368249Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=2156 cells_after_filter=2155 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=9.1 json_ms=2.6 total_ms=11.7
2026-05-05T21:47:15.268987Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.2 json_ms=0.1 total_ms=4.4
2026-05-05T21:47:16.742521Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:47:16.742526Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:47:19.144031Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.8 json_ms=0.1 total_ms=4.9
2026-05-05T21:47:21.329311Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=18.9
2026-05-05T21:48:19.552426Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:48:20.910287Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:48:20.911597Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:48:20.944503Z INFO property_map_server::routes::postcodes: GET /api/postcode/M44FZ postcode=M4 4FZ
2026-05-05T21:48:20.970972Z INFO property_map_server::routes::postcode_stats: GET /api/postcode-stats postcode=M4 4FZ total_count=144 filters=0 filters_raw="-" ms=0.3
2026-05-05T21:48:20.971485Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.4
2026-05-05T21:48:20.988965Z INFO property_map_server::routes::postcodes: GET /api/postcode/M4 4FZ postcode=M4 4FZ
2026-05-05T21:48:21.070355Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=881951b74bfffff resolution=8 total_count=2958 filters=0 filters_raw="-" ms=1.3
2026-05-05T21:48:21.194673Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b74a3ffff resolution=9 total_count=1241 filters=0 filters_raw="-" ms=0.7
2026-05-05T21:48:21.406038Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=163 cells_after_filter=163 truncated=false bounds=53.3675,-2.4548,53.5912,-2.0354 filters=0 filters_raw="-" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=5.1 json_ms=0.2 total_ms=5.3
2026-05-05T21:48:22.149166Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=1 travel=1 total=41544 filters_raw="-" ms=11.6
2026-05-05T21:48:26.000886Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:48:26.000890Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:48:27.742269Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:48:27.743395Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:48:27.877066Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:48:27.877153Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:48:27.877463Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:48:27.882051Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:48:27.891902Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:48:27.892061Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:48:31.303985Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.2
2026-05-05T21:48:31.324969Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b74a3ffff resolution=9 total_count=310 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=0.3
2026-05-05T21:48:31.326996Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.4
2026-05-05T21:48:31.688134Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.8 json_ms=0.1 total_ms=5.0
2026-05-05T21:48:32.516497Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=5 travel=1 total=20439 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=18.0
2026-05-05T21:48:38.027758Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=2156 cells_after_filter=2155 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=6.0 json_ms=1.7 total_ms=7.7
2026-05-05T21:48:38.546001Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.2 json_ms=0.1 total_ms=3.3
2026-05-05T21:48:40.622421Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.1 json_ms=0.1 total_ms=4.2
2026-05-05T21:48:42.032479Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=22.6
2026-05-05T21:49:10.338613Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:49:10.339888Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:50:46.231350Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:50:46.232494Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:50:46.232853Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:50:46.234575Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:50:46.261419Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:50:46.261843Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:50:46.286185Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:50:46.287577Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:50:47.072067Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:50:47.072362Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:50:47.073288Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:50:47.076801Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:50:47.117663Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:50:47.117695Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:50:47.118146Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:50:47.125177Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:50:52.820159Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:50:52.826259Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:50:52.826495Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:50:52.836857Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:50:52.858218Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:50:52.858230Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:50:52.894119Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:50:52.895473Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:52:16.280628Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:52:17.757767Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:52:17.759242Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:52:17.784497Z INFO property_map_server::routes::postcodes: GET /api/postcode/M44FZ postcode=M4 4FZ
2026-05-05T21:52:17.795311Z INFO property_map_server::routes::postcode_stats: GET /api/postcode-stats postcode=M4 4FZ total_count=144 filters=0 filters_raw="-" ms=0.4
2026-05-05T21:52:17.821652Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.4
2026-05-05T21:52:17.830328Z INFO property_map_server::routes::postcodes: GET /api/postcode/M4 4FZ postcode=M4 4FZ
2026-05-05T21:52:17.906952Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=881951b74bfffff resolution=8 total_count=2958 filters=0 filters_raw="-" ms=1.4
2026-05-05T21:52:18.280692Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b74a3ffff resolution=9 total_count=1241 filters=0 filters_raw="-" ms=0.6
2026-05-05T21:52:18.442852Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=163 cells_after_filter=163 truncated=false bounds=53.3675,-2.4548,53.5912,-2.0354 filters=0 filters_raw="-" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.3 json_ms=0.1 total_ms=2.4
2026-05-05T21:52:18.775913Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=1 travel=1 total=41544 filters_raw="-" ms=12.3
2026-05-05T21:52:29.653339Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:52:29.661372Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:52:29.947627Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.0
2026-05-05T21:52:29.968963Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b74a3ffff resolution=9 total_count=310 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=0.3
2026-05-05T21:52:29.970311Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.8
2026-05-05T21:52:30.307589Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.7 json_ms=0.1 total_ms=3.8
2026-05-05T21:52:30.524199Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:52:30.524204Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:52:30.624729Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=5 travel=1 total=20439 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=26.4
2026-05-05T21:52:35.559305Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=2156 cells_after_filter=2155 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=9.0 json_ms=2.1 total_ms=11.1
2026-05-05T21:52:35.822961Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.7 json_ms=0.1 total_ms=2.9
2026-05-05T21:52:37.260802Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.5 json_ms=0.1 total_ms=3.5
2026-05-05T21:52:37.768202Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=21.9
2026-05-05T21:53:03.831217Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:53:03.831449Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:53:03.848657Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:53:03.848697Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:53:03.880252Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:53:03.880653Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:53:03.890238Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:53:03.891728Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:53:03.901154Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:53:03.902246Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:53:18.725683Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:53:18.725685Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:54:05.076079Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:54:05.076294Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:56:34.822373Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:56:36.141237Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:56:36.142948Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:56:36.207992Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.8
2026-05-05T21:56:36.504745Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=163 cells_after_filter=163 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=0 filters_raw="-" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.2 json_ms=0.2 total_ms=3.3
2026-05-05T21:56:36.733128Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=1 travel=1 total=41544 filters_raw="-" ms=13.1
2026-05-05T21:56:40.571881Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.3
2026-05-05T21:56:40.583234Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.5
2026-05-05T21:56:40.892631Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.5 json_ms=0.2 total_ms=4.6
2026-05-05T21:56:41.062742Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=20439 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=24.6
2026-05-05T21:56:43.955287Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=2446 cells_after_filter=2433 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=7.9 json_ms=2.5 total_ms=10.4
2026-05-05T21:56:44.345169Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=9.5 json_ms=0.1 total_ms=9.7
2026-05-05T21:56:46.004109Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=5.2 json_ms=0.1 total_ms=5.2
2026-05-05T21:56:46.078279Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=24.5
2026-05-05T21:56:51.868367Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=268290 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.4198,-2.4014,53.5395,-2.1022 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.3 json_ms=0.1 total_ms=2.4
2026-05-05T21:56:51.879357Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=268290 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=13.3
2026-05-05T21:56:52.896449Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=173011 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.4360,-2.3633,53.5235,-2.1444 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.8 json_ms=0.1 total_ms=1.8
2026-05-05T21:56:52.901548Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=173011 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=6.9
2026-05-05T21:57:14.298418Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b7467ffff resolution=9 total_count=149 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=0.3
2026-05-05T21:57:14.913263Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:57:15.416546Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=141822 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.4360,-2.3359,53.5235,-2.1718 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.8 json_ms=0.1 total_ms=1.9
2026-05-05T21:57:15.455008Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=141822 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=8.2
2026-05-05T21:57:18.611959Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:57:18.611965Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:57:18.724225Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.3
2026-05-05T21:57:19.334648Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=163 cells_after_filter=163 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=0 filters_raw="-" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=8.3 json_ms=0.2 total_ms=8.5
2026-05-05T21:57:20.412105Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=1 travel=1 total=41544 filters_raw="-" ms=26.8
2026-05-05T21:57:23.399746Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.1
2026-05-05T21:57:23.425315Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.1
2026-05-05T21:57:23.643747Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=7.9 json_ms=0.1 total_ms=8.0
2026-05-05T21:57:25.019448Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=20439 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=27.7
2026-05-05T21:57:27.456478Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=2446 cells_after_filter=2433 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=21.8 json_ms=2.6 total_ms=24.5
2026-05-05T21:57:27.801520Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=7.9 json_ms=0.2 total_ms=8.0
2026-05-05T21:57:29.430108Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=5.0 json_ms=0.1 total_ms=5.1
2026-05-05T21:57:29.984292Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=27.1
2026-05-05T21:57:37.788237Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=268290 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.4198,-2.4014,53.5395,-2.1022 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.6 json_ms=0.1 total_ms=4.7
2026-05-05T21:57:38.031795Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=173011 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.4360,-2.3633,53.5235,-2.1444 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.3 json_ms=0.1 total_ms=4.4
2026-05-05T21:57:39.262206Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=173011 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=9.9
2026-05-05T21:57:58.793587Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b7467ffff resolution=9 total_count=149 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=0.2
2026-05-05T21:57:59.886649Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=141822 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.4360,-2.3359,53.5235,-2.1718 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.2 json_ms=0.1 total_ms=2.3
2026-05-05T21:57:59.945458Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=141822 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=6.3
2026-05-05T21:58:10.755620Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:58:10.757205Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T21:58:26.903364Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T21:58:26.904448Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:01:32.049909Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:01:34.012407Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:01:34.014056Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:01:34.073888Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.9
2026-05-05T22:01:34.604384Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=163 cells_after_filter=163 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=0 filters_raw="-" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.2 json_ms=0.2 total_ms=3.3
2026-05-05T22:01:35.318029Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=1 travel=1 total=41544 filters_raw="-" ms=14.1
2026-05-05T22:01:38.545948Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.1
2026-05-05T22:01:38.559798Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.9
2026-05-05T22:01:39.087439Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.9 json_ms=0.1 total_ms=4.1
2026-05-05T22:01:39.111716Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=20439 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=21.4
2026-05-05T22:01:42.572213Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=2446 cells_after_filter=2433 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=9.2 json_ms=2.5 total_ms=11.7
2026-05-05T22:01:42.812709Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.5 json_ms=0.1 total_ms=3.6
2026-05-05T22:01:44.895449Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.4 json_ms=0.1 total_ms=4.5
2026-05-05T22:01:44.915751Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=21.0
2026-05-05T22:02:00.539658Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b70abffff resolution=9 total_count=1487 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=0.6
2026-05-05T22:02:01.209769Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.1 json_ms=0.1 total_ms=4.2
2026-05-05T22:02:01.410662Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=21.9
2026-05-05T22:02:30.280215Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:02:30.280220Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:02:30.494339Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:02:30.494338Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:03:04.445153Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:03:05.814766Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:03:05.816218Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:03:05.877835Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.5
2026-05-05T22:03:06.171149Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=163 cells_after_filter=163 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=0 filters_raw="-" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.1 json_ms=0.2 total_ms=3.3
2026-05-05T22:03:06.416637Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=1 travel=1 total=41544 filters_raw="-" ms=13.0
2026-05-05T22:03:10.243319Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.4
2026-05-05T22:03:10.254863Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.3
2026-05-05T22:03:11.600208Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.8 json_ms=0.1 total_ms=4.9
2026-05-05T22:03:11.620435Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=20439 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=24.2
2026-05-05T22:03:14.007173Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=2446 cells_after_filter=2433 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=6.0 json_ms=1.9 total_ms=7.9
2026-05-05T22:03:17.458780Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.2 json_ms=0.2 total_ms=3.5
2026-05-05T22:03:18.845895Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.8 json_ms=0.1 total_ms=4.9
2026-05-05T22:03:18.864525Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=22.8
2026-05-05T22:03:35.601665Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b70abffff resolution=9 total_count=1487 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=0.6
2026-05-05T22:03:36.222150Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.7 json_ms=0.1 total_ms=3.8
2026-05-05T22:03:36.513454Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=23.9
2026-05-05T22:04:11.176851Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:04:12.558667Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:04:12.559951Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:04:12.628604Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.5
2026-05-05T22:04:12.929976Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=163 cells_after_filter=163 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=0 filters_raw="-" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.4 json_ms=0.2 total_ms=3.6
2026-05-05T22:04:13.173110Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=1 travel=1 total=41544 filters_raw="-" ms=13.1
2026-05-05T22:04:16.988146Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.6
2026-05-05T22:04:17.000016Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.4
2026-05-05T22:04:17.925005Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=5.9 json_ms=0.1 total_ms=6.0
2026-05-05T22:04:17.940327Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=20439 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=21.2
2026-05-05T22:04:20.338637Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=2446 cells_after_filter=2433 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=6.4 json_ms=1.9 total_ms=8.3
2026-05-05T22:04:20.819388Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.2 json_ms=0.2 total_ms=3.4
2026-05-05T22:04:23.030591Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.9 json_ms=0.1 total_ms=4.9
2026-05-05T22:04:23.052922Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=27.3
2026-05-05T22:04:40.188074Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b70abffff resolution=9 total_count=1487 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=1.0
2026-05-05T22:04:40.729235Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.9 json_ms=0.1 total_ms=4.0
2026-05-05T22:04:40.995511Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=25.3
2026-05-05T22:05:34.435389Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:05:35.713246Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:05:35.713807Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:05:35.775163Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.8
2026-05-05T22:05:36.061950Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=163 cells_after_filter=163 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=0 filters_raw="-" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.9 json_ms=0.1 total_ms=4.1
2026-05-05T22:05:36.320303Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=1 travel=1 total=41544 filters_raw="-" ms=12.8
2026-05-05T22:05:40.134229Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.3
2026-05-05T22:05:40.151547Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.6
2026-05-05T22:05:41.222363Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.0 json_ms=0.1 total_ms=4.1
2026-05-05T22:05:41.245295Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=20439 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=27.0
2026-05-05T22:05:44.655480Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=2446 cells_after_filter=2433 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=8.1 json_ms=2.4 total_ms=10.5
2026-05-05T22:05:45.759197Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.4 json_ms=0.1 total_ms=3.5
2026-05-05T22:05:46.783618Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.7 json_ms=0.1 total_ms=3.7
2026-05-05T22:05:47.179663Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=26.6
2026-05-05T22:05:52.156396Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b70abffff resolution=9 total_count=1487 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=1.1
2026-05-05T22:05:56.697343Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.4 json_ms=0.1 total_ms=3.5
2026-05-05T22:06:00.750033Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=29.0
2026-05-05T22:07:46.408950Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:07:47.806303Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:07:47.807699Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:07:47.869410Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.5
2026-05-05T22:07:48.207181Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=163 cells_after_filter=163 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=0 filters_raw="-" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.6 json_ms=0.1 total_ms=4.8
2026-05-05T22:07:48.419634Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=1 travel=1 total=41544 filters_raw="-" ms=14.1
2026-05-05T22:07:52.350175Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.0
2026-05-05T22:07:52.364217Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.0
2026-05-05T22:07:52.637745Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.7 json_ms=0.2 total_ms=3.9
2026-05-05T22:07:53.569400Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=20439 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=23.5
2026-05-05T22:07:58.242591Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=2446 cells_after_filter=2433 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=8.5 json_ms=1.7 total_ms=10.2
2026-05-05T22:07:59.705019Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.7 json_ms=0.2 total_ms=3.9
2026-05-05T22:08:01.282032Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.0 json_ms=0.1 total_ms=4.1
2026-05-05T22:08:01.302333Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=24.3
2026-05-05T22:08:07.868528Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b70abffff resolution=9 total_count=1487 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=1.1
2026-05-05T22:08:11.985786Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=5.5 json_ms=0.1 total_ms=5.6
2026-05-05T22:08:14.126304Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=22.2
2026-05-05T22:09:36.351097Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:09:37.648717Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:09:37.650429Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:09:37.718577Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.4
2026-05-05T22:09:38.022594Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=163 cells_after_filter=163 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=0 filters_raw="-" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.2 json_ms=0.2 total_ms=3.4
2026-05-05T22:09:38.479616Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=1 travel=1 total=41544 filters_raw="-" ms=15.1
2026-05-05T22:09:42.116891Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.3
2026-05-05T22:09:42.129082Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.7
2026-05-05T22:09:43.385938Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.5 json_ms=0.1 total_ms=4.6
2026-05-05T22:09:43.405845Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=20439 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=24.7
2026-05-05T22:09:45.802330Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=2446 cells_after_filter=2433 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=6.0 json_ms=1.7 total_ms=7.7
2026-05-05T22:09:46.150374Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.9 json_ms=0.1 total_ms=4.0
2026-05-05T22:09:47.635412Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.4 json_ms=0.1 total_ms=4.5
2026-05-05T22:09:51.024342Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=26.9
2026-05-05T22:09:57.125364Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b70abffff resolution=9 total_count=1487 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=1.0
2026-05-05T22:10:01.670253Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.0 json_ms=0.1 total_ms=3.1
2026-05-05T22:10:03.962206Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=21.3
2026-05-05T22:11:29.323311Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:11:29.324866Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:11:29.326169Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:11:29.327416Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:11:29.341892Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:11:29.342825Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:11:29.358511Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:11:29.361667Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:11:31.233803Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:11:31.233808Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:11:31.244858Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:11:31.244881Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:11:31.244950Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:11:31.246951Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:11:31.285478Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:11:31.286881Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:22:58.183462Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=83099 parallel=true cells_before_filter=312 cells_after_filter=300 truncated=false bounds=51.4958,-0.1756,51.5342,-0.0844 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=1.0 json_ms=0.2 total_ms=1.1
2026-05-05T22:22:59.659662Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89195da49c3ffff resolution=9 total_count=238 filters=0 filters_raw="-" ms=0.3
2026-05-05T22:23:00.015894Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=64311 parallel=true cells_before_filter=249 cells_after_filter=229 truncated=false bounds=51.4958,-0.1632,51.5342,-0.0968 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=0.8 json_ms=0.1 total_ms=0.9
2026-05-05T22:23:11.413301Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=64311 parallel=true cells_before_filter=249 cells_after_filter=229 truncated=false bounds=51.4958,-0.1632,51.5342,-0.0968 filters=0 filters_raw="-" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=0.9 json_ms=0.2 total_ms=1.0
2026-05-05T22:23:18.040570Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=64311 parallel=true cells_before_filter=249 cells_after_filter=229 truncated=false bounds=51.4958,-0.1632,51.5342,-0.0968 filters=0 filters_raw="-" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=0.9 json_ms=0.2 total_ms=1.2
2026-05-05T22:43:15.610059Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=83099 parallel=true cells_before_filter=312 cells_after_filter=300 truncated=false bounds=51.4958,-0.1756,51.5342,-0.0844 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=0.8 json_ms=0.2 total_ms=1.0
2026-05-05T22:43:16.903828Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=32331 parallel=false cells_before_filter=163 cells_after_filter=149 truncated=false bounds=51.4958,-0.1497,51.5342,-0.1103 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=0.6 json_ms=0.1 total_ms=0.7
2026-05-05T22:43:20.549697Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=19171 parallel=false cells_before_filter=105 cells_after_filter=94 truncated=false bounds=51.4978,-0.1407,51.5322,-0.1193 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=0.4 json_ms=0.1 total_ms=0.4
2026-05-05T22:43:21.976727Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=19171 parallel=false cells_before_filter=105 cells_after_filter=100 truncated=false bounds=51.4978,-0.1424,51.5322,-0.1176 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=0.5 json_ms=0.1 total_ms=0.6
2026-05-05T22:43:22.293571Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=40248 parallel=false cells_before_filter=186 cells_after_filter=175 truncated=false bounds=51.4978,-0.1547,51.5322,-0.1053 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=0.7 json_ms=0.1 total_ms=0.8
2026-05-05T22:43:22.845608Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=32331 parallel=false cells_before_filter=163 cells_after_filter=146 truncated=false bounds=51.4978,-0.1490,51.5322,-0.1110 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=0.6 json_ms=0.1 total_ms=0.6
2026-05-05T22:43:23.303420Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89195da4d23ffff resolution=9 total_count=4 filters=0 filters_raw="-" ms=0.1
2026-05-05T22:43:42.565671Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:43:44.001279Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:43:44.001506Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:43:44.076119Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.4
2026-05-05T22:43:44.654476Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=163 cells_after_filter=163 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=0 filters_raw="-" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.5 json_ms=0.1 total_ms=2.6
2026-05-05T22:43:44.894850Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=1 travel=1 total=41544 filters_raw="-" ms=11.6
2026-05-05T22:43:48.729626Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.4
2026-05-05T22:43:48.741364Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.6
2026-05-05T22:43:48.926131Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.1 json_ms=0.2 total_ms=4.3
2026-05-05T22:43:49.231122Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=20439 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=28.6
2026-05-05T22:43:51.908240Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=2446 cells_after_filter=2433 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=8.9 json_ms=2.3 total_ms=11.2
2026-05-05T22:43:53.360428Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.1 json_ms=0.2 total_ms=4.2
2026-05-05T22:43:57.147224Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.1 json_ms=0.1 total_ms=4.2
2026-05-05T22:43:57.169568Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=26.6
2026-05-05T22:44:07.124405Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b70abffff resolution=9 total_count=1487 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=1.0
2026-05-05T22:44:11.794994Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.1 json_ms=0.1 total_ms=3.2
2026-05-05T22:44:15.007697Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=26.1
2026-05-05T22:44:33.883430Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:44:33.883631Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:44:33.956903Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:44:33.957097Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:44:34.066338Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:44:34.066345Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:44:34.151042Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:44:34.151631Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:44:34.917734Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=32331 parallel=false cells_before_filter=163 cells_after_filter=146 truncated=false bounds=51.4978,-0.1490,51.5322,-0.1110 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=0.6 json_ms=0.1 total_ms=0.7
2026-05-05T22:45:56.400232Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=32331 parallel=false cells_before_filter=163 cells_after_filter=146 truncated=false bounds=51.4978,-0.1490,51.5322,-0.1110 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=0.6 json_ms=0.1 total_ms=0.7
2026-05-05T22:46:00.243885Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=32331 parallel=false cells_before_filter=163 cells_after_filter=146 truncated=false bounds=51.4978,-0.1490,51.5322,-0.1110 filters=0 filters_raw="-" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=1.6 json_ms=0.1 total_ms=1.7
2026-05-05T22:46:02.123616Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=32331 parallel=false cells_before_filter=163 cells_after_filter=151 truncated=false bounds=51.4957,-0.1512,51.5334,-0.1097 filters=0 filters_raw="-" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=1.5 json_ms=0.1 total_ms=1.6
2026-05-05T22:46:03.665847Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=90632 parallel=true cells_before_filter=347 cells_after_filter=312 truncated=false bounds=51.4868,-0.1624,51.5414,-0.1022 filters=0 filters_raw="-" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=1.8 json_ms=0.3 total_ms=2.1
2026-05-05T22:46:04.264588Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=414377 parallel=true cells_before_filter=1471 cells_after_filter=1371 truncated=false bounds=51.4530,-0.2043,51.5713,-0.0741 filters=0 filters_raw="-" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=3.6 json_ms=1.1 total_ms=4.7
2026-05-05T22:46:06.547788Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=32331 parallel=false cells_before_filter=163 cells_after_filter=146 truncated=false bounds=51.4978,-0.1490,51.5322,-0.1110 filters=0 filters_raw="-" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=1.5 json_ms=0.1 total_ms=1.7
2026-05-05T22:46:16.636099Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=32331 parallel=false cells_before_filter=163 cells_after_filter=146 truncated=false bounds=51.4978,-0.1490,51.5322,-0.1110 filters=0 filters_raw="-" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=1.5 json_ms=0.1 total_ms=1.6
2026-05-05T22:46:23.394449Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=32331 parallel=false cells_before_filter=163 cells_after_filter=146 truncated=false bounds=51.4978,-0.1490,51.5322,-0.1110 filters=0 filters_raw="-" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=1.6 json_ms=0.1 total_ms=1.7
2026-05-05T22:46:24.308069Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=32331 parallel=false cells_before_filter=162 cells_after_filter=145 truncated=false bounds=51.4978,-0.1490,51.5322,-0.1110 filters=1 filters_raw="Price per sqm:12:30077.969" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=1.5 json_ms=0.1 total_ms=1.6
2026-05-05T22:46:24.553931Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=32331 filters=1 travel=0 total=26612 filters_raw="Price per sqm:12:30077.969" ms=0.8
2026-05-05T22:46:26.959319Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=32787 parallel=false cells_before_filter=151 cells_after_filter=134 truncated=false bounds=51.4952,-0.1532,51.5297,-0.1152 filters=1 filters_raw="Price per sqm:12:30077.969" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=1.6 json_ms=0.1 total_ms=1.7
2026-05-05T22:46:27.318926Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=58963 parallel=true cells_before_filter=238 cells_after_filter=206 truncated=false bounds=51.4900,-0.1588,51.5326,-0.1119 filters=1 filters_raw="Price per sqm:12:30077.969" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=1.2 json_ms=0.2 total_ms=1.4
2026-05-05T22:46:27.749837Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=108235 parallel=true cells_before_filter=389 cells_after_filter=374 truncated=false bounds=51.4786,-0.1710,51.5391,-0.1044 filters=1 filters_raw="Price per sqm:12:30077.969" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=1.6 json_ms=0.4 total_ms=2.0
2026-05-05T22:46:28.171700Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=339930 parallel=true cells_before_filter=1157 cells_after_filter=1088 truncated=false bounds=51.4504,-0.2014,51.5554,-0.0858 filters=1 filters_raw="Price per sqm:12:30077.969" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=3.0 json_ms=1.0 total_ms=4.0
2026-05-05T22:46:28.601824Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=749457 parallel=true cells_before_filter=3198 cells_after_filter=3059 truncated=false bounds=51.3997,-0.2558,51.5845,-0.0524 filters=1 filters_raw="Price per sqm:12:30077.969" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=6.0 json_ms=2.1 total_ms=8.1
2026-05-05T22:46:29.055179Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=8 rows=1560750 parallel=true cells_before_filter=1545 cells_after_filter=1535 truncated=false bounds=51.3051,-0.3572,51.6387,0.0098 filters=1 filters_raw="Price per sqm:12:30077.969" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=11.5 json_ms=1.0 total_ms=12.5
2026-05-05T22:46:29.321372Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=1560750 filters=1 travel=0 total=1299177 filters_raw="Price per sqm:12:30077.969" ms=34.9
2026-05-05T22:46:33.087766Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=2261657 parallel=true cells_before_filter=616 cells_after_filter=616 truncated=false bounds=51.1810,-0.4615,51.7329,0.1455 filters=1 filters_raw="Price per sqm:12:30077.969" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=12.7 json_ms=0.4 total_ms=13.1
2026-05-05T22:46:33.494231Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=2918823 parallel=true cells_before_filter=1362 cells_after_filter=1362 truncated=false bounds=51.0169,-0.6036,51.8540,0.3166 filters=1 filters_raw="Price per sqm:12:30077.969" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=12.6 json_ms=1.0 total_ms=13.7
2026-05-05T22:46:33.752516Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=2918823 filters=1 travel=0 total=2383011 filters_raw="Price per sqm:12:30077.969" ms=62.9
2026-05-05T22:46:41.495897Z INFO property_map_server::routes::pois: GET /api/pois results=30 candidates=110817 categories=1 categories_raw="Ferry" ms=1.1
2026-05-05T22:46:54.343178Z INFO property_map_server::routes::pois: GET /api/pois results=546 candidates=110817 categories=2 categories_raw="Ferry,Rail station" ms=1.4
2026-05-05T22:46:55.441445Z INFO property_map_server::routes::pois: GET /api/pois results=548 candidates=110817 categories=3 categories_raw="Ferry,Rail station,Airport" ms=1.5
2026-05-05T22:46:55.794750Z INFO property_map_server::routes::pois: GET /api/pois results=549 candidates=110817 categories=4 categories_raw="Ferry,Rail station,Airport,Bus station" ms=1.5
2026-05-05T22:46:56.155646Z INFO property_map_server::routes::pois: GET /api/pois results=10000 candidates=110817 categories=5 categories_raw="Ferry,Rail station,Airport,Bus station,Bus stop" ms=6.7
2026-05-05T22:48:05.525357Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=87195db43ffffff resolution=7 total_count=91 filters=1 filters_raw="Price per sqm:12:30077.969" ms=0.1
2026-05-05T22:50:01.895537Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=87195db43ffffff resolution=7 total_count=127 filters=0 filters_raw="-" ms=0.1
2026-05-05T22:50:02.051971Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=2918823 parallel=true cells_before_filter=1363 cells_after_filter=1363 truncated=false bounds=51.0169,-0.6036,51.8540,0.3166 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=6.3 json_ms=0.6 total_ms=7.0
2026-05-05T22:50:31.118058Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=2918823 parallel=true cells_before_filter=1363 cells_after_filter=1363 truncated=false bounds=51.0169,-0.6036,51.8540,0.3166 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=7.1 json_ms=0.6 total_ms=7.8
2026-05-05T22:50:31.118295Z INFO property_map_server::routes::pois: GET /api/pois results=10000 candidates=110817 categories=5 categories_raw="Ferry,Rail station,Airport,Bus station,Bus stop" ms=8.0
2026-05-05T22:51:30.713271Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=7 rows=2918823 parallel=true cells_before_filter=1363 cells_after_filter=1363 truncated=false bounds=51.0169,-0.6036,51.8540,0.3166 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=7.2 json_ms=0.7 total_ms=7.9
2026-05-05T22:51:30.714292Z INFO property_map_server::routes::pois: GET /api/pois results=10000 candidates=110817 categories=5 categories_raw="Ferry,Rail station,Airport,Bus station,Bus stop" ms=8.8
2026-05-05T22:53:32.469648Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:53:32.472277Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:53:49.086905Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=32331 parallel=false cells_before_filter=163 cells_after_filter=146 truncated=false bounds=51.4978,-0.1490,51.5322,-0.1110 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=0.6 json_ms=0.1 total_ms=0.7
2026-05-05T22:54:09.482457Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=32331 parallel=false cells_before_filter=163 cells_after_filter=146 truncated=false bounds=51.4978,-0.1490,51.5322,-0.1110 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=0.6 json_ms=0.1 total_ms=0.7
2026-05-05T22:54:21.493756Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:54:21.496988Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:54:26.683522Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:54:26.689059Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:55:49.934502Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:55:51.273763Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:55:51.274240Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:55:51.342597Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.4
2026-05-05T22:55:51.625779Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=163 cells_after_filter=163 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=0 filters_raw="-" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.6 json_ms=0.1 total_ms=2.7
2026-05-05T22:55:51.883818Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=1 travel=1 total=41544 filters_raw="-" ms=13.2
2026-05-05T22:56:00.674036Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.0
2026-05-05T22:56:00.688452Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.6
2026-05-05T22:56:01.660467Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.3 json_ms=0.2 total_ms=3.5
2026-05-05T22:56:01.686325Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=20439 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=29.2
2026-05-05T22:56:08.892303Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=2446 cells_after_filter=2433 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=10.0 json_ms=2.4 total_ms=12.4
2026-05-05T22:56:09.044778Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.5 json_ms=0.2 total_ms=4.6
2026-05-05T22:56:27.273066Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.5 json_ms=0.1 total_ms=3.5
2026-05-05T22:56:27.290297Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=20.8
2026-05-05T22:56:41.380242Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b70abffff resolution=9 total_count=1487 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=1.1
2026-05-05T22:56:48.559932Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=5.9 json_ms=0.1 total_ms=6.0
2026-05-05T22:56:48.598808Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=25.7
2026-05-05T22:59:17.850275Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:59:19.683668Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T22:59:19.685209Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T22:59:19.868856Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.4
2026-05-05T22:59:20.700955Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=163 cells_after_filter=163 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=0 filters_raw="-" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.9 json_ms=0.2 total_ms=3.1
2026-05-05T22:59:21.388973Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=1 travel=1 total=41544 filters_raw="-" ms=18.5
2026-05-05T22:59:30.197488Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.0
2026-05-05T22:59:30.209877Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.8
2026-05-05T22:59:31.524992Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.1 json_ms=0.1 total_ms=4.2
2026-05-05T22:59:31.552172Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=20439 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=31.5
2026-05-05T22:59:39.058584Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=2446 cells_after_filter=2433 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=8.2 json_ms=2.4 total_ms=10.5
2026-05-05T22:59:40.888781Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.3 json_ms=0.2 total_ms=4.4
2026-05-05T22:59:49.369015Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.9 json_ms=0.1 total_ms=3.0
2026-05-05T22:59:53.656223Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=24.4
2026-05-05T23:00:06.632909Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b70abffff resolution=9 total_count=1487 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=1.1
2026-05-05T23:00:14.326078Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.6 json_ms=0.1 total_ms=4.7
2026-05-05T23:00:14.345041Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=24.1
2026-05-05T23:02:44.138033Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T23:02:45.527629Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T23:02:45.528980Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T23:02:45.591980Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.7
2026-05-05T23:02:45.870217Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=163 cells_after_filter=163 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=0 filters_raw="-" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.3 json_ms=0.2 total_ms=2.5
2026-05-05T23:02:46.138216Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=1 travel=1 total=41544 filters_raw="-" ms=13.9
2026-05-05T23:02:55.255284Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.3
2026-05-05T23:02:55.284710Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.3
2026-05-05T23:02:56.543228Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=5.1 json_ms=0.2 total_ms=5.2
2026-05-05T23:02:56.565466Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=20439 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=27.6
2026-05-05T23:03:04.737677Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=2446 cells_after_filter=2433 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=8.4 json_ms=2.3 total_ms=10.7
2026-05-05T23:03:06.524250Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.0 json_ms=0.2 total_ms=4.1
2026-05-05T23:03:21.842452Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.6 json_ms=0.1 total_ms=4.7
2026-05-05T23:03:21.864802Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=27.0
2026-05-05T23:03:42.145302Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b70abffff resolution=9 total_count=1487 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=1.1
2026-05-05T23:03:46.908451Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=499440 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.4549,53.5912,-2.0354 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.5 json_ms=0.1 total_ms=2.6
2026-05-05T23:03:47.229906Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=499440 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=24.5
2026-05-05T23:05:31.402218Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T23:05:31.414308Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T23:05:31.415566Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T23:05:31.415719Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T23:05:31.436317Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T23:05:31.436387Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T23:05:31.451985Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T23:05:31.452263Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T23:05:31.961090Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=32331 parallel=false cells_before_filter=163 cells_after_filter=146 truncated=false bounds=51.4978,-0.1490,51.5322,-0.1110 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=0.7 json_ms=0.1 total_ms=0.8
2026-05-05T23:06:57.415147Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T23:06:58.764931Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T23:06:58.766642Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T23:06:58.836054Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.5
2026-05-05T23:06:59.176972Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=163 cells_after_filter=163 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=0 filters_raw="-" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.3 json_ms=0.1 total_ms=2.5
2026-05-05T23:06:59.430262Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=1 travel=1 total=41544 filters_raw="-" ms=12.3
2026-05-05T23:07:08.481220Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.9
2026-05-05T23:07:08.494481Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.8
2026-05-05T23:07:09.211888Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.7 json_ms=0.1 total_ms=3.9
2026-05-05T23:07:09.235165Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=20439 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=27.3
2026-05-05T23:07:16.474238Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=2446 cells_after_filter=2433 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=7.3 json_ms=2.7 total_ms=9.9
2026-05-05T23:07:21.423747Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.3 json_ms=0.2 total_ms=4.4
2026-05-05T23:07:28.025215Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.0 json_ms=0.1 total_ms=3.1
2026-05-05T23:07:30.260151Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=21.9
2026-05-05T23:07:40.399802Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=470423 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3848,-2.4911,53.5741,-2.0174 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.0 json_ms=0.1 total_ms=3.1
2026-05-05T23:07:42.310630Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=328746 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.4065,-2.4487,53.5527,-2.0829 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.3 json_ms=0.1 total_ms=3.4
2026-05-05T23:07:42.807273Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=328746 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.4065,-2.4487,53.5527,-2.0829 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.1 json_ms=0.1 total_ms=2.2
2026-05-05T23:07:42.872719Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=328746 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=17.6
2026-05-05T23:07:44.921638Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b70abffff resolution=9 total_count=1487 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=1.1
2026-05-05T23:07:46.846641Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=283073 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.4065,-2.4029,53.5527,-2.1286 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.1 json_ms=0.1 total_ms=3.2
2026-05-05T23:07:47.055307Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=283073 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=11.3
2026-05-05T23:09:24.286826Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T23:09:24.288081Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T23:09:24.310068Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T23:09:24.310452Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T23:09:24.328925Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T23:09:24.330142Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T23:09:24.376513Z INFO property_map_server::routes::features: GET /api/features
2026-05-05T23:09:24.386911Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-05T23:09:24.866051Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=32331 parallel=false cells_before_filter=163 cells_after_filter=146 truncated=false bounds=51.4978,-0.1490,51.5322,-0.1110 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=0.8 json_ms=0.1 total_ms=0.9

View file

@ -0,0 +1,78 @@
2026-05-06T06:24:15.704164Z INFO property_map_server::routes::features: GET /api/features
2026-05-06T06:24:15.851886Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-06T07:12:47.756563Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=32331 parallel=false cells_before_filter=163 cells_after_filter=149 truncated=false bounds=51.4958,-0.1497,51.5342,-0.1103 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=0.6 json_ms=0.1 total_ms=0.7
2026-05-06T07:12:51.057411Z INFO property_map_server::routes::pois: GET /api/pois results=8114 candidates=8143 categories=94 categories_raw="Airport,Bus station,Bus stop,Ferry,Rail station,Taxi rank,Tube station,Aldi,Asda,Bakery,Booths,Budgens,Butcher & Fishmonger,COOK,Co-op,Convenience Store,Costco,Deli & Specialty,Farmfoods,Greengrocer,Heron Foods,Iceland,Lidl,M&S,Makro,Morrisons,Off-Licence,Planet Organic,Sainsbury's,Spar,Supermarket,Tesco,Waitrose,Whole Foods Market,Bar,Café,Cinema,Entertainment,Fast Food,Live Music & Events,Nightclub,Park,Playground,Pub,Restaurant,Sports Centre,Theatre,School,Care Home,Counselling & Therapy,Dentist,GP Surgery,Hospital & Clinic,Medical & Mobility,Optician,Pharmacy,Physiotherapy,Ambulance Station,Fire Station,Police,Community Centre,EV Charging,Fuel Station,Hotel,Local Business,Offices,Arts Centre,Gallery,Library,Museum,Place of Worship,Tourist Attraction,Zoo,Bank,Car Services,Dry Cleaner & Laundry,Gym & Fitness,Hairdresser & Beauty,Other,Post Office,Travel Agent,Vet & Pet Care,Bookshop,Charity Shop,DIY & Hardware,Department Store,Electronics,Fashion & Clothing,Gift & Hobby,Home & Garden,Newsagent,Pet Shop,Specialist Shop,Sports & Outdoor" ms=2.9
2026-05-06T07:12:53.465923Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=109329 parallel=true cells_before_filter=400 cells_after_filter=367 truncated=false bounds=51.4830,-0.1594,51.5444,-0.0964 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=1.1 json_ms=0.2 total_ms=1.3
2026-05-06T07:12:53.470208Z INFO property_map_server::routes::pois: GET /api/pois results=10000 candidates=13896 categories=94 categories_raw="Airport,Bus station,Bus stop,Ferry,Rail station,Taxi rank,Tube station,Aldi,Asda,Bakery,Booths,Budgens,Butcher & Fishmonger,COOK,Co-op,Convenience Store,Costco,Deli & Specialty,Farmfoods,Greengrocer,Heron Foods,Iceland,Lidl,M&S,Makro,Morrisons,Off-Licence,Planet Organic,Sainsbury's,Spar,Supermarket,Tesco,Waitrose,Whole Foods Market,Bar,Café,Cinema,Entertainment,Fast Food,Live Music & Events,Nightclub,Park,Playground,Pub,Restaurant,Sports Centre,Theatre,School,Care Home,Counselling & Therapy,Dentist,GP Surgery,Hospital & Clinic,Medical & Mobility,Optician,Pharmacy,Physiotherapy,Ambulance Station,Fire Station,Police,Community Centre,EV Charging,Fuel Station,Hotel,Local Business,Offices,Arts Centre,Gallery,Library,Museum,Place of Worship,Tourist Attraction,Zoo,Bank,Car Services,Dry Cleaner & Laundry,Gym & Fitness,Hairdresser & Beauty,Other,Post Office,Travel Agent,Vet & Pet Care,Bookshop,Charity Shop,DIY & Hardware,Department Store,Electronics,Fashion & Clothing,Gift & Hobby,Home & Garden,Newsagent,Pet Shop,Specialist Shop,Sports & Outdoor" ms=4.4
2026-05-06T07:12:54.729810Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=163973 parallel=true cells_before_filter=583 cells_after_filter=552 truncated=false bounds=51.4759,-0.1656,51.5526,-0.0869 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=1.3 json_ms=0.3 total_ms=1.5
2026-05-06T07:12:54.734619Z INFO property_map_server::routes::pois: GET /api/pois results=10000 candidates=17509 categories=94 categories_raw="Airport,Bus station,Bus stop,Ferry,Rail station,Taxi rank,Tube station,Aldi,Asda,Bakery,Booths,Budgens,Butcher & Fishmonger,COOK,Co-op,Convenience Store,Costco,Deli & Specialty,Farmfoods,Greengrocer,Heron Foods,Iceland,Lidl,M&S,Makro,Morrisons,Off-Licence,Planet Organic,Sainsbury's,Spar,Supermarket,Tesco,Waitrose,Whole Foods Market,Bar,Café,Cinema,Entertainment,Fast Food,Live Music & Events,Nightclub,Park,Playground,Pub,Restaurant,Sports Centre,Theatre,School,Care Home,Counselling & Therapy,Dentist,GP Surgery,Hospital & Clinic,Medical & Mobility,Optician,Pharmacy,Physiotherapy,Ambulance Station,Fire Station,Police,Community Centre,EV Charging,Fuel Station,Hotel,Local Business,Offices,Arts Centre,Gallery,Library,Museum,Place of Worship,Tourist Attraction,Zoo,Bank,Car Services,Dry Cleaner & Laundry,Gym & Fitness,Hairdresser & Beauty,Other,Post Office,Travel Agent,Vet & Pet Care,Bookshop,Charity Shop,DIY & Hardware,Department Store,Electronics,Fashion & Clothing,Gift & Hobby,Home & Garden,Newsagent,Pet Shop,Specialist Shop,Sports & Outdoor" ms=4.8
2026-05-06T07:13:00.198267Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=360388 parallel=true cells_before_filter=1242 cells_after_filter=1226 truncated=false bounds=51.4759,-0.2171,51.5526,-0.0354 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=2.3 json_ms=0.6 total_ms=2.8
2026-05-06T07:13:00.201735Z INFO property_map_server::routes::pois: GET /api/pois results=10000 candidates=28781 categories=94 categories_raw="Airport,Bus station,Bus stop,Ferry,Rail station,Taxi rank,Tube station,Aldi,Asda,Bakery,Booths,Budgens,Butcher & Fishmonger,COOK,Co-op,Convenience Store,Costco,Deli & Specialty,Farmfoods,Greengrocer,Heron Foods,Iceland,Lidl,M&S,Makro,Morrisons,Off-Licence,Planet Organic,Sainsbury's,Spar,Supermarket,Tesco,Waitrose,Whole Foods Market,Bar,Café,Cinema,Entertainment,Fast Food,Live Music & Events,Nightclub,Park,Playground,Pub,Restaurant,Sports Centre,Theatre,School,Care Home,Counselling & Therapy,Dentist,GP Surgery,Hospital & Clinic,Medical & Mobility,Optician,Pharmacy,Physiotherapy,Ambulance Station,Fire Station,Police,Community Centre,EV Charging,Fuel Station,Hotel,Local Business,Offices,Arts Centre,Gallery,Library,Museum,Place of Worship,Tourist Attraction,Zoo,Bank,Car Services,Dry Cleaner & Laundry,Gym & Fitness,Hairdresser & Beauty,Other,Post Office,Travel Agent,Vet & Pet Care,Bookshop,Charity Shop,DIY & Hardware,Department Store,Electronics,Fashion & Clothing,Gift & Hobby,Home & Garden,Newsagent,Pet Shop,Specialist Shop,Sports & Outdoor" ms=6.3
2026-05-06T07:13:05.333847Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=360388 parallel=true cells_before_filter=1242 cells_after_filter=1226 truncated=false bounds=51.4759,-0.2171,51.5526,-0.0354 filters=0 filters_raw="-" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=3.3 json_ms=0.9 total_ms=4.2
2026-05-06T07:13:08.713423Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=360388 parallel=true cells_before_filter=1242 cells_after_filter=1226 truncated=false bounds=51.4759,-0.2171,51.5526,-0.0354 filters=0 filters_raw="-" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=3.2 json_ms=0.9 total_ms=4.1
2026-05-06T07:13:13.136900Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=360388 parallel=true cells_before_filter=1242 cells_after_filter=1226 truncated=false bounds=51.4759,-0.2171,51.5526,-0.0354 filters=0 filters_raw="-" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=3.1 json_ms=0.9 total_ms=4.0
2026-05-06T07:14:05.699501Z INFO property_map_server::routes::features: GET /api/features
2026-05-06T07:14:07.875074Z INFO property_map_server::routes::features: GET /api/features
2026-05-06T07:14:07.877346Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-06T07:14:08.019024Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.4
2026-05-06T07:14:08.941722Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=163 cells_after_filter=163 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=0 filters_raw="-" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.9 json_ms=0.1 total_ms=3.1
2026-05-06T07:14:09.179080Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=1 travel=1 total=41544 filters_raw="-" ms=12.0
2026-05-06T07:14:18.221435Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.1
2026-05-06T07:14:18.236889Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.3
2026-05-06T07:14:19.840645Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.5 json_ms=0.1 total_ms=4.7
2026-05-06T07:14:19.866316Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=20439 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=24.0
2026-05-06T07:14:26.999239Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=2446 cells_after_filter=2433 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=8.4 json_ms=2.5 total_ms=10.9
2026-05-06T07:14:31.542557Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=5.3 json_ms=0.2 total_ms=5.5
2026-05-06T07:14:37.389719Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=418717 parallel=true cells_before_filter=1460 cells_after_filter=1344 truncated=false bounds=51.4738,-0.2209,51.5536,-0.0316 filters=0 filters_raw="-" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=5.7 json_ms=1.1 total_ms=6.8
2026-05-06T07:14:37.391144Z INFO property_map_server::routes::pois: GET /api/pois results=10000 candidates=29908 categories=94 categories_raw="Airport,Bus station,Bus stop,Ferry,Rail station,Taxi rank,Tube station,Aldi,Asda,Bakery,Booths,Budgens,Butcher & Fishmonger,COOK,Co-op,Convenience Store,Costco,Deli & Specialty,Farmfoods,Greengrocer,Heron Foods,Iceland,Lidl,M&S,Makro,Morrisons,Off-Licence,Planet Organic,Sainsbury's,Spar,Supermarket,Tesco,Waitrose,Whole Foods Market,Bar,Café,Cinema,Entertainment,Fast Food,Live Music & Events,Nightclub,Park,Playground,Pub,Restaurant,Sports Centre,Theatre,School,Care Home,Counselling & Therapy,Dentist,GP Surgery,Hospital & Clinic,Medical & Mobility,Optician,Pharmacy,Physiotherapy,Ambulance Station,Fire Station,Police,Community Centre,EV Charging,Fuel Station,Hotel,Local Business,Offices,Arts Centre,Gallery,Library,Museum,Place of Worship,Tourist Attraction,Zoo,Bank,Car Services,Dry Cleaner & Laundry,Gym & Fitness,Hairdresser & Beauty,Other,Post Office,Travel Agent,Vet & Pet Care,Bookshop,Charity Shop,DIY & Hardware,Department Store,Electronics,Fashion & Clothing,Gift & Hobby,Home & Garden,Newsagent,Pet Shop,Specialist Shop,Sports & Outdoor" ms=7.8
2026-05-06T07:14:38.216101Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=234725 parallel=true cells_before_filter=806 cells_after_filter=680 truncated=false bounds=51.4911,-0.1905,51.5453,-0.0620 filters=0 filters_raw="-" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=3.7 json_ms=0.8 total_ms=4.5
2026-05-06T07:14:38.222170Z INFO property_map_server::routes::pois: GET /api/pois results=10000 candidates=19659 categories=94 categories_raw="Airport,Bus station,Bus stop,Ferry,Rail station,Taxi rank,Tube station,Aldi,Asda,Bakery,Booths,Budgens,Butcher & Fishmonger,COOK,Co-op,Convenience Store,Costco,Deli & Specialty,Farmfoods,Greengrocer,Heron Foods,Iceland,Lidl,M&S,Makro,Morrisons,Off-Licence,Planet Organic,Sainsbury's,Spar,Supermarket,Tesco,Waitrose,Whole Foods Market,Bar,Café,Cinema,Entertainment,Fast Food,Live Music & Events,Nightclub,Park,Playground,Pub,Restaurant,Sports Centre,Theatre,School,Care Home,Counselling & Therapy,Dentist,GP Surgery,Hospital & Clinic,Medical & Mobility,Optician,Pharmacy,Physiotherapy,Ambulance Station,Fire Station,Police,Community Centre,EV Charging,Fuel Station,Hotel,Local Business,Offices,Arts Centre,Gallery,Library,Museum,Place of Worship,Tourist Attraction,Zoo,Bank,Car Services,Dry Cleaner & Laundry,Gym & Fitness,Hairdresser & Beauty,Other,Post Office,Travel Agent,Vet & Pet Care,Bookshop,Charity Shop,DIY & Hardware,Department Store,Electronics,Fashion & Clothing,Gift & Hobby,Home & Garden,Newsagent,Pet Shop,Specialist Shop,Sports & Outdoor" ms=8.5
2026-05-06T07:14:44.419446Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=105806 parallel=true cells_before_filter=404 cells_after_filter=317 truncated=false bounds=51.4911,-0.1541,51.5453,-0.0984 filters=0 filters_raw="-" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=2.2 json_ms=0.4 total_ms=2.5
2026-05-06T07:14:44.423580Z INFO property_map_server::routes::pois: GET /api/pois results=10000 candidates=11596 categories=94 categories_raw="Airport,Bus station,Bus stop,Ferry,Rail station,Taxi rank,Tube station,Aldi,Asda,Bakery,Booths,Budgens,Butcher & Fishmonger,COOK,Co-op,Convenience Store,Costco,Deli & Specialty,Farmfoods,Greengrocer,Heron Foods,Iceland,Lidl,M&S,Makro,Morrisons,Off-Licence,Planet Organic,Sainsbury's,Spar,Supermarket,Tesco,Waitrose,Whole Foods Market,Bar,Café,Cinema,Entertainment,Fast Food,Live Music & Events,Nightclub,Park,Playground,Pub,Restaurant,Sports Centre,Theatre,School,Care Home,Counselling & Therapy,Dentist,GP Surgery,Hospital & Clinic,Medical & Mobility,Optician,Pharmacy,Physiotherapy,Ambulance Station,Fire Station,Police,Community Centre,EV Charging,Fuel Station,Hotel,Local Business,Offices,Arts Centre,Gallery,Library,Museum,Place of Worship,Tourist Attraction,Zoo,Bank,Car Services,Dry Cleaner & Laundry,Gym & Fitness,Hairdresser & Beauty,Other,Post Office,Travel Agent,Vet & Pet Care,Bookshop,Charity Shop,DIY & Hardware,Department Store,Electronics,Fashion & Clothing,Gift & Hobby,Home & Garden,Newsagent,Pet Shop,Specialist Shop,Sports & Outdoor" ms=6.6
2026-05-06T07:14:44.661886Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.3 json_ms=0.1 total_ms=4.4
2026-05-06T07:14:44.686961Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=29.1
2026-05-06T07:14:46.392737Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=32331 parallel=false cells_before_filter=163 cells_after_filter=146 truncated=false bounds=51.4978,-0.1490,51.5322,-0.1110 filters=0 filters_raw="-" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=1.9 json_ms=0.1 total_ms=2.1
2026-05-06T07:14:46.394380Z INFO property_map_server::routes::pois: GET /api/pois results=6986 candidates=7010 categories=94 categories_raw="Airport,Bus station,Bus stop,Ferry,Rail station,Taxi rank,Tube station,Aldi,Asda,Bakery,Booths,Budgens,Butcher & Fishmonger,COOK,Co-op,Convenience Store,Costco,Deli & Specialty,Farmfoods,Greengrocer,Heron Foods,Iceland,Lidl,M&S,Makro,Morrisons,Off-Licence,Planet Organic,Sainsbury's,Spar,Supermarket,Tesco,Waitrose,Whole Foods Market,Bar,Café,Cinema,Entertainment,Fast Food,Live Music & Events,Nightclub,Park,Playground,Pub,Restaurant,Sports Centre,Theatre,School,Care Home,Counselling & Therapy,Dentist,GP Surgery,Hospital & Clinic,Medical & Mobility,Optician,Pharmacy,Physiotherapy,Ambulance Station,Fire Station,Police,Community Centre,EV Charging,Fuel Station,Hotel,Local Business,Offices,Arts Centre,Gallery,Library,Museum,Place of Worship,Tourist Attraction,Zoo,Bank,Car Services,Dry Cleaner & Laundry,Gym & Fitness,Hairdresser & Beauty,Other,Post Office,Travel Agent,Vet & Pet Care,Bookshop,Charity Shop,DIY & Hardware,Department Store,Electronics,Fashion & Clothing,Gift & Hobby,Home & Garden,Newsagent,Pet Shop,Specialist Shop,Sports & Outdoor" ms=3.7
2026-05-06T07:14:55.383153Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89195da4d2bffff resolution=9 total_count=166 filters=0 filters_raw="-" ms=0.3
2026-05-06T07:14:55.521238Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=470423 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3851,-2.4904,53.5738,-2.0185 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.6 json_ms=0.1 total_ms=2.7
2026-05-06T07:14:57.292200Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=387398 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3999,-2.4614,53.5591,-2.0632 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.2 json_ms=0.1 total_ms=3.3
2026-05-06T07:14:57.327444Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=387398 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=16.0
2026-05-06T07:14:58.285866Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=328746 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.4065,-2.4487,53.5527,-2.0829 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.5 json_ms=0.1 total_ms=2.6
2026-05-06T07:14:58.350203Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=328746 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=16.5
2026-05-06T07:14:58.617181Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89195da4d63ffff resolution=9 total_count=382 filters=0 filters_raw="-" ms=0.3
2026-05-06T07:15:00.740312Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b70abffff resolution=9 total_count=1487 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=1.2
2026-05-06T07:15:02.197947Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=283073 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.4065,-2.4029,53.5527,-2.1286 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.1 json_ms=0.1 total_ms=2.2
2026-05-06T07:15:02.491433Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=283073 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=12.8
2026-05-06T07:15:13.901971Z INFO property_map_server::routes::features: GET /api/features
2026-05-06T07:15:15.283795Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-06T07:15:15.284213Z INFO property_map_server::routes::features: GET /api/features
2026-05-06T07:15:15.347797Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.6
2026-05-06T07:15:15.636170Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=163 cells_after_filter=163 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=0 filters_raw="-" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.9 json_ms=0.1 total_ms=5.1
2026-05-06T07:15:15.900377Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=1 travel=1 total=41544 filters_raw="-" ms=13.9
2026-05-06T07:15:24.985267Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.1
2026-05-06T07:15:25.003392Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.1
2026-05-06T07:15:25.539775Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.0 json_ms=0.2 total_ms=3.2
2026-05-06T07:15:25.556452Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=20439 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=19.8
2026-05-06T07:15:27.290918Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=64311 parallel=true cells_before_filter=249 cells_after_filter=229 truncated=false bounds=51.4958,-0.1632,51.5342,-0.0968 filters=0 filters_raw="-" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=2.3 json_ms=0.3 total_ms=2.6
2026-05-06T07:15:27.294614Z INFO property_map_server::routes::pois: GET /api/pois results=10000 candidates=11740 categories=94 categories_raw="Airport,Bus station,Bus stop,Ferry,Rail station,Taxi rank,Tube station,Aldi,Asda,Bakery,Booths,Budgens,Butcher & Fishmonger,COOK,Co-op,Convenience Store,Costco,Deli & Specialty,Farmfoods,Greengrocer,Heron Foods,Iceland,Lidl,M&S,Makro,Morrisons,Off-Licence,Planet Organic,Sainsbury's,Spar,Supermarket,Tesco,Waitrose,Whole Foods Market,Bar,Café,Cinema,Entertainment,Fast Food,Live Music & Events,Nightclub,Park,Playground,Pub,Restaurant,Sports Centre,Theatre,School,Care Home,Counselling & Therapy,Dentist,GP Surgery,Hospital & Clinic,Medical & Mobility,Optician,Pharmacy,Physiotherapy,Ambulance Station,Fire Station,Police,Community Centre,EV Charging,Fuel Station,Hotel,Local Business,Offices,Arts Centre,Gallery,Library,Museum,Place of Worship,Tourist Attraction,Zoo,Bank,Car Services,Dry Cleaner & Laundry,Gym & Fitness,Hairdresser & Beauty,Other,Post Office,Travel Agent,Vet & Pet Care,Bookshop,Charity Shop,DIY & Hardware,Department Store,Electronics,Fashion & Clothing,Gift & Hobby,Home & Garden,Newsagent,Pet Shop,Specialist Shop,Sports & Outdoor" ms=6.3
2026-05-06T07:15:33.072972Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=2446 cells_after_filter=2433 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=7.3 json_ms=2.6 total_ms=9.9
2026-05-06T07:15:33.220203Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=134 cells_after_filter=134 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.7 json_ms=0.1 total_ms=3.9
2026-05-06T07:15:47.139336Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=556230 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3675,-2.5248,53.5912,-1.9654 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.1 json_ms=0.1 total_ms=4.2
2026-05-06T07:15:47.158892Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=556230 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=23.7
2026-05-06T07:16:01.664304Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=387398 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.3999,-2.4614,53.5591,-2.0632 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.3 json_ms=0.1 total_ms=3.4
2026-05-06T07:16:02.312074Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=338007 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.4063,-2.4490,53.5529,-2.0824 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.8 json_ms=0.1 total_ms=2.9
2026-05-06T07:16:03.862614Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=328746 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.4065,-2.4487,53.5527,-2.0829 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.5 json_ms=0.1 total_ms=2.6
2026-05-06T07:16:03.875136Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=328746 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=15.0
2026-05-06T07:16:06.187821Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=891951b70abffff resolution=9 total_count=1487 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=1.1
2026-05-06T07:16:07.771759Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=283073 parallel=true cells_before_filter=59 cells_after_filter=59 truncated=false bounds=53.4065,-2.4029,53.5527,-2.1286 filters=4 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.4 json_ms=0.1 total_ms=2.4
2026-05-06T07:16:07.828161Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=283073 filters=5 travel=1 total=12434 filters_raw="Property type:Flats/Maisonettes|Terraced;;Estimated current price:175000:450000;;Serious crime per 1k residents (avg/yr):0:55;;Noise (dB):50:68" ms=14.7
2026-05-06T07:16:41.428217Z INFO property_map_server::routes::features: GET /api/features
2026-05-06T07:16:41.429461Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-06T07:16:41.458276Z INFO property_map_server::routes::features: GET /api/features
2026-05-06T07:16:41.464802Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-06T07:16:41.466920Z INFO property_map_server::routes::features: GET /api/features
2026-05-06T07:16:41.468098Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-06T07:16:41.474421Z INFO property_map_server::routes::features: GET /api/features
2026-05-06T07:16:41.475516Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-06T18:23:02.915468Z INFO property_map_server::routes::features: GET /api/features
2026-05-06T18:23:02.922130Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11

View file

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

View file

@ -23,20 +23,16 @@ 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:
# Right-pane inspection (~16s output) is the clearest paused-state preview:
# Manchester map, filters applied, right pane populated, larger narration
# caption visible.
POSTER_TIME_S="${POSTER_TIME_S:-8}"
POSTER_TIME_S="${POSTER_TIME_S:-16}"
FRESH_AUTH="${FORCE_AUTH:-0}"
DO_ENCODE=1
@ -134,13 +130,12 @@ 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" CAPTURE_SCALE="$CAPTURE_SCALE" \
RECORD_SCALE="$RECORD_SCALE" OUTPUT_FPS="$OUTPUT_FPS" \
node dist/record.js
APP_URL="$APP_URL" node dist/record.js
if [ ! -s output/recording.webm ]; then
fail "recording.webm missing or empty"
fi
node dist/verify.js output/recording.webm
# -- encode -------------------------------------------------------------------
if [ "$DO_ENCODE" = "1" ]; then
@ -164,6 +159,8 @@ if [ "$DO_ENCODE" = "1" ]; then
ffmpeg -y -loglevel warning -i output/recording.mp4 -ss "$POSTER_TIME_S" \
-frames:v 1 -update 1 -q:v 2 \
output/poster.jpg
node dist/verify.js output/recording.mp4 output/poster.jpg
fi
# -- publish to homepage ------------------------------------------------------
@ -177,6 +174,7 @@ if [ "$DO_ENCODE" = "1" ]; then
say "Publishing to $PUBLISH_DIR"
cp output/recording.mp4 "$PUBLISH_DIR/recording.mp4"
cp output/poster.jpg "$PUBLISH_DIR/poster.jpg"
node dist/verify.js "$PUBLISH_DIR/recording.mp4" "$PUBLISH_DIR/poster.jpg"
fi
# -- report -------------------------------------------------------------------

123
video/src/browser.ts Normal file
View file

@ -0,0 +1,123 @@
import { chromium, type Browser, type BrowserContext } from 'playwright';
import { AUTH_STATE_PATH, CAPTURE_SCALE, OUTPUT_DIR, VIDEO_SIZE, VIEWPORT } from './config.js';
export interface RecordingBrowser {
browser: Browser;
context: BrowserContext;
}
export async function launchRecordingBrowser(): Promise<RecordingBrowser> {
const browser = await chromium.launch({
headless: true,
args: [
'--disable-blink-features=AutomationControlled',
'--use-gl=angle',
'--use-angle=swiftshader',
'--enable-unsafe-swiftshader',
'--ignore-gpu-blocklist',
'--disable-frame-rate-limit',
'--disable-gpu-vsync',
'--disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling',
'--disable-renderer-backgrounding',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
],
});
const context = await browser.newContext({
storageState: AUTH_STATE_PATH,
viewport: VIEWPORT,
deviceScaleFactor: CAPTURE_SCALE,
recordVideo: { dir: OUTPUT_DIR, size: VIDEO_SIZE },
});
await suppressDevServerNoise(context);
return { browser, context };
}
async function suppressDevServerNoise(context: BrowserContext) {
await context.addInitScript(() => {
(window as typeof window & { __demoRecording?: boolean }).__demoRecording = true;
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') ||
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);
},
});
Object.defineProperty(window.location, 'reload', {
value: () => {},
configurable: true,
});
window.addEventListener('error', (e) => e.stopImmediatePropagation(), true);
window.addEventListener('unhandledrejection', (e) => e.stopImmediatePropagation(), true);
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);
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 })
);
}
});
}

View file

@ -50,7 +50,6 @@ export const STUBBED_TRAVEL_TIME_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;
@ -59,12 +58,6 @@ export const TT_DRAG_TO_MIN = 20;
// 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 = {
@ -73,29 +66,17 @@ export const INITIAL_MAP_VIEW = {
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';
// Verification guard only. The renderer no longer uses this as an editing cap:
// if the storyboard needs more than 15 seconds to avoid jumps, keep the frames.
export const MAX_DURATION_S = Number(process.env.MAX_DURATION_S ?? 45);
export const MIN_DURATION_S = Number(process.env.MIN_DURATION_S ?? 10);
// 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 while recording, then speed the output back up in
// ffmpeg. A higher scale makes rendering take longer, but gives the 25fps raw
// recorder enough unique frames for a smooth 50fps final without shortcut cuts.
export const RECORD_SCALE = Math.max(1, Number(process.env.RECORD_SCALE ?? 3.5));
// 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. With RECORD_SCALE=2 this matches the real
// captured frame cadence, so the MP4 does not need synthetic interpolation.
// Target fps of the FINAL output.
export const OUTPUT_FPS = Number(process.env.OUTPUT_FPS ?? 50);
// Brand strings for the outro card.

View file

@ -1,4 +1,5 @@
import type { Page } from 'playwright';
import { RECORD_SCALE } from './config.js';
/**
* Inject a visible cursor that mirrors the real mouse position. The browser's
@ -110,17 +111,19 @@ export async function installCursor(page: Page): Promise<void> {
#__demo-outro-card {
text-align: center;
color: white;
opacity: 1;
transform: translateY(0) scale(1);
opacity: 0;
transform: translateY(12px) scale(0.985);
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 #__demo-outro-card {
animation: __demo-outro-pop 620ms cubic-bezier(0.22,1,0.36,1) both;
}
@keyframes __demo-outro-pop {
0% { transform: translateY(10px) scale(0.985); }
100% { transform: translateY(0) scale(1); }
0% { opacity: 0; transform: translateY(12px) scale(0.985); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
#__demo-outro-brand {
font: 760 72px/1.05 ui-sans-serif, system-ui, sans-serif;
@ -177,6 +180,15 @@ export async function installCursor(page: Page): Promise<void> {
},
{ passive: true, capture: true }
);
(window as typeof window & {
__demoMoveCursor?: (x: number, y: number, durationMs: number) => void;
}).__demoMoveCursor = (x, y, durationMs) => {
cursor.style.transition = `transform ${durationMs}ms cubic-bezier(0.22, 1, 0.36, 1), scale 120ms ease-out`;
cursor.style.transform = `translate(${x - 2}px, ${y - 2}px)`;
window.setTimeout(() => {
cursor.style.transition = 'transform 60ms linear, scale 120ms ease-out';
}, durationMs + 40);
};
window.addEventListener(
'mousedown',
@ -236,6 +248,29 @@ export async function flashRect(
}, rect);
}
export async function visualClick(
page: Page,
point: { x: number; y: number },
rippleColor = 'rgba(20, 184, 166, 0.9)'
): Promise<void> {
await page.evaluate(
({ point, rippleColor }) => {
const cursor = document.getElementById('__demo-cursor');
cursor?.classList.add('click');
window.setTimeout(() => cursor?.classList.remove('click'), 140);
const r = document.createElement('div');
r.className = '__demo-ripple';
r.style.left = `${point.x}px`;
r.style.top = `${point.y}px`;
r.style.borderColor = rippleColor;
document.body.appendChild(r);
window.setTimeout(() => r.remove(), 650);
},
{ point, rippleColor }
);
}
export async function showOutro(
page: Page,
brand: string,
@ -247,7 +282,6 @@ export async function showOutro(
document.getElementById('__demo-caption')?.classList.remove('visible');
const el = document.createElement('div');
el.id = '__demo-outro';
el.className = 'visible';
el.innerHTML = `
<div id="__demo-outro-card">
<div id="__demo-outro-brand">${brand}</div>
@ -255,6 +289,9 @@ export async function showOutro(
<div id="__demo-outro-url">${url}</div>
</div>`;
document.body.appendChild(el);
requestAnimationFrame(() => {
requestAnimationFrame(() => el.classList.add('visible'));
});
},
{ brand, tagline, url }
);
@ -336,79 +373,30 @@ export async function zoomTo(
opts: { scale: number; focusX: number; focusY: number; durationMs?: number }
): Promise<void> {
const { scale, focusX, focusY, durationMs = 1100 } = opts;
const transitionMs = Math.round(durationMs * RECORD_SCALE);
const 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 }) => {
({ dx, dy, scale, transitionMs }) => {
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.transition = `transform ${transitionMs}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 }
{ dx, dy, scale, transitionMs }
);
}
/** Animate the wrapper back to identity transform. */
export async function zoomReset(page: Page, durationMs = 1100): Promise<void> {
await page.evaluate((durationMs) => {
const transitionMs = Math.round(durationMs * RECORD_SCALE);
await page.evaluate((transitionMs) => {
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.transition = `transform ${transitionMs}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 }
);
}, transitionMs);
}
/**
@ -447,3 +435,83 @@ export async function scrollPaneTo(
{ selector, top }
);
}
export async function waitForAnimationFrames(page: Page, frames = 3): Promise<void> {
await page.evaluate(
(frameCount) =>
new Promise<void>((resolve) => {
let seen = 0;
const tick = () => {
seen += 1;
if (seen >= frameCount) resolve();
else requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}),
frames
);
}
export async function getDemoMapSettleVersion(page: Page): Promise<number> {
return page.evaluate(
() =>
(
window as typeof window & {
__demoMapSettleVersion?: number;
}
).__demoMapSettleVersion ?? 0
);
}
export async function waitForDemoMapSettled(
page: Page,
timeoutMs = 12000,
afterVersion = -1
): Promise<void> {
await page.waitForFunction(
(version) => {
const demo = window as typeof window & {
__demoMapSettled?: boolean;
__demoMapSettleVersion?: number;
__demoMapIdle?: boolean;
};
return (
demo.__demoMapSettled === true &&
demo.__demoMapIdle === true &&
(demo.__demoMapSettleVersion ?? 0) > version
);
},
afterVersion,
{ timeout: timeoutMs }
);
await waitForAnimationFrames(page, 4);
}
export async function waitForCurrentDemoMapSettled(page: Page, timeoutMs = 12000): Promise<void> {
await page.waitForFunction(
() => {
const demo = window as typeof window & {
__demoMapSettled?: boolean;
__demoMapIdle?: boolean;
};
return demo.__demoMapSettled === true && demo.__demoMapIdle === true;
},
undefined,
{ timeout: timeoutMs }
);
await waitForAnimationFrames(page, 4);
}
export async function waitForDemoSelectionReady(page: Page, timeoutMs = 12000): Promise<void> {
await page.waitForFunction(
() =>
(
window as typeof window & {
__demoSelectionReady?: boolean;
}
).__demoSelectionReady === true,
undefined,
{ timeout: timeoutMs }
);
await waitForAnimationFrames(page, 4);
}

View file

@ -12,30 +12,22 @@ export const sleep = (ms: number) =>
export const easeInOut = (t: number): number =>
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
// Slight overshoot then settle — gives clicks a tactile feel when paired with ripple.
export const easeOutBack = (t: number): number => {
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
};
interface MoveOptions {
durationMs?: number;
ease?: (t: number) => number;
realMouse?: boolean;
/**
* 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
* Override the per-step CDP cost used to size the loop. During a drag, every
* mouse.move fires a pointermove -> React re-render -> thumb position update
* on the same thread, pushing effective per-step cost higher. Pass that for drags
* so the loop's wall duration matches `durationMs * RECORD_SCALE`.
*/
stepBudgetMs?: number;
}
// Empirical Playwright→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;
// Empirical Playwright-to-Chromium CDP roundtrip cost for a mouse.move command
// while recording the software-GL dashboard.
const CDP_MOVE_MS = 70;
/**
* Move the real mouse from its current position to (x, y) along an eased path.
@ -51,10 +43,36 @@ export async function smoothMove(
page: Page,
from: { x: number; y: number },
to: { x: number; y: number },
{ durationMs = 600, ease = easeInOut, stepBudgetMs = CDP_MOVE_MS }: MoveOptions = {}
{
durationMs = 600,
ease = easeInOut,
realMouse = false,
stepBudgetMs = CDP_MOVE_MS,
}: MoveOptions = {}
): Promise<void> {
const wallDuration = durationMs * RECORD_SCALE;
const steps = Math.max(2, Math.min(28, Math.round(wallDuration / stepBudgetMs)));
if (!realMouse) {
const animated = await page.evaluate(
({ x, y, wallDuration }) => {
const move = (
window as typeof window & {
__demoMoveCursor?: (x: number, y: number, durationMs: number) => void;
}
).__demoMoveCursor;
if (!move) return false;
move(x, y, wallDuration);
return true;
},
{ x: to.x, y: to.y, wallDuration }
);
if (animated) {
await new Promise((resolve) => setTimeout(resolve, wallDuration));
await page.mouse.move(to.x, to.y);
return;
}
}
const steps = Math.max(2, Math.min(96, 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;
@ -76,7 +94,7 @@ export async function fakeType(
delayMs: number
): Promise<void> {
const delay = delayMs * RECORD_SCALE;
const steps = Math.min(6, text.length);
const steps = text.length;
for (let i = 1; i <= steps; i++) {
const end = Math.ceil((text.length * i) / steps);
await page.evaluate(
@ -121,14 +139,13 @@ export async function smoothDragSliderThumb(
// smoothMove already applies RECORD_SCALE internally; pass human-time durations.
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.
// The user explicitly prefers a longer render over stepped motion, so use
// enough real pointer updates for the thumb itself to read as continuous.
await smoothMove(
page,
{ x: thumbCx, y: thumbCy },
{ x: targetX, y: thumbCy },
{ durationMs, stepBudgetMs: 360 }
{ durationMs, realMouse: true, stepBudgetMs: 135 }
);
await page.mouse.up();
return { x: targetX, y: thumbCy };

View file

@ -1,241 +1,23 @@
import { chromium } from 'playwright';
import { execSync } from 'node:child_process';
import { existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
import { existsSync, mkdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import {
APP_URL,
AUTH_STATE_PATH,
CAPTURE_SCALE,
DASHBOARD_PATH,
INITIAL_MAP_VIEW,
MAX_DURATION_S,
OUTPUT_FPS,
OUTPUT_DIR,
PRELOAD_POSTCODE,
RECORD_SCALE,
STUBBED_FILTERS,
STUBBED_TRAVEL_TIME_FILTERS,
VIDEO_SIZE,
VIEWPORT,
WEBM_BITRATE,
} from './config.js';
import { installCursor, installZoomWrapper } from './dom.js';
import {
preZoomToAiBox,
sceneAiCloseUp,
sceneClusterClick,
sceneExportAndOutro,
sceneTravelTimeSlider,
sceneZoomOutResults,
type SceneCtx,
} from './scenes.js';
import { sleep } from './motion.js';
/**
* Stub the AI endpoint. The real backend calls Gemini and takes 25s; for a
* 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, 180));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
filters: STUBBED_FILTERS,
travel_time_filters: STUBBED_TRAVEL_TIME_FILTERS,
notes: '',
match_count: 1247,
}),
});
});
}
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}`);
}
}
import { AUTH_STATE_PATH, OUTPUT_DIR } from './config.js';
import { launchRecordingBrowser } from './browser.js';
import { installDemoRoutes } from './routes.js';
import { prepareTimeline, runTimeline } from './timeline.js';
import { trimRecording } from './video.js';
async function main() {
if (!existsSync(AUTH_STATE_PATH)) {
console.error(
`No ${AUTH_STATE_PATH} found. Run "npm run setup-auth" first to log in once.`
);
console.error(`No ${AUTH_STATE_PATH} found. Run "npm run setup-auth" first.`);
process.exit(1);
}
if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true });
const browser = await chromium.launch({
headless: true,
args: [
'--disable-blink-features=AutomationControlled',
// Headless Chromium otherwise loses the WebGL context mid-render when
// deck.gl pushes large buffers; SwiftShader is software GL but stable.
'--use-gl=angle',
'--use-angle=swiftshader',
'--enable-unsafe-swiftshader',
'--ignore-gpu-blocklist',
// Lift Chromium's animation/raster rate caps so RECORD_SCALE actually
// gets us extra frames per second of wall time. Without these, Chromium
// throttles offscreen rendering and the slow-down is wasted.
'--disable-frame-rate-limit',
'--disable-gpu-vsync',
'--disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling',
'--disable-renderer-backgrounding',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
],
});
const context = await browser.newContext({
storageState: AUTH_STATE_PATH,
viewport: 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
// module isn't accept-marked the client triggers a FULL page reload — which
// mid-recording resets the React tree and re-shows "Connecting to server…".
// Disable the client-side HMR socket entirely.
await context.addInitScript(() => {
// Block the vite-hmr WebSocket so HMR push messages never arrive.
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') ||
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);
},
});
// Belt-and-braces: even if an HMR push slips through (e.g. via a different
// 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 { browser, context } = await launchRecordingBrowser();
const page = await context.newPage();
const recordedVideo = page.video();
// recordVideo starts the moment the page is created. We want the final clip
// 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') {
console.log(`[browser ${m.type()}] ${m.text()}`);
@ -251,87 +33,14 @@ async function main() {
const u = r.url();
if (u.includes('ai-filters')) console.log(`[req] ${r.method()} ${u}`);
});
await stubAiFilters(page);
await stubExport(page);
// 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 });
// 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 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();
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 installDemoRoutes(page);
const ctx = await prepareTimeline(page);
const timeline = await runTimeline(ctx);
await page.close();
const rawPath = join(OUTPUT_DIR, 'recording.raw.webm');
if (recordedVideo) {
await recordedVideo.saveAs(rawPath);
}
if (recordedVideo) await recordedVideo.saveAs(rawPath);
await context.close();
await browser.close();
@ -340,46 +49,10 @@ async function main() {
process.exit(1);
}
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 (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 ${maxFinalDuration.toFixed(2)}s (anchored to outro).`
);
}
// 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". 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},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' }
);
// Drop the untrimmed file once we've extracted the scenes.
try {
statSync(rawPath) && renameSync(rawPath, rawPath + '.untrimmed');
} catch {
/* ignore */
}
console.log(
`Wrote ${trimmedPath} (${finalDuration.toFixed(2)}s, scale=${RECORD_SCALE}, capture=${VIDEO_SIZE.width}x${VIDEO_SIZE.height})`
);
trimRecording(rawPath, join(OUTPUT_DIR, 'recording.webm'), {
recordStartMs,
...timeline,
});
console.log('Run "npm run encode" to produce output/recording.mp4');
}

58
video/src/routes.ts Normal file
View file

@ -0,0 +1,58 @@
import type { Page } from 'playwright';
import {
APP_URL,
DASHBOARD_PATH,
INITIAL_MAP_VIEW,
STUBBED_FILTERS,
STUBBED_TRAVEL_TIME_FILTERS,
} from './config.js';
export async function installDemoRoutes(page: Page) {
await Promise.all([stubAiFilters(page), stubExport(page)]);
}
export function dashboardUrl(): string {
const params = new URLSearchParams({
lat: String(INITIAL_MAP_VIEW.lat),
lon: String(INITIAL_MAP_VIEW.lon),
zoom: String(INITIAL_MAP_VIEW.zoom),
});
addInitialTravelTimeParams(params);
return `${APP_URL}${DASHBOARD_PATH}?${params}`;
}
async function stubAiFilters(page: Page) {
await page.route('**/api/ai-filters', async (route) => {
await new Promise((r) => setTimeout(r, 120));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
filters: STUBBED_FILTERS,
travel_time_filters: STUBBED_TRAVEL_TIME_FILTERS,
notes: '',
match_count: 1247,
}),
});
});
}
async function stubExport(page: Page) {
await page.route('**/api/export?**', async (route) => {
await new Promise((r) => setTimeout(r, 120));
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}`);
}
}

View file

@ -13,13 +13,16 @@ import {
import {
clearVignette,
flashRect,
getDemoMapSettleVersion,
hideCaption,
scrollPaneTo,
showCaption,
showOutro,
visualClick,
waitForDemoMapSettled,
waitForCurrentDemoMapSettled,
waitForDemoSelectionReady,
zoomReset,
zoomTo,
zoomToInstant,
} from './dom.js';
import { fakeType, sleep, smoothMove, smoothDragSliderThumb } from './motion.js';
@ -29,37 +32,36 @@ export interface SceneCtx {
}
/**
* 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.
* Scene 1: start wide, then zoom into the AI prompt. The AI response is
* stubbed, while the map filters and right pane are loaded from the real app.
*/
export async function sceneAiCloseUp(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await clearVignette(page);
await showCaption(page, 'Brief: flats or terraces under £450k near central Manchester.');
await sleep(160);
await sleep(180);
await fakeType(page, '[data-tutorial="ai-filters"] textarea', PROMPT_TEXT, 14);
await sleep(120);
await zoomToAiBox(page, 720);
await sleep(760);
await fakeType(page, '[data-tutorial="ai-filters"] textarea', PROMPT_TEXT, 18);
await sleep(160);
const aiResponse = page
.waitForResponse(
(response) => response.url().includes('/api/ai-filters') && response.status() === 200,
{ timeout: 1800 }
)
.catch(() => null);
const mapVersion = await getDemoMapSettleVersion(page);
await page.evaluate(() => {
document.querySelector<HTMLFormElement>('[data-tutorial="ai-filters"] form')?.requestSubmit();
});
await aiResponse;
await sleep(160);
await waitForDemoMapSettled(page, 15000, mapVersion);
await showCaption(page, 'The filters are already live on the map.');
await sleep(360);
await sleep(560);
await hideCaption(page);
}
@ -72,10 +74,10 @@ export async function sceneAiCloseUp(ctx: SceneCtx): Promise<void> {
export async function sceneZoomOutResults(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await showCaption(page, 'Now the view lands on central Manchester with the filters already set.');
await zoomReset(page, 560);
await sleep(420);
await zoomReset(page, 860);
await sleep(980);
await hideCaption(page);
await sleep(80);
await sleep(180);
}
/**
@ -107,6 +109,7 @@ export async function sceneTravelTimeSlider(ctx: SceneCtx): Promise<void> {
// Slider goes 0..120, target = 20 → fraction 0.166...
const toFraction = TT_DRAG_TO_MIN / TT_SLIDER_MAX;
const mapVersion = await getDemoMapSettleVersion(page);
ctx.cursor = await smoothDragSliderThumb(
page,
@ -114,87 +117,90 @@ export async function sceneTravelTimeSlider(ctx: SceneCtx): Promise<void> {
trackSelector,
ctx.cursor,
toFraction,
520
1180
);
await sleep(120);
await sleep(220);
await waitForDemoMapSettled(page, 16000, mapVersion);
await showCaption(page, 'The map redraws around the areas that still work.');
await sleep(440);
await sleep(720);
await hideCaption(page);
await sleep(60);
await sleep(180);
}
/**
* 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.
* Scene 4: after the filtered result map is visible, zoom into Manchester,
* click a hexagon, then let the right pane open from that selection.
*/
export async function sceneClusterClick(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
const viewport = page.viewportSize() ?? { width: 1920, height: 1080 };
await showCaption(page, 'Open one promising area and check the detail before shortlisting.');
await showCaption(page, 'Zoom into the result clusters, then click a promising hexagon.');
// 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,
x: 360 + (viewport.width - 360) * 0.35,
y: viewport.height * 0.52,
};
await smoothMove(page, ctx.cursor, cluster, { durationMs: 260 });
await smoothMove(page, ctx.cursor, cluster, { durationMs: 520 });
ctx.cursor = cluster;
await sleep(70);
await sleep(220);
// 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);
await zoomMapWithWheel(page, cluster);
// 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);
const clicked = await clickHexagon(page, cluster);
ctx.cursor = clicked;
await openDemoHexagon(page);
await page.locator('[data-tutorial="right-pane"]').waitFor({ state: 'visible', timeout: 5000 });
await waitForDemoSelectionReady(page, 16000);
await sleep(360);
await showCaption(
page,
'This is the useful pause: local stats, matching homes, and street context together.'
);
await sleep(520);
await sleep(1000);
await hideCaption(page);
}
async function clickHexagon(
page: Page,
target: { x: number; y: number }
): Promise<{ x: number; y: number }> {
await visualClick(page, target);
await sleep(140);
return target;
}
async function zoomMapWithWheel(page: Page, target: { x: number; y: number }): Promise<void> {
await page.mouse.move(target.x, target.y);
for (let i = 0; i < 5; i++) {
await page.mouse.wheel(0, -120);
await sleep(95);
}
await waitForCurrentDemoMapSettled(page, 16000);
await sleep(260);
}
async function openDemoHexagon(page: Page): Promise<void> {
const selected = await page.evaluate(
() =>
(
window as typeof window & {
__demoOpenBestHexagon?: () => string | null;
}
).__demoOpenBestHexagon?.() ?? null
);
if (!selected) throw new Error('Could not open a demo hexagon selection');
}
/** 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);
await zoomReset(page, 680);
await sleep(520);
const exportButton = page.locator('button[title="Export to Excel"]').first();
await exportButton.waitFor({ state: 'visible', timeout: 4000 });
@ -202,34 +208,25 @@ export async function sceneExportAndOutro(ctx: SceneCtx): Promise<void> {
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 });
await smoothMove(page, ctx.cursor, target, { durationMs: 620 });
ctx.cursor = target;
await sleep(70);
await sleep(160);
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 sleep(680);
await hideCaption(page);
await showOutro(ctx.page, BRAND_NAME, BRAND_TAGLINE, BRAND_URL);
void download;
await sleep(2400);
await sleep(2200);
}
/**
* 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> {
/** Open the AI prompt before the timed scene starts. */
export async function prepareAiBox(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 });
@ -247,13 +244,13 @@ export async function preZoomToAiBox(ctx: SceneCtx): Promise<void> {
}
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.
async function zoomToAiBox(page: Page, durationMs: number): Promise<void> {
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 });
await zoomTo(page, { scale: AI_ZOOM_SCALE, focusX, focusY, durationMs });
}

58
video/src/timeline.ts Normal file
View file

@ -0,0 +1,58 @@
import type { Page } from 'playwright';
import { installCursor, installZoomWrapper, waitForCurrentDemoMapSettled } from './dom.js';
import { sleep } from './motion.js';
import { dashboardUrl } from './routes.js';
import {
prepareAiBox,
sceneAiCloseUp,
sceneClusterClick,
sceneExportAndOutro,
sceneTravelTimeSlider,
sceneZoomOutResults,
type SceneCtx,
} from './scenes.js';
export interface TimelineResult {
sceneStartMs: number;
sceneEndMs: number;
}
export async function prepareTimeline(page: Page): Promise<SceneCtx> {
await page.goto(dashboardUrl(), { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('load', { timeout: 15000 }).catch(() => {});
await page
.locator('[data-tutorial="ai-filters"]')
.waitFor({ state: 'visible', timeout: 15000 });
await page.locator('canvas').first().waitFor({ state: 'attached', timeout: 15000 });
await waitForCurrentDemoMapSettled(page, 15000);
await new Promise((r) => setTimeout(r, 400));
await installZoomWrapper(page);
await installCursor(page);
const ctx: SceneCtx = { page, cursor: { x: 200, y: 240 } };
await page.mouse.move(ctx.cursor.x, ctx.cursor.y);
await prepareAiBox(ctx);
await sleep(80);
return ctx;
}
export async function runTimeline(ctx: SceneCtx): Promise<TimelineResult> {
const sceneStartMs = Date.now();
let mark = sceneStartMs;
mark = await runScene('AI close-up', mark, () => sceneAiCloseUp(ctx));
mark = await runScene('Zoom out', mark, () => sceneZoomOutResults(ctx));
mark = await runScene('TT slider', mark, () => sceneTravelTimeSlider(ctx));
mark = await runScene('Cluster click', mark, () => sceneClusterClick(ctx));
mark = await runScene('Export + outro', mark, () => sceneExportAndOutro(ctx));
return { sceneStartMs, sceneEndMs: mark };
}
async function runScene(label: string, prev: number, scene: () => Promise<void>): Promise<number> {
await scene();
const now = Date.now();
console.log(`[scene] ${label}: ${((now - prev) / 1000).toFixed(2)}s wall`);
return now;
}

88
video/src/verify.ts Normal file
View file

@ -0,0 +1,88 @@
import { execFileSync } from 'node:child_process';
import { existsSync, statSync } from 'node:fs';
import { MAX_DURATION_S, MIN_DURATION_S, OUTPUT_FPS, OUTPUT_DIR, VIDEO_SIZE } from './config.js';
interface Probe {
streams?: {
width?: number;
height?: number;
avg_frame_rate?: string;
r_frame_rate?: string;
}[];
format?: {
duration?: string;
size?: string;
};
}
function fail(message: string): never {
console.error(`[verify] FAIL: ${message}`);
process.exit(1);
}
function parseRate(rate: string | undefined): number {
if (!rate) return 0;
const [num, den] = rate.split('/').map(Number);
if (!num || !den) return Number(rate) || 0;
return num / den;
}
function probe(path: string): Probe {
const raw = execFileSync(
'ffprobe',
[
'-v',
'error',
'-select_streams',
'v:0',
'-show_entries',
'stream=width,height,r_frame_rate,avg_frame_rate',
'-show_entries',
'format=duration,size',
'-of',
'json',
path,
],
{ encoding: 'utf8' }
);
return JSON.parse(raw) as Probe;
}
function verifyVideo(path: string) {
if (!existsSync(path)) fail(`${path} is missing`);
if (statSync(path).size === 0) fail(`${path} is empty`);
const data = probe(path);
const stream = data.streams?.[0];
if (!stream) fail(`${path} has no video stream`);
const duration = Number(data.format?.duration ?? 0);
const fps = parseRate(stream.avg_frame_rate || stream.r_frame_rate);
if (stream.width !== VIDEO_SIZE.width || stream.height !== VIDEO_SIZE.height) {
fail(`${path} is ${stream.width}x${stream.height}, expected ${VIDEO_SIZE.width}x${VIDEO_SIZE.height}`);
}
if (duration < MIN_DURATION_S || duration > MAX_DURATION_S) {
fail(
`${path} duration is ${duration.toFixed(2)}s, expected ${MIN_DURATION_S}-${MAX_DURATION_S}s`
);
}
if (Math.abs(fps - OUTPUT_FPS) > 0.1) {
fail(`${path} is ${fps.toFixed(2)}fps, expected ${OUTPUT_FPS}fps`);
}
console.log(
`[verify] ${path}: ${stream.width}x${stream.height}, ${duration.toFixed(2)}s, ${fps.toFixed(2)}fps`
);
}
function verifyImage(path: string) {
if (!existsSync(path)) fail(`${path} is missing`);
if (statSync(path).size === 0) fail(`${path} is empty`);
console.log(`[verify] ${path}: ${statSync(path).size} bytes`);
}
const videoPath = process.argv[2] ?? `${OUTPUT_DIR}/recording.mp4`;
const posterPath = process.argv[3] ?? (process.argv[2] ? undefined : `${OUTPUT_DIR}/poster.jpg`);
verifyVideo(videoPath);
if (posterPath) verifyImage(posterPath);

58
video/src/video.ts Normal file
View file

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