seems fine

This commit is contained in:
Andras Schmelczer 2026-05-05 22:29:28 +01:00
parent 48983e3b4b
commit 7a1696541f
37 changed files with 4999 additions and 1242 deletions

View file

@ -5,11 +5,6 @@
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>1.0</priority> <priority>1.0</priority>
</url> </url>
<url>
<loc>https://perfect-postcode.co.uk/dashboard</loc>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<url> <url>
<loc>https://perfect-postcode.co.uk/learn</loc> <loc>https://perfect-postcode.co.uk/learn</loc>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
@ -20,4 +15,59 @@
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url>
<loc>https://perfect-postcode.co.uk/property-price-map</loc>
<changefreq>monthly</changefreq>
<priority>0.85</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/postcode-property-search</loc>
<changefreq>monthly</changefreq>
<priority>0.85</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/commute-property-search</loc>
<changefreq>monthly</changefreq>
<priority>0.85</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/school-property-search</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/postcode-checker</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/property-search/birmingham</loc>
<changefreq>monthly</changefreq>
<priority>0.75</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/property-search/manchester</loc>
<changefreq>monthly</changefreq>
<priority>0.75</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/property-search/bristol</loc>
<changefreq>monthly</changefreq>
<priority>0.75</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/data-sources</loc>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/methodology</loc>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://perfect-postcode.co.uk/privacy-security</loc>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
</urlset> </urlset>

View file

@ -1,10 +1,141 @@
import { createServer } from 'http'; import { createServer } from 'http';
import { readFileSync, writeFileSync, existsSync, statSync } from 'fs'; import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'fs';
import { join, extname } from 'path'; import { join, extname, dirname } from 'path';
import { launch } from 'puppeteer'; import { launch } from 'puppeteer';
const DIST_DIR = join(import.meta.dirname, '..', 'dist'); const DIST_DIR = join(import.meta.dirname, '..', 'dist');
const INDEX_PATH = join(DIST_DIR, 'index.html'); const INDEX_PATH = join(DIST_DIR, 'index.html');
const PUBLIC_URL = 'https://perfect-postcode.co.uk';
const OG_PLACEHOLDER = '<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__"/>';
const ROUTES = [
{
path: '/',
output: 'index.html',
title: 'Perfect Postcode - Find where to buy before browsing listings',
description:
'Search every postcode by budget, commute, schools, safety, noise, broadband, prices and more. Build a better home-buying shortlist before viewings.',
},
{
path: '/learn',
output: 'learn/index.html',
title: 'How Perfect Postcode works - Data sources, FAQ and support',
description:
'Learn how Perfect Postcode combines property prices, EPC records, travel times, crime, schools, broadband, noise, amenities and open data for postcode research.',
},
{
path: '/pricing',
output: 'pricing/index.html',
title: 'Perfect Postcode pricing - Lifetime property search map access',
description:
'Get lifetime access to the postcode property search map for England, including filters, saved searches, exports, and future data updates.',
},
{
path: '/property-price-map',
output: 'property-price-map/index.html',
title: 'Property price map for England - Compare postcodes before viewing',
description:
'Compare sold prices, estimated current value, price per square metre and local context across English postcodes before searching listings.',
},
{
path: '/postcode-property-search',
output: 'postcode-property-search/index.html',
title: 'Postcode property search - Find areas that match your criteria',
description:
'Search every postcode by budget, property type, floor area, tenure, commute, schools, crime, broadband, noise, parks and local amenities.',
},
{
path: '/commute-property-search',
output: 'commute-property-search/index.html',
title: 'Commute property search - Find places to live by travel time',
description:
'Filter postcodes by commute time, then compare price, schools, safety, broadband, road noise, parks and property data on one map.',
},
{
path: '/school-property-search',
output: 'school-property-search/index.html',
title: 'School property search - Compare postcodes for family moves',
description:
'Compare nearby schools, property size, prices, parks, safety, commute and local amenities before building a viewing shortlist.',
},
{
path: '/postcode-checker',
output: 'postcode-checker/index.html',
title: 'Postcode checker - Property, crime, broadband, noise and schools',
description:
'Check postcode-level property prices, EPC data, crime, broadband, road noise, schools, council tax, amenities and travel-time context.',
},
{
path: '/property-search/birmingham',
output: 'property-search/birmingham/index.html',
title: 'Birmingham property search - Compare postcodes by price and commute',
description:
'Use postcode-level data to compare Birmingham property prices, commute trade-offs, schools, crime, broadband and local amenities before viewings.',
},
{
path: '/property-search/manchester',
output: 'property-search/manchester/index.html',
title: 'Manchester property search - Compare postcodes before viewing',
description:
'Compare Manchester-area postcodes by budget, commute, property type, schools, broadband, crime, noise and amenities before booking viewings.',
},
{
path: '/property-search/bristol',
output: 'property-search/bristol/index.html',
title: 'Bristol property search - Compare postcodes by commute and price',
description:
'Compare Bristol postcodes by price, commute, property size, schools, broadband, crime, road noise, parks and amenities before viewings.',
},
{
path: '/data-sources',
output: 'data-sources/index.html',
title: 'Perfect Postcode data sources - Property, schools, commute and local context',
description:
'Review the public and official datasets used by Perfect Postcode, including property prices, EPC, schools, crime, broadband, noise and travel-time context.',
},
{
path: '/methodology',
output: 'methodology/index.html',
title: 'Perfect Postcode methodology - How to interpret postcode property data',
description:
'Understand how to use postcode filters, property estimates, travel-time data, school context and local signals as a home-buying shortlist tool.',
},
{
path: '/privacy-security',
output: 'privacy-security/index.html',
title: 'Perfect Postcode privacy and security - Saved searches and account data',
description:
'Learn how Perfect Postcode treats saved searches, account data and property research workflows with privacy and security in mind.',
},
];
const FAQ_SCHEMA_ITEMS = [
{
question: 'Where should I look once the obvious areas are too expensive?',
answer:
'Set your budget, property type, floor area, commute, schools, crime, noise, broadband, parks, and other must-haves. The map removes postcodes that fail those tests, so overlooked areas can surface before you start searching listings.',
},
{
question: 'What should I do when my search returns too many or too few areas?',
answer:
'Start with hard limits, then colour the map by a trade-off such as price per sqm, road noise, school score, or commute time. If the map gets too narrow, relax one slider and you can see exactly which compromise opens up more options.',
},
{
question: 'How are the travel times calculated?',
answer:
'Travel times are precomputed with Conveyal R5, a routing engine used for transport analysis. For each supported destination we route to reachable postcodes over the street and transit network, then store sparse postcode travel-time files for car, cycling, walking, and public transport.',
},
{
question: 'How does the estimated current price algorithm work?',
answer:
'The estimate starts with the last HM Land Registry sale price, adjusts it to current-market terms using repeat-sales modelling and fallback models, then blends that result with a nearest-neighbour estimate from nearby, recently sold, same-type homes.',
},
{
question: 'Should I use this before or after checking Rightmove?',
answer:
'Use Perfect Postcode before and alongside listing portals. Rightmove, Zoopla, and OnTheMarket are still where you check live availability, photos, agent contact, viewings, and alerts.',
},
];
const MIME_TYPES = { const MIME_TYPES = {
'.html': 'text/html', '.html': 'text/html',
@ -13,9 +144,151 @@ const MIME_TYPES = {
'.json': 'application/json', '.json': 'application/json',
'.png': 'image/png', '.png': 'image/png',
'.jpg': 'image/jpeg', '.jpg': 'image/jpeg',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.svg': 'image/svg+xml', '.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
}; };
function escapeAttr(value) {
return value
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
}
function routeUrl(pathname) {
return `${PUBLIC_URL}${pathname === '/' ? '/' : pathname}`;
}
function jsonLd(data) {
return `<script type="application/ld+json">${JSON.stringify(data).replaceAll(
'<',
'\\u003c'
)}</script>`;
}
function structuredDataForRoute(route) {
const url = routeUrl(route.path);
const base = [
{
'@context': 'https://schema.org',
'@type': 'WebPage',
'@id': `${url}#webpage`,
url,
name: route.title,
description: route.description,
isPartOf: { '@id': `${PUBLIC_URL}/#website` },
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Perfect Postcode',
item: `${PUBLIC_URL}/`,
},
...(route.path === '/'
? []
: [
{
'@type': 'ListItem',
position: 2,
name: route.title.split(' - ')[0],
item: url,
},
]),
],
},
];
if (route.path === '/') {
base.push(
{
'@context': 'https://schema.org',
'@type': 'Organization',
'@id': `${PUBLIC_URL}/#organization`,
name: 'Perfect Postcode',
url: `${PUBLIC_URL}/`,
logo: `${PUBLIC_URL}/favicon.svg`,
},
{
'@context': 'https://schema.org',
'@type': 'WebSite',
'@id': `${PUBLIC_URL}/#website`,
name: 'Perfect Postcode',
url: `${PUBLIC_URL}/`,
publisher: { '@id': `${PUBLIC_URL}/#organization` },
},
{
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: 'Perfect Postcode',
applicationCategory: 'BusinessApplication',
operatingSystem: 'Web',
url: `${PUBLIC_URL}/`,
description: route.description,
}
);
}
if (route.path === '/learn') {
base.push({
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: FAQ_SCHEMA_ITEMS.map((item) => ({
'@type': 'Question',
name: item.question,
acceptedAnswer: {
'@type': 'Answer',
text: item.answer,
},
})),
});
}
if (route.path === '/pricing') {
base.push({
'@context': 'https://schema.org',
'@type': 'Product',
name: 'Perfect Postcode lifetime access',
description: route.description,
brand: { '@type': 'Brand', name: 'Perfect Postcode' },
});
}
return base;
}
function updateHead(indexHtml, route) {
const structuredData = structuredDataForRoute(route).map(jsonLd).join('\n ');
return indexHtml
.replace(/<title>.*?<\/title>/, `<title>${escapeAttr(route.title)}</title>`)
.replace(
/<meta name="description" content="[^"]*" ?\/?>/,
`<meta name="description" content="${escapeAttr(route.description)}" />`
)
.replace(OG_PLACEHOLDER, `${OG_PLACEHOLDER}\n ${structuredData}`);
}
function cleanBaseIndexHtml(indexHtml) {
const withoutStructuredData = indexHtml.replace(
/<script type="application\/ld\+json">.*?<\/script>\s*/gs,
''
);
const rootStart = withoutStructuredData.indexOf('<div id="root">');
const bodyEnd = withoutStructuredData.indexOf('</body>', rootStart);
if (rootStart === -1 || bodyEnd === -1) {
return withoutStructuredData;
}
return `${withoutStructuredData.slice(0, rootStart)}<div id="root"></div>${withoutStructuredData.slice(bodyEnd)}`;
}
function startServer() { function startServer() {
return new Promise((resolve) => { return new Promise((resolve) => {
const server = createServer((req, res) => { const server = createServer((req, res) => {
@ -53,81 +326,101 @@ async function prerender() {
}); });
try { try {
const page = await browser.newPage(); const baseIndexHtml = cleanBaseIndexHtml(readFileSync(INDEX_PATH, 'utf-8'));
// Intercept API requests to prevent real fetches and retry loops for (const route of ROUTES) {
await page.setRequestInterception(true); const page = await browser.newPage();
page.on('request', (req) => {
const url = req.url(); // Intercept API requests to prevent real fetches and retry loops.
if (url.includes('/api/features')) { await page.setRequestInterception(true);
req.respond({ page.on('request', (req) => {
status: 200, const url = req.url();
contentType: 'application/json', if (url.includes('/api/features')) {
body: JSON.stringify({ groups: [] }), req.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ groups: [] }),
});
} else if (url.includes('/api/poi-categories')) {
req.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ groups: [] }),
});
} else if (url.includes('/api/pricing')) {
req.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
licensed_count: 120,
current_price_pence: 999,
tiers: [
{ up_to: 50, price_pence: 99, slots: 50 },
{ up_to: 150, price_pence: 999, slots: 100 },
{ up_to: 250, price_pence: 2999, slots: 100 },
{ up_to: 350, price_pence: 4999, slots: 100 },
{ up_to: null, price_pence: 9999, slots: 0 },
],
}),
});
} else if (url.includes('/api/')) {
req.respond({
status: 200,
contentType: 'application/json',
body: '{}',
});
} else {
req.continue();
}
});
await page.goto(`http://127.0.0.1:${port}${route.path}`, {
waitUntil: 'networkidle0',
timeout: 30000,
});
await page.waitForSelector('h1', { timeout: 10000 });
// Extract and clean the rendered HTML.
const html = await page.evaluate(() => {
const root = document.getElementById('root');
if (!root) return '';
// Strip fade-in-visible classes (added by IntersectionObserver effects).
root.querySelectorAll('.fade-in-visible').forEach((el) => {
el.classList.remove('fade-in-visible');
}); });
} else if (url.includes('/api/poi-categories')) {
req.respond({ // Clean canvas elements (dimensions set by ResizeObserver effect).
status: 200, root.querySelectorAll('canvas').forEach((canvas) => {
contentType: 'application/json', canvas.removeAttribute('width');
body: JSON.stringify({ groups: [] }), canvas.removeAttribute('height');
canvas.style.removeProperty('width');
canvas.style.removeProperty('height');
}); });
} else if (url.includes('/api/')) {
req.respond({ return root.innerHTML;
status: 200, });
contentType: 'application/json',
body: '{}', if (!html || html.length < 100) {
}); throw new Error(`Prerender produced too little HTML for ${route.path}`);
} else {
req.continue();
} }
});
await page.goto(`http://127.0.0.1:${port}/`, { const updated = updateHead(baseIndexHtml, route).replace(
waitUntil: 'networkidle0', '<div id="root"></div>',
timeout: 30000, `<div id="root">${html}</div>`
}); );
// Wait for the home page heading to render if (updated === baseIndexHtml) {
await page.waitForSelector('h1', { timeout: 10000 }); throw new Error('Could not find <div id="root"></div> in index.html');
}
// Extract and clean the rendered HTML const outputPath = join(DIST_DIR, route.output);
const html = await page.evaluate(() => { mkdirSync(dirname(outputPath), { recursive: true });
const root = document.getElementById('root'); writeFileSync(outputPath, updated);
if (!root) return ''; await page.close();
console.log(`Prerendered ${route.path} (${html.length} chars) into ${route.output}`);
// Strip fade-in-visible classes (added by IntersectionObserver effects)
root.querySelectorAll('.fade-in-visible').forEach((el) => {
el.classList.remove('fade-in-visible');
});
// Clean canvas elements (dimensions set by ResizeObserver effect)
root.querySelectorAll('canvas').forEach((canvas) => {
canvas.removeAttribute('width');
canvas.removeAttribute('height');
canvas.style.removeProperty('width');
canvas.style.removeProperty('height');
});
return root.innerHTML;
});
if (!html || html.length < 100) {
throw new Error('Prerender produced too little HTML — something went wrong');
} }
// Inject into dist/index.html
const indexHtml = readFileSync(INDEX_PATH, 'utf-8');
const updated = indexHtml.replace(
'<div id="root"></div>',
`<div id="root">${html}</div>`
);
if (updated === indexHtml) {
throw new Error('Could not find <div id="root"></div> in index.html');
}
writeFileSync(INDEX_PATH, updated);
console.log(`Prerendered ${html.length} chars into dist/index.html`);
} finally { } finally {
await browser.close(); await browser.close();
server.close(); server.close();

View file

@ -1,14 +1,16 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { lazy, Suspense, useState, useEffect, useCallback, useMemo, useRef } from 'react';
import MapPage, { type ExportState } from './components/map/MapPage'; import type { ExportState } from './components/map/MapPage';
import PricingPage from './components/pricing/PricingPage'; import {
import HomePage from './components/home/HomePage'; getSeoContentPage,
import LearnPage from './components/learn/LearnPage'; getSeoLandingPage,
import AccountPage, { SavedPage, InvitesPage } from './components/account/AccountPage'; isSeoContentKey,
import InvitePage from './components/invite/InvitePage'; isSeoLandingKey,
SEO_CONTENT_PATHS,
SEO_LANDING_PATHS,
type SeoContentKey,
type SeoLandingKey,
} from './lib/seoRoutes';
import Header, { type Page } from './components/ui/Header'; import Header, { type Page } from './components/ui/Header';
import AuthModal from './components/ui/AuthModal';
import SaveSearchModal from './components/ui/SaveSearchModal';
import LicenseSuccessModal from './components/ui/LicenseSuccessModal';
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types'; import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
import { fetchWithRetry, apiUrl } from './lib/api'; import { fetchWithRetry, apiUrl } from './lib/api';
import { trackEvent } from './lib/analytics'; import { trackEvent } from './lib/analytics';
@ -28,6 +30,28 @@ declare global {
} }
} }
const HomePage = lazy(() => import('./components/home/HomePage'));
const PricingPage = lazy(() => import('./components/pricing/PricingPage'));
const LearnPage = lazy(() => import('./components/learn/LearnPage'));
const SeoLandingPage = lazy(() => import('./components/landing/SeoLandingPage'));
const SeoContentPage = lazy(() => import('./components/landing/SeoContentPage'));
const AccountPage = lazy(() => import('./components/account/AccountPage'));
const SavedPage = lazy(() =>
import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage }))
);
const InvitesPage = lazy(() =>
import('./components/account/AccountPage').then((module) => ({ default: module.InvitesPage }))
);
const InvitePage = lazy(() => import('./components/invite/InvitePage'));
const MapPage = lazy(() => import('./components/map/MapPage'));
const AuthModal = lazy(() => import('./components/ui/AuthModal'));
const SaveSearchModal = lazy(() => import('./components/ui/SaveSearchModal'));
const LicenseSuccessModal = lazy(() => import('./components/ui/LicenseSuccessModal'));
function PageFallback() {
return <div className="flex-1 bg-warm-50 dark:bg-navy-950" />;
}
function pageToPath(page: Page, inviteCode?: string): string { function pageToPath(page: Page, inviteCode?: string): string {
switch (page) { switch (page) {
case 'dashboard': case 'dashboard':
@ -36,6 +60,19 @@ function pageToPath(page: Page, inviteCode?: string): string {
return '/learn'; return '/learn';
case 'pricing': case 'pricing':
return '/pricing'; return '/pricing';
case 'property-price-map':
case 'postcode-property-search':
case 'commute-property-search':
case 'school-property-search':
case 'postcode-checker':
return SEO_LANDING_PATHS[page];
case 'birmingham-property-search':
case 'manchester-property-search':
case 'bristol-property-search':
case 'data-sources':
case 'methodology':
case 'privacy-security':
return SEO_CONTENT_PATHS[page];
case 'saved': case 'saved':
return '/saved'; return '/saved';
case 'invites': case 'invites':
@ -55,6 +92,10 @@ function pathToPage(pathname: string): { page: Page; inviteCode?: string } | nul
if (pathname === '/invites') return { page: 'invites' }; if (pathname === '/invites') return { page: 'invites' };
if (pathname === '/learn') return { page: 'learn' }; if (pathname === '/learn') return { page: 'learn' };
if (pathname === '/pricing') return { page: 'pricing' }; if (pathname === '/pricing') return { page: 'pricing' };
const seoLandingPage = getSeoLandingPage(pathname);
if (seoLandingPage) return { page: seoLandingPage };
const seoContentPage = getSeoContentPage(pathname);
if (seoContentPage) return { page: seoContentPage };
if (pathname === '/account') return { page: 'account' }; if (pathname === '/account') return { page: 'account' };
if (pathname === '/support') return { page: 'learn' }; if (pathname === '/support') return { page: 'learn' };
if (pathname.startsWith('/invite/')) { if (pathname.startsWith('/invite/')) {
@ -65,6 +106,14 @@ function pathToPage(pathname: string): { page: Page; inviteCode?: string } | nul
return null; return null;
} }
function isSeoLandingPage(page: Page): page is SeoLandingKey {
return isSeoLandingKey(page);
}
function isSeoContentPage(page: Page): page is SeoContentKey {
return isSeoContentKey(page);
}
export default function App() { export default function App() {
const urlState = useMemo(() => parseUrlState(), []); const urlState = useMemo(() => parseUrlState(), []);
const [mapUrlState, setMapUrlState] = useState(urlState); const [mapUrlState, setMapUrlState] = useState(urlState);
@ -271,37 +320,41 @@ export default function App() {
if ((isScreenshotMode || isOgMode) && inviteCode) { if ((isScreenshotMode || isOgMode) && inviteCode) {
return ( return (
<InvitePage <Suspense fallback={<PageFallback />}>
code={inviteCode} <InvitePage
user={null} code={inviteCode}
theme={theme} user={null}
screenshotMode theme={theme}
onLoginClick={() => {}} screenshotMode
onRegisterClick={() => {}} onLoginClick={() => {}}
onLicenseGranted={() => {}} onRegisterClick={() => {}}
/> onLicenseGranted={() => {}}
/>
</Suspense>
); );
} }
if (isScreenshotMode) { if (isScreenshotMode) {
return ( return (
<MapPage <Suspense fallback={<PageFallback />}>
features={features} <MapPage
poiCategoryGroups={poiCategoryGroups} features={features}
initialFilters={urlState.filters || {}} poiCategoryGroups={poiCategoryGroups}
initialViewState={initialViewState} initialFilters={urlState.filters || {}}
initialPOICategories={urlState.poiCategories || new Set()} initialViewState={initialViewState}
initialTab={urlState.tab || 'area'} initialPOICategories={urlState.poiCategories || new Set()}
initialLoading={initialLoading} initialTab={urlState.tab || 'area'}
theme={theme} initialLoading={initialLoading}
pendingInfoFeature={null} theme={theme}
onClearPendingInfoFeature={() => {}} pendingInfoFeature={null}
onNavigateTo={() => {}} onClearPendingInfoFeature={() => {}}
screenshotMode onNavigateTo={() => {}}
ogMode={isOgMode} screenshotMode
initialTravelTime={urlState.travelTime} ogMode={isOgMode}
shareCode={urlState.share} initialTravelTime={urlState.travelTime}
/> shareCode={urlState.share}
/>
</Suspense>
); );
} }
@ -328,137 +381,145 @@ export default function App() {
onLogout={logout} onLogout={logout}
isMobile={isMobile} isMobile={isMobile}
/> />
{activePage === 'home' ? ( <Suspense fallback={<PageFallback />}>
<HomePage {activePage === 'home' ? (
onOpenDashboard={() => navigateTo('dashboard')} <HomePage
onOpenPricing={() => navigateTo('pricing')} onOpenDashboard={() => navigateTo('dashboard')}
theme={theme} onOpenPricing={() => navigateTo('pricing')}
hidePricing={user?.subscription === 'licensed' || user?.isAdmin} theme={theme}
/> hidePricing={user?.subscription === 'licensed' || user?.isAdmin}
) : activePage === 'pricing' && !(user?.subscription === 'licensed' || user?.isAdmin) ? ( />
<PricingPage ) : activePage === 'pricing' && !(user?.subscription === 'licensed' || user?.isAdmin) ? (
onOpenDashboard={() => navigateTo('dashboard')} <PricingPage
user={user} onOpenDashboard={() => navigateTo('dashboard')}
onLoginClick={() => { user={user}
setAuthModalTab('login'); onLoginClick={() => {
setShowAuthModal(true); setAuthModalTab('login');
}} setShowAuthModal(true);
onRegisterClick={() => { }}
setAuthModalTab('register'); onRegisterClick={() => {
setShowAuthModal(true); setAuthModalTab('register');
}} setShowAuthModal(true);
/> }}
) : activePage === 'learn' ? ( />
<LearnPage /> ) : activePage === 'learn' ? (
) : activePage === 'saved' && user ? ( <LearnPage />
<SavedPage ) : isSeoLandingPage(activePage) ? (
searches={savedSearches.searches} <SeoLandingPage pageKey={activePage} onOpenDashboard={() => navigateTo('dashboard')} />
searchesLoading={savedSearches.loading} ) : isSeoContentPage(activePage) ? (
onDeleteSearch={savedSearches.deleteSearch} <SeoContentPage pageKey={activePage} onOpenDashboard={() => navigateTo('dashboard')} />
onUpdateSearchNotes={savedSearches.updateSearchNotes} ) : activePage === 'saved' && user ? (
onUpdateSearchName={savedSearches.updateSearchName} <SavedPage
onOpenSearch={(params) => { searches={savedSearches.searches}
window.location.href = `/dashboard?${params}`; searchesLoading={savedSearches.loading}
}} onDeleteSearch={savedSearches.deleteSearch}
savedProperties={savedProperties.properties} onUpdateSearchNotes={savedSearches.updateSearchNotes}
propertiesLoading={savedProperties.loading} onUpdateSearchName={savedSearches.updateSearchName}
onDeleteProperty={savedProperties.deleteProperty} onOpenSearch={(params) => {
onUpdatePropertyNotes={savedProperties.updatePropertyNotes} window.location.href = `/dashboard?${params}`;
onOpenProperty={(postcode) => { }}
window.location.href = `/dashboard?pc=${encodeURIComponent(postcode)}`; savedProperties={savedProperties.properties}
}} propertiesLoading={savedProperties.loading}
/> onDeleteProperty={savedProperties.deleteProperty}
) : activePage === 'invites' && user ? ( onUpdatePropertyNotes={savedProperties.updatePropertyNotes}
<InvitesPage user={user} /> onOpenProperty={(postcode) => {
) : activePage === 'account' && user ? ( window.location.href = `/dashboard?pc=${encodeURIComponent(postcode)}`;
<AccountPage user={user} onRefreshAuth={refreshAuth} /> }}
) : activePage === 'invite' && inviteCode ? ( />
<InvitePage ) : activePage === 'invites' && user ? (
code={inviteCode} <InvitesPage user={user} />
user={user} ) : activePage === 'account' && user ? (
theme={theme} <AccountPage user={user} onRefreshAuth={refreshAuth} />
onLoginClick={() => { ) : activePage === 'invite' && inviteCode ? (
setAuthModalTab('login'); <InvitePage
setShowAuthModal(true); code={inviteCode}
}} user={user}
onRegisterClick={() => { theme={theme}
setAuthModalTab('register'); onLoginClick={() => {
setShowAuthModal(true); setAuthModalTab('login');
}} setShowAuthModal(true);
onLicenseGranted={() => { }}
setShowLicenseSuccess(true); onRegisterClick={() => {
refreshAuth(); setAuthModalTab('register');
}} setShowAuthModal(true);
/> }}
) : ( onLicenseGranted={() => {
<MapPage setShowLicenseSuccess(true);
features={features} refreshAuth();
poiCategoryGroups={poiCategoryGroups} }}
initialFilters={mapUrlState.filters || {}} />
initialViewState={initialViewState} ) : (
initialPOICategories={mapUrlState.poiCategories || new Set()} <MapPage
initialTab={mapUrlState.tab || 'area'} features={features}
initialLoading={initialLoading} poiCategoryGroups={poiCategoryGroups}
theme={theme} initialFilters={mapUrlState.filters || {}}
pendingInfoFeature={pendingInfoFeature} initialViewState={initialViewState}
onClearPendingInfoFeature={() => setPendingInfoFeature(null)} initialPOICategories={mapUrlState.poiCategories || new Set()}
onNavigateTo={navigateTo} initialTab={mapUrlState.tab || 'area'}
onExportStateChange={setExportState} initialLoading={initialLoading}
isMobile={isMobile} theme={theme}
initialTravelTime={mapUrlState.travelTime} pendingInfoFeature={pendingInfoFeature}
initialPostcode={mapUrlState.postcode} onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
shareCode={mapUrlState.share} onNavigateTo={navigateTo}
user={user} onExportStateChange={setExportState}
onLoginClick={() => { isMobile={isMobile}
setAuthModalTab('login'); initialTravelTime={mapUrlState.travelTime}
setShowAuthModal(true); initialPostcode={mapUrlState.postcode}
}} shareCode={mapUrlState.share}
onRegisterClick={() => { user={user}
setAuthModalTab('register'); onLoginClick={() => {
setShowAuthModal(true); setAuthModalTab('login');
}} setShowAuthModal(true);
onSaveProperty={user ? savedProperties.saveProperty : undefined} }}
onUnsaveProperty={user ? savedProperties.deleteProperty : undefined} onRegisterClick={() => {
isPropertySaved={user ? savedProperties.isPropertySaved : undefined} setAuthModalTab('register');
getSavedPropertyId={user ? savedProperties.getSavedPropertyId : undefined} setShowAuthModal(true);
deferTutorial={showLicenseSuccess} }}
onSaveSearch={user ? savedSearches.saveSearch : undefined} onSaveProperty={user ? savedProperties.saveProperty : undefined}
savingSearch={savedSearches.saving} onUnsaveProperty={user ? savedProperties.deleteProperty : undefined}
/> isPropertySaved={user ? savedProperties.isPropertySaved : undefined}
)} getSavedPropertyId={user ? savedProperties.getSavedPropertyId : undefined}
{showAuthModal && ( deferTutorial={showLicenseSuccess}
<AuthModal onSaveSearch={user ? savedSearches.saveSearch : undefined}
onClose={() => setShowAuthModal(false)} savingSearch={savedSearches.saving}
onLogin={login} />
onRegister={register} )}
onOAuthLogin={loginWithOAuth} </Suspense>
onForgotPassword={requestPasswordReset} <Suspense fallback={null}>
loading={authLoading} {showAuthModal && (
error={authError} <AuthModal
onClearError={clearError} onClose={() => setShowAuthModal(false)}
initialTab={authModalTab} onLogin={login}
/> onRegister={register}
)} onOAuthLogin={loginWithOAuth}
{showSaveModal && ( onForgotPassword={requestPasswordReset}
<SaveSearchModal loading={authLoading}
onClose={() => setShowSaveModal(false)} error={authError}
onSave={savedSearches.saveSearch} onClearError={clearError}
onViewSearches={() => { initialTab={authModalTab}
setShowSaveModal(false); />
navigateTo('saved'); )}
}} {showSaveModal && (
saving={savedSearches.saving} <SaveSearchModal
error={savedSearches.error} onClose={() => setShowSaveModal(false)}
/> onSave={savedSearches.saveSearch}
)} onViewSearches={() => {
{showLicenseSuccess && ( setShowSaveModal(false);
<LicenseSuccessModal navigateTo('saved');
onClose={() => { }}
setShowLicenseSuccess(false); saving={savedSearches.saving}
navigateTo('dashboard'); error={savedSearches.error}
}} />
/> )}
)} {showLicenseSuccess && (
<LicenseSuccessModal
onClose={() => {
setShowLicenseSuccess(false);
navigateTo('dashboard');
}}
/>
)}
</Suspense>
</div> </div>
); );
} }

View file

@ -6,6 +6,7 @@ interface HexConfig {
size: number; size: number;
opacity: number; opacity: number;
top: number; top: number;
left: number;
driftDuration: number; driftDuration: number;
bobDuration: number; bobDuration: number;
bobAmount: number; bobAmount: number;
@ -21,6 +22,7 @@ function generateHexes(): HexConfig[] {
size: 10 + Math.random() * 32, size: 10 + Math.random() * 32,
opacity: 0.06 + Math.random() * 0.18, opacity: 0.06 + Math.random() * 0.18,
top: Math.random() * 100, top: Math.random() * 100,
left: Math.random() * 100,
driftDuration, driftDuration,
bobDuration: 6 + Math.random() * 8, bobDuration: 6 + Math.random() * 8,
bobAmount: 8 + Math.random() * 30, bobAmount: 8 + Math.random() * 30,
@ -31,7 +33,13 @@ function generateHexes(): HexConfig[] {
return hexes; return hexes;
} }
export default function HexCanvas({ isDark = false }: { isDark?: boolean }) { export default function HexCanvas({
isDark = false,
animated = true,
}: {
isDark?: boolean;
animated?: boolean;
}) {
const hexes = useMemo(generateHexes, []); const hexes = useMemo(generateHexes, []);
return ( return (
@ -42,7 +50,14 @@ export default function HexCanvas({ isDark = false }: { isDark?: boolean }) {
className="absolute" className="absolute"
style={{ style={{
top: `${hex.top}%`, top: `${hex.top}%`,
animation: `hex-drift ${hex.driftDuration}s linear ${hex.delay}s infinite${hex.reverse ? ' reverse' : ''}`, ...(animated
? {
animation: `hex-drift ${hex.driftDuration}s linear ${hex.delay}s infinite${hex.reverse ? ' reverse' : ''}`,
}
: {
left: `${hex.left}%`,
transform: `translate(-50%, -50%) rotate(${hex.delay * 12}deg)`,
}),
}} }}
> >
<div <div
@ -51,9 +66,9 @@ export default function HexCanvas({ isDark = false }: { isDark?: boolean }) {
{ {
width: hex.size, width: hex.size,
height: (hex.size * 2) / Math.sqrt(3), height: (hex.size * 2) / Math.sqrt(3),
opacity: hex.opacity * (isDark ? 0.6 : 1), opacity: hex.opacity * (isDark ? 0.45 : 0.6),
clipPath: 'polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)', clipPath: 'polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)',
animation: `hex-bob ${hex.bobDuration}s ease-in-out infinite`, animation: animated ? `hex-bob ${hex.bobDuration}s ease-in-out infinite` : undefined,
'--bob': `${hex.bobAmount}px`, '--bob': `${hex.bobAmount}px`,
} as React.CSSProperties } as React.CSSProperties
} }

File diff suppressed because it is too large Load diff

View file

@ -203,7 +203,7 @@ export default function InvitePage({
{`\u00A3${(Math.round(pricePence * 0.7) / 100).toFixed(2)}`} {`\u00A3${(Math.round(pricePence * 0.7) / 100).toFixed(2)}`}
</span> </span>
<span className="text-warm-500 dark:text-warm-400 ml-2 text-3xl"> <span className="text-warm-500 dark:text-warm-400 ml-2 text-3xl">
{t('upgrade.once')} {t('pricingPage.lifetime')}
</span> </span>
</div> </div>
)} )}
@ -301,7 +301,9 @@ export default function InvitePage({
<span className="text-3xl font-extrabold text-teal-600 dark:text-teal-400"> <span className="text-3xl font-extrabold text-teal-600 dark:text-teal-400">
{`\u00A3${(Math.round(pricePence * 0.7) / 100).toFixed(2)}`} {`\u00A3${(Math.round(pricePence * 0.7) / 100).toFixed(2)}`}
</span> </span>
<span className="text-warm-500 dark:text-warm-400 ml-1">{t('upgrade.once')}</span> <span className="text-warm-500 dark:text-warm-400 ml-1">
{t('pricingPage.lifetime')}
</span>
</div> </div>
)} )}

View file

@ -260,6 +260,9 @@ export default function LearnPage() {
<> <>
<div className="flex-1"> <div className="flex-1">
<div className="max-w-5xl mx-auto px-6 py-6"> <div className="max-w-5xl mx-auto px-6 py-6">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.dataSources')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6"> <p className="text-warm-600 dark:text-warm-400 mb-6">
{t('learnPage.dataSourcesIntro', { count: DATA_SOURCE_DEFS.length })} {t('learnPage.dataSourcesIntro', { count: DATA_SOURCE_DEFS.length })}
</p> </p>
@ -369,6 +372,9 @@ export default function LearnPage() {
</> </>
) : tab === 'faq' ? ( ) : tab === 'faq' ? (
<div className="max-w-3xl mx-auto px-6 py-6 w-full"> <div className="max-w-3xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.faq')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.faqIntro')}</p> <p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.faqIntro')}</p>
<div className="space-y-8"> <div className="space-y-8">
{FAQ_SECTIONS.map((section) => ( {FAQ_SECTIONS.map((section) => (
@ -387,6 +393,9 @@ export default function LearnPage() {
</div> </div>
) : ( ) : (
<div className="max-w-2xl mx-auto px-6 py-6 w-full"> <div className="max-w-2xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.support')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.supportIntro')}</p> <p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.supportIntro')}</p>
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center"> <div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
<p className="text-warm-600 dark:text-warm-300 mb-2">{t('accountPage.needHelp')}</p> <p className="text-warm-600 dark:text-warm-300 mb-2">{t('accountPage.needHelp')}</p>

View file

@ -33,7 +33,6 @@ interface FeatureBrowserProps {
onClearOpenInfoFeature?: () => void; onClearOpenInfoFeature?: () => void;
travelTimeEntries: TravelTimeEntry[]; travelTimeEntries: TravelTimeEntry[];
onAddTravelTimeEntry: (mode: TransportMode) => void; onAddTravelTimeEntry: (mode: TransportMode) => void;
isLicensed: boolean;
} }
export default function FeatureBrowser({ export default function FeatureBrowser({
@ -47,7 +46,6 @@ export default function FeatureBrowser({
onClearOpenInfoFeature, onClearOpenInfoFeature,
travelTimeEntries: _travelTimeEntries, travelTimeEntries: _travelTimeEntries,
onAddTravelTimeEntry, onAddTravelTimeEntry,
isLicensed,
}: FeatureBrowserProps) { }: FeatureBrowserProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const modes = useTranslatedModes(); const modes = useTranslatedModes();
@ -107,6 +105,11 @@ export default function FeatureBrowser({
onChange={setSearch} onChange={setSearch}
placeholder={t('filters.searchFeatures')} placeholder={t('filters.searchFeatures')}
/> />
{!search && (
<p className="mt-2 px-1 text-xs leading-relaxed text-warm-500 dark:text-warm-400">
{t('filters.chooseFilters')}
</p>
)}
</div> </div>
<div> <div>
{mergedGrouped.map((group) => { {mergedGrouped.map((group) => {
@ -200,10 +203,6 @@ export default function FeatureBrowser({
description={search ? t('filters.tryDifferentSearch') : t('filters.removeFilterHint')} description={search ? t('filters.tryDifferentSearch') : t('filters.removeFilterHint')}
className="px-3 py-4" className="px-3 py-4"
/> />
) : isLicensed ? (
<p className="flex-1 flex items-center justify-center px-4 py-6 text-xs text-center text-warm-400 dark:text-warm-500">
{t('filters.chooseFilters')}
</p>
) : null} ) : null}
</div> </div>
{infoFeature && ( {infoFeature && (

View file

@ -1082,7 +1082,6 @@ export default memo(function Filters({
onClearOpenInfoFeature={onClearOpenInfoFeature} onClearOpenInfoFeature={onClearOpenInfoFeature}
travelTimeEntries={travelTimeEntries} travelTimeEntries={travelTimeEntries}
onAddTravelTimeEntry={handleAddTravelTimeAndScroll} onAddTravelTimeEntry={handleAddTravelTimeAndScroll}
isLicensed={isLicensed}
/> />
{!isLicensed && ( {!isLicensed && (
<div className="shrink-0 flex flex-col items-center px-5 pt-4 pb-0 border-t border-warm-200 dark:border-warm-700"> <div className="shrink-0 flex flex-col items-center px-5 pt-4 pb-0 border-t border-warm-200 dark:border-warm-700">

View file

@ -11,6 +11,10 @@ interface JourneyInstructionsProps {
entries: TravelTimeEntry[]; entries: TravelTimeEntry[];
/** When set, shown as a subtitle (e.g. the central postcode for a hexagon) */ /** When set, shown as a subtitle (e.g. the central postcode for a hexagon) */
label?: string; label?: string;
/** Preloaded journey rows, useful for static demos that should not call the API. */
presetJourneys?: JourneyInstructionPreset[];
className?: string;
showGoogleMapsLink?: boolean;
} }
interface JourneyData { interface JourneyData {
@ -24,6 +28,16 @@ interface JourneyData {
loading: boolean; loading: boolean;
} }
export interface JourneyInstructionPreset {
slug: string;
label: string;
legs: JourneyLeg[] | null;
/** Median (50th percentile) total travel time, including waiting. */
minutes: number | null;
/** Best-case (5th percentile) total travel time. */
bestMinutes?: number | null;
}
// Official TfL line colors + other known London transit // Official TfL line colors + other known London transit
const ROUTE_COLORS: Record<string, { color: string; darkText?: boolean }> = { const ROUTE_COLORS: Record<string, { color: string; darkText?: boolean }> = {
Bakerloo: { color: '#B36305' }, Bakerloo: { color: '#B36305' },
@ -164,14 +178,23 @@ export default function JourneyInstructions({
postcode, postcode,
entries, entries,
label, label,
presetJourneys,
className,
showGoogleMapsLink = true,
}: JourneyInstructionsProps) { }: JourneyInstructionsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [journeys, setJourneys] = useState<JourneyData[]>([]); const [journeys, setJourneys] = useState<JourneyData[]>([]);
// Only transit entries with a destination set // Only transit entries with a destination set
const transitEntries = entries.filter((e) => e.mode === 'transit' && e.slug !== ''); const transitEntries = entries.filter((e) => e.mode === 'transit' && e.slug !== '');
const hasPresetJourneys = Boolean(presetJourneys?.length);
useEffect(() => { useEffect(() => {
if (hasPresetJourneys) {
setJourneys([]);
return;
}
if (transitEntries.length === 0) { if (transitEntries.length === 0) {
setJourneys([]); setJourneys([]);
return; return;
@ -227,18 +250,29 @@ export default function JourneyInstructions({
}); });
return () => controller.abort(); return () => controller.abort();
}, [postcode, transitEntries.map((e) => e.slug).join(',')]); // eslint-disable-line react-hooks/exhaustive-deps }, [postcode, hasPresetJourneys, transitEntries.map((e) => e.slug).join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
if (transitEntries.length === 0) return null; if (transitEntries.length === 0 && !hasPresetJourneys) return null;
const displayedJourneys: JourneyData[] = hasPresetJourneys
? (presetJourneys ?? []).map((journey) => ({
slug: journey.slug,
label: journey.label,
legs: journey.legs,
minutes: journey.minutes,
bestMinutes: journey.bestMinutes ?? null,
loading: false,
}))
: journeys;
return ( return (
<div className="mx-3 mt-2 space-y-2"> <div className={className ?? 'mx-3 mt-2 space-y-2'}>
{label && ( {label && (
<div className="text-xs text-warm-500 dark:text-warm-400"> <div className="text-xs text-warm-500 dark:text-warm-400">
{t('areaPane.journeysFrom', { label })} {t('areaPane.journeysFrom', { label })}
</div> </div>
)} )}
{journeys.map((j) => { {displayedJourneys.map((j) => {
const displayLegs = j.legs ? invertLegs(j.legs) : null; const displayLegs = j.legs ? invertLegs(j.legs) : null;
const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0; const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0;
const totalMin = j.minutes ?? legSum; const totalMin = j.minutes ?? legSum;
@ -267,27 +301,29 @@ export default function JourneyInstructions({
{displayLegs.map((leg, i) => ( {displayLegs.map((leg, i) => (
<TimelineLeg key={i} leg={leg} isLast={i === displayLegs.length - 1} /> <TimelineLeg key={i} leg={leg} isLast={i === displayLegs.length - 1} />
))} ))}
<a {showGoogleMapsLink && (
href={googleMapsUrl(postcode, j.label || j.slug)} <a
target="_blank" href={googleMapsUrl(postcode, j.label || j.slug)}
rel="noopener noreferrer" target="_blank"
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors" rel="noopener noreferrer"
> className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
{t('areaPane.viewOnGoogleMaps')}
<svg
className="w-3 h-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
> >
<path {t('areaPane.viewOnGoogleMaps')}
d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5" <svg
strokeLinecap="round" className="w-3 h-3"
strokeLinejoin="round" viewBox="0 0 12 12"
/> fill="none"
</svg> stroke="currentColor"
</a> strokeWidth="1.5"
>
<path
d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
)}
</div> </div>
) : j.minutes != null ? ( ) : j.minutes != null ? (
<div> <div>
@ -297,27 +333,29 @@ export default function JourneyInstructions({
{t('areaPane.walk')} · {j.minutes} {t('common.min')} {t('areaPane.walk')} · {j.minutes} {t('common.min')}
</span> </span>
</div> </div>
<a {showGoogleMapsLink && (
href={googleMapsUrl(postcode, j.label || j.slug)} <a
target="_blank" href={googleMapsUrl(postcode, j.label || j.slug)}
rel="noopener noreferrer" target="_blank"
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors" rel="noopener noreferrer"
> className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
{t('areaPane.viewOnGoogleMaps')}
<svg
className="w-3 h-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
> >
<path {t('areaPane.viewOnGoogleMaps')}
d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5" <svg
strokeLinecap="round" className="w-3 h-3"
strokeLinejoin="round" viewBox="0 0 12 12"
/> fill="none"
</svg> stroke="currentColor"
</a> strokeWidth="1.5"
>
<path
d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
)}
</div> </div>
) : ( ) : (
<span className="text-xs text-warm-500 dark:text-warm-400"> <span className="text-xs text-warm-500 dark:text-warm-400">

View file

@ -13,6 +13,10 @@ export interface SearchedLocation {
geometry: PostcodeGeometry; geometry: PostcodeGeometry;
latitude: number; latitude: number;
longitude: number; longitude: number;
markerLatitude?: number;
markerLongitude?: number;
openProperties?: boolean;
focusAddress?: string;
} }
const ZOOM_FOR_TYPE: Record<string, number> = { const ZOOM_FOR_TYPE: Record<string, number> = {
@ -81,6 +85,46 @@ export default function LocationSearch({
return; return;
} }
if (result.type === 'address') {
setError(null);
setLoading(true);
search.close();
try {
const res = await fetch(
`/api/postcode/${encodeURIComponent(result.postcode)}`,
authHeaders()
);
if (!res.ok) {
setError(t('locationSearch.postcodeNotFound'));
return;
}
const json: {
postcode: string;
latitude: number;
longitude: number;
geometry: PostcodeGeometry;
} = await res.json();
onFlyTo(result.lat, result.lon, 17);
onLocationSearched?.({
postcode: json.postcode,
geometry: json.geometry,
latitude: result.lat,
longitude: result.lon,
markerLatitude: result.lat,
markerLongitude: result.lon,
openProperties: true,
focusAddress: result.address,
});
search.clear();
if (isMobile) setExpanded(false);
} catch {
setError(t('locationSearch.lookupFailed'));
} finally {
setLoading(false);
}
return;
}
// Postcode — fetch geometry // Postcode — fetch geometry
setError(null); setError(null);
setLoading(true); setLoading(true);

View file

@ -48,6 +48,8 @@ interface MapProps {
filterRange: [number, number] | null; filterRange: [number, number] | null;
viewSource: 'drag' | 'eye' | null; viewSource: 'drag' | 'eye' | null;
onCancelPin: () => void; onCancelPin: () => void;
onResetPreviewScale?: () => void;
canResetPreviewScale?: boolean;
features: FeatureMeta[]; features: FeatureMeta[];
selectedHexagonId: string | null; selectedHexagonId: string | null;
hoveredHexagonId: string | null; hoveredHexagonId: string | null;
@ -77,6 +79,49 @@ interface Dimensions {
height: number; height: number;
} }
interface DeckWithPrivateDraw {
_drawLayers?: (
redrawReason: string,
renderOptions?: { viewports?: unknown[]; [key: string]: unknown }
) => unknown;
__propertyMapNullViewportPatch?: boolean;
}
function patchNullViewportDraw(overlay: MapboxOverlay) {
const deck = (overlay as unknown as { _deck?: DeckWithPrivateDraw })._deck;
if (!deck || deck.__propertyMapNullViewportPatch || typeof deck._drawLayers !== 'function') {
return;
}
const drawLayers = deck._drawLayers.bind(deck);
deck._drawLayers = (redrawReason, renderOptions) => {
const viewports = renderOptions?.viewports;
if (viewports) {
// Split-route startup can hand deck.gl a transient null viewport before MapLibre has sized the map.
const nonNullViewports = viewports.filter(Boolean);
if (nonNullViewports.length === 0) return;
if (nonNullViewports.length !== viewports.length) {
return drawLayers(redrawReason, { ...renderOptions, viewports: nonNullViewports });
}
}
return drawLayers(redrawReason, renderOptions);
};
deck.__propertyMapNullViewportPatch = true;
}
class SafeMapboxOverlay extends MapboxOverlay {
onAdd(map: unknown) {
const element = super.onAdd(map);
patchNullViewportDraw(this);
return element;
}
setProps(props: Parameters<MapboxOverlay['setProps']>[0]) {
super.setProps(props);
patchNullViewportDraw(this);
}
}
function DeckOverlay({ function DeckOverlay({
layers, layers,
getTooltip, getTooltip,
@ -86,10 +131,13 @@ function DeckOverlay({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
getTooltip: any; getTooltip: any;
}) { }) {
const overlay = useControl(() => new MapboxOverlay({ interleaved: true })); const overlay = useControl(() => new SafeMapboxOverlay({ interleaved: true }));
useEffect(() => { useEffect(() => {
overlay.setProps({ layers: layers.filter(Boolean), getTooltip }); overlay.setProps({
layers: layers.filter(Boolean),
getTooltip,
});
}, [overlay, layers, getTooltip]); }, [overlay, layers, getTooltip]);
return null; return null;
@ -106,6 +154,8 @@ export default memo(function Map({
filterRange, filterRange,
viewSource, viewSource,
onCancelPin, onCancelPin,
onResetPreviewScale,
canResetPreviewScale = false,
features, features,
selectedHexagonId, selectedHexagonId,
hoveredHexagonId, hoveredHexagonId,
@ -311,7 +361,7 @@ export default memo(function Map({
) : null ) : null
) : ( ) : (
<> <>
<div className="absolute top-3 left-3 right-3 z-10 flex flex-wrap items-start justify-between gap-2 pointer-events-none"> <div className="absolute top-3 left-3 right-3 z-[60] flex flex-wrap items-start justify-between gap-2 pointer-events-none">
<LocationSearch <LocationSearch
onFlyTo={handleFlyTo} onFlyTo={handleFlyTo}
onLocationSearched={onLocationSearched} onLocationSearched={onLocationSearched}
@ -330,6 +380,8 @@ export default memo(function Map({
range={colorRange} range={colorRange}
showCancel={viewSource === 'eye'} showCancel={viewSource === 'eye'}
onCancel={onCancelPin} onCancel={onCancelPin}
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
resetScaleDisabled={!canResetPreviewScale}
mode="feature" mode="feature"
theme={theme} theme={theme}
suffix=" min" suffix=" min"
@ -344,6 +396,8 @@ export default memo(function Map({
range={colorRange} range={colorRange}
showCancel={viewSource === 'eye'} showCancel={viewSource === 'eye'}
onCancel={onCancelPin} onCancel={onCancelPin}
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
resetScaleDisabled={!canResetPreviewScale}
mode="feature" mode="feature"
enumValues={ enumValues={
colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { lazy, Suspense, useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { cellToLatLng } from 'h3-js'; import { cellToLatLng } from 'h3-js';
import type { import type {
@ -11,15 +11,8 @@ import type {
} from '../../types'; } from '../../types';
import type { SearchedLocation } from './LocationSearch'; import type { SearchedLocation } from './LocationSearch';
import type { Page } from '../ui/Header'; import type { Page } from '../ui/Header';
import Map from './Map';
import Filters from './Filters';
import POIPane from './POIPane';
import { PropertiesPane } from './PropertiesPane';
import AreaPane from './AreaPane';
import MobileDrawer from './MobileDrawer';
import MobileBottomSheet from './MobileBottomSheet'; import MobileBottomSheet from './MobileBottomSheet';
import MapLegend from './MapLegend'; import MapLegend from './MapLegend';
import { MapPageSelectionPane } from './MapPageSelectionPane';
import { useMapData } from '../../hooks/useMapData'; import { useMapData } from '../../hooks/useMapData';
import { usePOIData } from '../../hooks/usePOIData'; import { usePOIData } from '../../hooks/usePOIData';
import { useFilters } from '../../hooks/useFilters'; import { useFilters } from '../../hooks/useFilters';
@ -30,7 +23,6 @@ import { useAiFilters } from '../../hooks/useAiFilters';
import { useUrlSync } from '../../hooks/useUrlSync'; import { useUrlSync } from '../../hooks/useUrlSync';
import { useTutorial } from '../../hooks/useTutorial'; import { useTutorial } from '../../hooks/useTutorial';
import { getTutorialStyles } from '../../lib/tutorial-styles'; import { getTutorialStyles } from '../../lib/tutorial-styles';
import Joyride from 'react-joyride';
import { import {
useTravelTime, useTravelTime,
useTranslatedModes, useTranslatedModes,
@ -44,11 +36,40 @@ import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE } from '../../lib/consts'; import { INITIAL_VIEW_STATE } from '../../lib/consts';
import { getSchoolBackendFeatureName } from '../../lib/school-filter'; import { getSchoolBackendFeatureName } from '../../lib/school-filter';
import { useLicense } from '../../hooks/useLicense'; import { useLicense } from '../../hooks/useLicense';
import UpgradeModal from '../ui/UpgradeModal';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon'; import { MapPinIcon } from '../ui/icons/MapPinIcon';
import { BookmarkIcon } from '../ui/icons/BookmarkIcon'; import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
const Map = lazy(() => import('./Map'));
const Filters = lazy(() => import('./Filters'));
const POIPane = lazy(() => import('./POIPane'));
const AreaPane = lazy(() => import('./AreaPane'));
const PropertiesPane = lazy(() =>
import('./PropertiesPane').then((module) => ({ default: module.PropertiesPane }))
);
const MobileDrawer = lazy(() => import('./MobileDrawer'));
const MapPageSelectionPane = lazy(() =>
import('./MapPageSelectionPane').then((module) => ({ default: module.MapPageSelectionPane }))
);
const UpgradeModal = lazy(() => import('../ui/UpgradeModal'));
const Joyride = lazy(() => import('react-joyride'));
function MapFallback() {
return (
<div className="flex h-full w-full items-center justify-center bg-warm-100 dark:bg-navy-950">
<SpinnerIcon className="h-8 w-8 animate-spin text-teal-600 dark:text-teal-400" />
</div>
);
}
function PaneFallback() {
return (
<div className="flex h-full w-full items-center justify-center bg-white dark:bg-navy-950">
<SpinnerIcon className="h-6 w-6 animate-spin text-teal-600 dark:text-teal-400" />
</div>
);
}
export interface ExportState { export interface ExportState {
onExport: () => void; onExport: () => void;
exporting: boolean; exporting: boolean;
@ -193,6 +214,7 @@ export default function MapPage({
features, features,
viewFeature, viewFeature,
activeFeature, activeFeature,
pinnedFeature,
travelTimeEntries: entries, travelTimeEntries: entries,
shareCode, shareCode,
}); });
@ -335,8 +357,19 @@ export default function MapPage({
const handleLocationSearchResult = useCallback( const handleLocationSearchResult = useCallback(
(result: SearchedLocation | null) => { (result: SearchedLocation | null) => {
if (result) { if (result) {
setCurrentLocation(null); if (result.markerLatitude != null && result.markerLongitude != null) {
handleLocationSearch(result.postcode, result.geometry, result.latitude, result.longitude); setCurrentLocation({ lat: result.markerLatitude, lng: result.markerLongitude });
} else {
setCurrentLocation(null);
}
handleLocationSearch(
result.postcode,
result.geometry,
result.latitude,
result.longitude,
result.openProperties,
result.focusAddress
);
if (isMobile) setMobileDrawerOpen(true); if (isMobile) setMobileDrawerOpen(true);
} else { } else {
setCurrentLocation(null); setCurrentLocation(null);
@ -604,121 +637,134 @@ export default function MapPage({
if (screenshotMode) { if (screenshotMode) {
return ( return (
<div className="h-full w-full"> <div className="h-full w-full">
<Map <Suspense fallback={<MapFallback />}>
data={mapData.data} <Map
postcodeData={mapData.postcodeData} data={mapData.data}
usePostcodeView={mapData.usePostcodeView} postcodeData={mapData.postcodeData}
pois={[]} usePostcodeView={mapData.usePostcodeView}
onViewChange={mapData.handleViewChange} pois={[]}
viewFeature={mapViewFeature} onViewChange={mapData.handleViewChange}
colorRange={mapData.colorRange} viewFeature={mapViewFeature}
filterRange={filterRange} colorRange={mapData.colorRange}
viewSource={viewSource} filterRange={filterRange}
onCancelPin={() => {}} viewSource={viewSource}
features={features} onCancelPin={() => {}}
selectedHexagonId={null} onResetPreviewScale={mapData.handleResetPreviewScale}
hoveredHexagonId={null} canResetPreviewScale={mapData.canResetPreviewScale}
onHexagonClick={() => {}} features={features}
onHexagonHover={() => {}} selectedHexagonId={null}
initialViewState={initialViewState} hoveredHexagonId={null}
theme={theme} onHexagonClick={() => {}}
screenshotMode onHexagonHover={() => {}}
ogMode={ogMode} initialViewState={initialViewState}
bounds={mapData.bounds} theme={theme}
travelTimeEntries={entries} screenshotMode
/> ogMode={ogMode}
bounds={mapData.bounds}
travelTimeEntries={entries}
/>
</Suspense>
</div> </div>
); );
} }
const renderAreaPane = () => ( const renderAreaPane = () => (
<AreaPane <Suspense fallback={<PaneFallback />}>
stats={areaStats} <AreaPane
globalFeatures={features} stats={areaStats}
loading={loadingAreaStats} globalFeatures={features}
hexagonId={selectedHexagon?.id || null} loading={loadingAreaStats}
isPostcode={selectedHexagon?.type === 'postcode'} hexagonId={selectedHexagon?.id || null}
postcodeData={ isPostcode={selectedHexagon?.type === 'postcode'}
selectedHexagon?.type === 'postcode' postcodeData={
? mapData.postcodeData.find((f) => f.properties.postcode === selectedHexagon?.id) || null selectedHexagon?.type === 'postcode'
: null ? mapData.postcodeData.find((f) => f.properties.postcode === selectedHexagon?.id) ||
} null
onViewProperties={handleViewPropertiesFromArea} : null
onClearFilters={hasActiveFilters ? handleClearAll : undefined} }
hexagonLocation={hexagonLocation} onViewProperties={handleViewPropertiesFromArea}
filters={filters} onClearFilters={hasActiveFilters ? handleClearAll : undefined}
unfilteredCount={unfilteredAreaCount} hexagonLocation={hexagonLocation}
travelTimeEntries={activeEntries} filters={filters}
isGroupExpanded={isAreaGroupExpanded} unfilteredCount={unfilteredAreaCount}
onToggleGroup={toggleAreaGroup} travelTimeEntries={activeEntries}
/> isGroupExpanded={isAreaGroupExpanded}
onToggleGroup={toggleAreaGroup}
/>
</Suspense>
); );
const renderPropertiesPane = () => ( const renderPropertiesPane = () => (
<PropertiesPane <Suspense fallback={<PaneFallback />}>
properties={properties} <PropertiesPane
total={propertiesTotal} properties={properties}
loading={loadingProperties} total={propertiesTotal}
hexagonId={selectedHexagon?.id || null} loading={loadingProperties}
onLoadMore={handleLoadMoreProperties} hexagonId={selectedHexagon?.id || null}
onSaveProperty={onSaveProperty ? handleSavePropertyWithToast : undefined} onLoadMore={handleLoadMoreProperties}
onUnsaveProperty={onUnsaveProperty} onSaveProperty={onSaveProperty ? handleSavePropertyWithToast : undefined}
isPropertySaved={isPropertySaved} onUnsaveProperty={onUnsaveProperty}
getSavedPropertyId={getSavedPropertyId} isPropertySaved={isPropertySaved}
/> getSavedPropertyId={getSavedPropertyId}
/>
</Suspense>
); );
const renderPOIPane = () => ( const renderPOIPane = () => (
<POIPane <Suspense fallback={<PaneFallback />}>
groups={poiCategoryGroups} <POIPane
selectedCategories={selectedPOICategories} groups={poiCategoryGroups}
onCategoriesChange={setSelectedPOICategories} selectedCategories={selectedPOICategories}
poiCount={pois.length} onCategoriesChange={setSelectedPOICategories}
onClose={() => setPoiPaneOpen(false)} poiCount={pois.length}
/> onClose={() => setPoiPaneOpen(false)}
/>
</Suspense>
); );
const renderFilters = (options?: { destinationDropdownPortal?: boolean }) => ( const renderFilters = (options?: { destinationDropdownPortal?: boolean }) => (
<Filters <Suspense fallback={<PaneFallback />}>
features={features} <Filters
filters={filters} features={features}
activeFeature={activeFeature} filters={filters}
dragValue={dragValue} activeFeature={activeFeature}
enabledFeatures={enabledFeatures} dragValue={dragValue}
onAddFilter={handleAddFilter} enabledFeatures={enabledFeatures}
onRemoveFilter={handleRemoveFilter} onAddFilter={handleAddFilter}
onFilterChange={handleFilterChange} onRemoveFilter={handleRemoveFilter}
onDragStart={handleDragStart} onFilterChange={handleFilterChange}
onDragChange={handleDragChange} onDragStart={handleDragStart}
onDragEnd={handleDragEnd} onDragChange={handleDragChange}
pinnedFeature={pinnedFeature} onDragEnd={handleDragEnd}
onTogglePin={handleTogglePin} pinnedFeature={pinnedFeature}
openInfoFeature={pendingInfoFeature} onTogglePin={handleTogglePin}
onClearOpenInfoFeature={onClearPendingInfoFeature} openInfoFeature={pendingInfoFeature}
travelTimeEntries={entries} onClearOpenInfoFeature={onClearPendingInfoFeature}
onTravelTimeAddEntry={handleAddEntry} travelTimeEntries={entries}
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry} onTravelTimeAddEntry={handleAddEntry}
onTravelTimeSetDestination={handleTravelTimeSetDestination} onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
onTravelTimeRangeChange={handleTimeRangeChange} onTravelTimeSetDestination={handleTravelTimeSetDestination}
onTravelTimeDragEnd={handleTravelTimeDragEnd} onTravelTimeRangeChange={handleTimeRangeChange}
onTravelTimeToggleBest={handleToggleBest} onTravelTimeDragEnd={handleTravelTimeDragEnd}
aiFilterLoading={aiFilterLoading} onTravelTimeToggleBest={handleToggleBest}
aiFilterError={aiFilterError} aiFilterLoading={aiFilterLoading}
aiFilterErrorType={aiFilterErrorType} aiFilterError={aiFilterError}
aiFilterNotes={aiFilterNotes} aiFilterErrorType={aiFilterErrorType}
aiFilterSummary={aiFilterSummary} aiFilterNotes={aiFilterNotes}
onAiFilterSubmit={handleAiFilterSubmit} aiFilterSummary={aiFilterSummary}
isLoggedIn={!!user} onAiFilterSubmit={handleAiFilterSubmit}
onLoginRequired={onRegisterClick ?? (() => {})} isLoggedIn={!!user}
isLicensed={user?.subscription === 'licensed'} onLoginRequired={onRegisterClick ?? (() => {})}
onUpgradeClick={() => onNavigateTo('pricing')} isLicensed={user?.subscription === 'licensed'}
onResetTutorial={tutorial.resetTutorial} onUpgradeClick={() => onNavigateTo('pricing')}
filterImpacts={filterCounts.impacts} onResetTutorial={tutorial.resetTutorial}
onClearAll={handleClearAll} filterImpacts={filterCounts.impacts}
onSaveSearch={onSaveSearch} onClearAll={handleClearAll}
savingSearch={savingSearch} onSaveSearch={onSaveSearch}
destinationDropdownPortal={options?.destinationDropdownPortal} savingSearch={savingSearch}
/> destinationDropdownPortal={options?.destinationDropdownPortal}
/>
</Suspense>
); );
const renderMobileLegend = () => { const renderMobileLegend = () => {
@ -734,6 +780,8 @@ export default function MapPage({
range={mapData.colorRange} range={mapData.colorRange}
showCancel={viewSource === 'eye'} showCancel={viewSource === 'eye'}
onCancel={handleCancelPin} onCancel={handleCancelPin}
onResetScale={viewSource === 'eye' ? mapData.handleResetPreviewScale : undefined}
resetScaleDisabled={!mapData.canResetPreviewScale}
mode="feature" mode="feature"
theme={theme} theme={theme}
inline inline
@ -753,6 +801,8 @@ export default function MapPage({
range={mapData.colorRange} range={mapData.colorRange}
showCancel={viewSource === 'eye'} showCancel={viewSource === 'eye'}
onCancel={handleCancelPin} onCancel={handleCancelPin}
onResetScale={viewSource === 'eye' ? mapData.handleResetPreviewScale : undefined}
resetScaleDisabled={!mapData.canResetPreviewScale}
mode="feature" mode="feature"
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined} enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
featureName={mobileLegendMeta.name} featureName={mobileLegendMeta.name}
@ -794,34 +844,38 @@ export default function MapPage({
)} )}
<div className="absolute inset-0"> <div className="absolute inset-0">
<Map <Suspense fallback={<MapFallback />}>
data={mapData.data} <Map
postcodeData={mapData.postcodeData} data={mapData.data}
usePostcodeView={mapData.usePostcodeView} postcodeData={mapData.postcodeData}
pois={pois} usePostcodeView={mapData.usePostcodeView}
onViewChange={mapData.handleViewChange} pois={pois}
viewFeature={mapViewFeature} onViewChange={mapData.handleViewChange}
colorRange={mapData.colorRange} viewFeature={mapViewFeature}
filterRange={filterRange} colorRange={mapData.colorRange}
viewSource={viewSource} filterRange={filterRange}
onCancelPin={handleCancelPin} viewSource={viewSource}
features={features} onCancelPin={handleCancelPin}
selectedHexagonId={selectedHexagon?.id || null} onResetPreviewScale={mapData.handleResetPreviewScale}
hoveredHexagonId={hoveredHexagon} canResetPreviewScale={mapData.canResetPreviewScale}
onHexagonClick={handleMobileHexagonClick} features={features}
onHexagonHover={handleHexagonHover} selectedHexagonId={selectedHexagon?.id || null}
initialViewState={initialViewState} hoveredHexagonId={hoveredHexagon}
flyToRef={mapFlyToRef} onHexagonClick={handleMobileHexagonClick}
theme={theme} onHexagonHover={handleHexagonHover}
filters={filters} initialViewState={initialViewState}
selectedPostcodeGeometry={selectedPostcodeGeometry} flyToRef={mapFlyToRef}
onLocationSearched={handleLocationSearchResult} theme={theme}
onCurrentLocationFound={handleCurrentLocationFound} filters={filters}
currentLocation={currentLocation} selectedPostcodeGeometry={selectedPostcodeGeometry}
bounds={mapData.bounds} onLocationSearched={handleLocationSearchResult}
hideLegend onCurrentLocationFound={handleCurrentLocationFound}
travelTimeEntries={entries} currentLocation={currentLocation}
/> bounds={mapData.bounds}
hideLegend
travelTimeEntries={entries}
/>
</Suspense>
</div> </div>
{mapData.loading && ( {mapData.loading && (
@ -849,40 +903,41 @@ export default function MapPage({
</div> </div>
)} )}
<MobileBottomSheet <MobileBottomSheet legend={renderMobileLegend()}>
activeCount={Object.keys(filters).length + entries.length}
legend={renderMobileLegend()}
>
{renderFilters({ destinationDropdownPortal: false })} {renderFilters({ destinationDropdownPortal: false })}
</MobileBottomSheet> </MobileBottomSheet>
{mobileDrawerOpen && selectedHexagon && ( {mobileDrawerOpen && selectedHexagon && (
<MobileDrawer <Suspense fallback={<PaneFallback />}>
onClose={() => setMobileDrawerOpen(false)} <MobileDrawer
renderArea={renderAreaPane} onClose={() => setMobileDrawerOpen(false)}
renderProperties={renderPropertiesPane} renderArea={renderAreaPane}
tab={rightPaneTab} renderProperties={renderPropertiesPane}
onTabChange={(t) => { tab={rightPaneTab}
if (t === 'properties') { onTabChange={(t) => {
handlePropertiesTabClick(); if (t === 'properties') {
} else { handlePropertiesTabClick();
setRightPaneTab(t); } else {
} setRightPaneTab(t);
}} }
/> }}
/>
</Suspense>
)} )}
{bookmarkToast} {bookmarkToast}
{mapData.licenseRequired && ( {mapData.licenseRequired && (
<UpgradeModal <Suspense fallback={null}>
isLoggedIn={!!user} <UpgradeModal
onLoginClick={onLoginClick ?? (() => {})} isLoggedIn={!!user}
onRegisterClick={onRegisterClick ?? (() => {})} onLoginClick={onLoginClick ?? (() => {})}
onStartCheckout={() => license.startCheckout()} onRegisterClick={onRegisterClick ?? (() => {})}
onZoomToFreeZone={handleZoomToFreeZone} onStartCheckout={() => license.startCheckout()}
isShareReturn={!!shareReturnViewRef.current} onZoomToFreeZone={handleZoomToFreeZone}
/> isShareReturn={!!shareReturnViewRef.current}
/>
</Suspense>
)} )}
</div> </div>
); );
@ -901,17 +956,21 @@ export default function MapPage({
</div> </div>
)} )}
<Joyride {tutorial.run && (
steps={tutorial.steps} <Suspense fallback={null}>
run={tutorial.run} <Joyride
continuous steps={tutorial.steps}
showProgress run={tutorial.run}
showSkipButton continuous
callback={tutorial.handleCallback} showProgress
styles={getTutorialStyles(theme)} showSkipButton
disableScrolling callback={tutorial.handleCallback}
locale={{ last: 'Finish' }} styles={getTutorialStyles(theme)}
/> disableScrolling
locale={{ last: 'Finish' }}
/>
</Suspense>
)}
<div <div
data-tutorial="filters" data-tutorial="filters"
@ -932,35 +991,39 @@ export default function MapPage({
</div> </div>
<div data-tutorial="map" className="flex-1 relative"> <div data-tutorial="map" className="flex-1 relative">
<Map <Suspense fallback={<MapFallback />}>
data={mapData.data} <Map
postcodeData={mapData.postcodeData} data={mapData.data}
usePostcodeView={mapData.usePostcodeView} postcodeData={mapData.postcodeData}
pois={pois} usePostcodeView={mapData.usePostcodeView}
onViewChange={mapData.handleViewChange} pois={pois}
viewFeature={mapViewFeature} onViewChange={mapData.handleViewChange}
colorRange={mapData.colorRange} viewFeature={mapViewFeature}
filterRange={filterRange} colorRange={mapData.colorRange}
viewSource={viewSource} filterRange={filterRange}
onCancelPin={handleCancelPin} viewSource={viewSource}
features={features} onCancelPin={handleCancelPin}
selectedHexagonId={selectedHexagon?.id || null} onResetPreviewScale={mapData.handleResetPreviewScale}
hoveredHexagonId={hoveredHexagon} canResetPreviewScale={mapData.canResetPreviewScale}
onHexagonClick={handleHexagonClick} features={features}
onHexagonHover={handleHexagonHover} selectedHexagonId={selectedHexagon?.id || null}
initialViewState={initialViewState} hoveredHexagonId={hoveredHexagon}
flyToRef={mapFlyToRef} onHexagonClick={handleHexagonClick}
theme={theme} onHexagonHover={handleHexagonHover}
filters={filters} initialViewState={initialViewState}
selectedPostcodeGeometry={selectedPostcodeGeometry} flyToRef={mapFlyToRef}
onLocationSearched={handleLocationSearchResult} theme={theme}
onCurrentLocationFound={handleCurrentLocationFound} filters={filters}
currentLocation={currentLocation} selectedPostcodeGeometry={selectedPostcodeGeometry}
bounds={mapData.bounds} onLocationSearched={handleLocationSearchResult}
travelTimeEntries={entries} onCurrentLocationFound={handleCurrentLocationFound}
densityLabel={densityLabel} currentLocation={currentLocation}
totalCount={hasActiveFilters ? filterCounts.total : undefined} bounds={mapData.bounds}
/> travelTimeEntries={entries}
densityLabel={densityLabel}
totalCount={hasActiveFilters ? filterCounts.total : undefined}
/>
</Suspense>
{mapData.loading && ( {mapData.loading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto"> <div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
@ -989,29 +1052,33 @@ export default function MapPage({
</div> </div>
{selectedHexagon && ( {selectedHexagon && (
<MapPageSelectionPane <Suspense fallback={<PaneFallback />}>
width={rightPaneWidth} <MapPageSelectionPane
resizeHandlers={rightPaneHandlers} width={rightPaneWidth}
tab={rightPaneTab} resizeHandlers={rightPaneHandlers}
onAreaTabClick={() => setRightPaneTab('area')} tab={rightPaneTab}
onPropertiesTabClick={handlePropertiesTabClick} onAreaTabClick={() => setRightPaneTab('area')}
onClose={handleCloseSelection} onPropertiesTabClick={handlePropertiesTabClick}
renderAreaPane={renderAreaPane} onClose={handleCloseSelection}
renderPropertiesPane={renderPropertiesPane} renderAreaPane={renderAreaPane}
/> renderPropertiesPane={renderPropertiesPane}
/>
</Suspense>
)} )}
{bookmarkToast} {bookmarkToast}
{mapData.licenseRequired && ( {mapData.licenseRequired && (
<UpgradeModal <Suspense fallback={null}>
isLoggedIn={!!user} <UpgradeModal
onLoginClick={onLoginClick ?? (() => {})} isLoggedIn={!!user}
onRegisterClick={onRegisterClick ?? (() => {})} onLoginClick={onLoginClick ?? (() => {})}
onStartCheckout={() => license.startCheckout()} onRegisterClick={onRegisterClick ?? (() => {})}
onZoomToFreeZone={handleZoomToFreeZone} onStartCheckout={() => license.startCheckout()}
isShareReturn={!!shareReturnViewRef.current} onZoomToFreeZone={handleZoomToFreeZone}
/> isShareReturn={!!shareReturnViewRef.current}
/>
</Suspense>
)} )}
</div> </div>
); );

View file

@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
interface VisualViewportState { interface VisualViewportState {
height: number; height: number;
@ -8,7 +7,6 @@ interface VisualViewportState {
} }
interface MobileBottomSheetProps { interface MobileBottomSheetProps {
activeCount: number;
children: ReactNode; children: ReactNode;
legend?: ReactNode; legend?: ReactNode;
} }
@ -57,11 +55,9 @@ function clamp(value: number, min: number, max: number): number {
} }
export default function MobileBottomSheet({ export default function MobileBottomSheet({
activeCount,
children, children,
legend, legend,
}: MobileBottomSheetProps) { }: MobileBottomSheetProps) {
const { t } = useTranslation();
const viewport = useVisualViewportState(); const viewport = useVisualViewportState();
const sheetRef = useRef<HTMLDivElement>(null); const sheetRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
@ -133,8 +129,6 @@ export default function MobileBottomSheet({
return () => sheet.removeEventListener('focusin', handleFocusIn); return () => sheet.removeEventListener('focusin', handleFocusIn);
}, [heightBounds.initial, heightBounds.max, viewport.height]); }, [heightBounds.initial, heightBounds.max, viewport.height]);
const sheetTitle = activeCount === 0 ? t('filters.chooseFilters') : t('filters.activeFilters');
return ( return (
<section <section
ref={sheetRef} ref={sheetRef}
@ -148,29 +142,16 @@ export default function MobileBottomSheet({
? undefined ? undefined
: 'height 140ms ease, bottom 180ms ease', : 'height 140ms ease, bottom 180ms ease',
}} }}
aria-label={sheetTitle}
> >
<div <div
className="shrink-0 touch-none px-4 pt-2 pb-1" className="shrink-0 touch-none px-4 py-2"
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp} onPointerCancel={handlePointerUp}
> >
<div className="w-full flex flex-col items-center gap-2" role="presentation"> <div className="w-full flex items-center justify-center" role="presentation">
<span className="h-1.5 w-12 rounded-full bg-warm-300 dark:bg-navy-600" /> <span className="h-1.5 w-12 rounded-full bg-warm-300 dark:bg-navy-600" />
<span className="w-full flex items-center justify-between">
<span className="flex items-center gap-2 min-w-0">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100 truncate">
{sheetTitle}
</span>
{activeCount > 0 && (
<span className="text-xs font-medium px-1.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
{activeCount}
</span>
)}
</span>
</span>
</div> </div>
</div> </div>

View file

@ -6,7 +6,7 @@ interface PriceHistoryChartProps {
points: PricePoint[]; points: PricePoint[];
} }
const PADDING = { top: 8, right: 8, bottom: 20, left: 42 }; const PADDING = { top: 8, right: 24, bottom: 20, left: 42 };
const HEIGHT = 120; const HEIGHT = 120;
const priceFmt = { prefix: '£' }; const priceFmt = { prefix: '£' };

View file

@ -147,7 +147,7 @@ export default function Header({
}`; }`;
return ( return (
<header className="h-12 bg-navy-900 text-white flex items-center px-4 shrink-0"> <header className="relative z-50 h-12 bg-navy-900 text-white flex items-center px-4 shrink-0">
{/* Left: Logo + nav */} {/* Left: Logo + nav */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<a <a

View file

@ -156,15 +156,16 @@ export default function MobileMenu({
</button> </button>
{/* Language selector */} {/* Language selector */}
<div className="flex gap-1 px-4"> <div className="flex max-w-full gap-1 overflow-x-auto overflow-y-hidden px-4 pb-1 scrollbar-hide">
{SUPPORTED_LANGUAGES.map((lang) => ( {SUPPORTED_LANGUAGES.map((lang) => (
<button <button
key={lang.code} key={lang.code}
aria-label={lang.label}
onClick={() => { onClick={() => {
localStorage.setItem('language', lang.code); localStorage.setItem('language', lang.code);
void changeAppLanguage(lang.code); void changeAppLanguage(lang.code);
}} }}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded text-sm ${ className={`flex-none min-w-[2.75rem] flex items-center justify-center gap-1.5 px-2 py-2 rounded text-sm ${
i18n.language === lang.code i18n.language === lang.code
? 'bg-navy-700 text-white font-medium' ? 'bg-navy-700 text-white font-medium'
: 'text-warm-400 hover:bg-navy-800 hover:text-white' : 'text-warm-400 hover:bg-navy-800 hover:text-white'

View file

@ -5,6 +5,7 @@ import type { SearchResult } from '../../hooks/useLocationSearch';
import { useDropdownPosition } from '../../hooks/useDropdownPosition'; import { useDropdownPosition } from '../../hooks/useDropdownPosition';
import { SearchIcon } from './icons/SearchIcon'; import { SearchIcon } from './icons/SearchIcon';
import { MapPinIcon } from './icons/MapPinIcon'; import { MapPinIcon } from './icons/MapPinIcon';
import { HouseIcon } from './icons/HouseIcon';
interface SearchHook { interface SearchHook {
query: string; query: string;
@ -66,7 +67,11 @@ export function PlaceSearchInput({
{search.results.map((result, idx) => ( {search.results.map((result, idx) => (
<button <button
key={ key={
result.type === 'postcode' ? `pc-${result.label}` : `pl-${result.name}-${result.lat}` result.type === 'postcode'
? `pc-${result.label}`
: result.type === 'address'
? `addr-${result.postcode}-${result.address}-${result.lat}`
: `pl-${result.name}-${result.lat}`
} }
type="button" type="button"
className={`w-full text-left flex items-center cursor-pointer ${ className={`w-full text-left flex items-center cursor-pointer ${
@ -87,6 +92,16 @@ export function PlaceSearchInput({
<SearchIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} /> <SearchIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
<span className="text-warm-700 dark:text-warm-200">{result.label}</span> <span className="text-warm-700 dark:text-warm-200">{result.label}</span>
</> </>
) : result.type === 'address' ? (
<>
<HouseIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
<span className="min-w-0 text-warm-700 dark:text-warm-200">
<span className="block truncate">{result.address}</span>
<span className="block truncate text-warm-400 dark:text-warm-500">
{result.postcode}
</span>
</span>
</>
) : ( ) : (
<> <>
<MapPinIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} /> <MapPinIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />

View file

@ -84,7 +84,9 @@ export default function UpgradeModal({
{priceLabel} {priceLabel}
</span> </span>
{!isFree && ( {!isFree && (
<span className="text-warm-500 dark:text-warm-400 text-lg">{t('upgrade.once')}</span> <span className="text-warm-500 dark:text-warm-400 text-lg">
{t('pricingPage.lifetime')}
</span>
)} )}
</div> </div>
<p className="text-center text-sm text-warm-500 dark:text-warm-400 mb-6"> <p className="text-center text-sm text-warm-500 dark:text-warm-400 mb-6">

View file

@ -178,7 +178,7 @@ export function useHexagonSelection({
); );
const fetchPostcodeProperties = useCallback( const fetchPostcodeProperties = useCallback(
async (postcode: string, offset = 0) => { async (postcode: string, offset = 0, focusAddress?: string) => {
setLoadingProperties(true); setLoadingProperties(true);
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
@ -186,6 +186,9 @@ export function useHexagonSelection({
limit: '100', limit: '100',
offset: offset.toString(), offset: offset.toString(),
}); });
if (focusAddress && offset === 0) {
params.set('focus_address', focusAddress);
}
const filterStr = buildFilterString(filters, features); const filterStr = buildFilterString(filters, features);
if (filterStr) params.append('filters', filterStr); if (filterStr) params.append('filters', filterStr);
@ -464,13 +467,20 @@ export function useHexagonSelection({
]); ]);
const handleLocationSearch = useCallback( const handleLocationSearch = useCallback(
(postcode: string, geometry: PostcodeGeometry, lat?: number, lng?: number) => { (
trackEvent('Postcode Search'); postcode: string,
geometry: PostcodeGeometry,
lat?: number,
lng?: number,
openProperties = false,
focusAddress?: string
) => {
trackEvent(openProperties ? 'Address Search' : 'Postcode Search');
setProperties([]); setProperties([]);
setPropertiesTotal(0); setPropertiesTotal(0);
setPropertiesOffset(0); setPropertiesOffset(0);
setUnfilteredAreaCount(null); setUnfilteredAreaCount(null);
setRightPaneTab('area'); setRightPaneTab(openProperties ? 'properties' : 'area');
setLoadingAreaStats(true); setLoadingAreaStats(true);
// First try the postcode; if it has no properties, fall back to hexagons // First try the postcode; if it has no properties, fall back to hexagons
@ -482,6 +492,9 @@ export function useHexagonSelection({
setSelectedPostcodeGeometry(geometry); setSelectedPostcodeGeometry(geometry);
setAreaStats(stats); setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count); refreshUnfilteredAreaCount(selection, stats.count);
if (openProperties) {
fetchPostcodeProperties(postcode, 0, focusAddress);
}
return; return;
} }
@ -493,6 +506,7 @@ export function useHexagonSelection({
setSelectedPostcodeGeometry(geometry); setSelectedPostcodeGeometry(geometry);
setAreaStats(stats); setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count); refreshUnfilteredAreaCount(selection, stats.count);
setRightPaneTab('area');
return; return;
} }
@ -507,6 +521,7 @@ export function useHexagonSelection({
setSelectedPostcodeGeometry(null); setSelectedPostcodeGeometry(null);
setAreaStats(hexStats); setAreaStats(hexStats);
refreshUnfilteredAreaCount(selection, hexStats.count); refreshUnfilteredAreaCount(selection, hexStats.count);
setRightPaneTab('area');
return; return;
} }
} }
@ -519,11 +534,18 @@ export function useHexagonSelection({
setSelectedPostcodeGeometry(null); setSelectedPostcodeGeometry(null);
setAreaStats(fallbackStats); setAreaStats(fallbackStats);
refreshUnfilteredAreaCount(selection, fallbackStats.count); refreshUnfilteredAreaCount(selection, fallbackStats.count);
setRightPaneTab('area');
}) })
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error)) .catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
.finally(() => setLoadingAreaStats(false)); .finally(() => setLoadingAreaStats(false));
}, },
[resolution, fetchPostcodeStats, fetchHexagonStats, refreshUnfilteredAreaCount] [
resolution,
fetchPostcodeStats,
fetchHexagonStats,
fetchPostcodeProperties,
refreshUnfilteredAreaCount,
]
); );
const handleCurrentLocationSearch = useCallback( const handleCurrentLocationSearch = useCallback(

View file

@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react'; import { useState, useCallback, useRef, useEffect } from 'react';
import type { PlaceResult } from '../types'; import type { AddressResult, PlaceResult } from '../types';
import { authHeaders, logNonAbortError } from '../lib/api'; import { authHeaders, logNonAbortError } from '../lib/api';
/** Matches a full UK postcode with complete inward code (e.g. "E14 2DG", "SW1A1AA"). /** Matches a full UK postcode with complete inward code (e.g. "E14 2DG", "SW1A1AA").
@ -19,8 +19,54 @@ function normalizePostcode(s: string): string {
return stripped; return stripped;
} }
function searchableTextForResult(result: SearchResult): string {
if (result.type === 'postcode') {
return result.label;
}
if (result.type === 'address') {
return `${result.address} ${result.postcode}`;
}
return `${result.name} ${result.city ?? ''}`;
}
function searchTokens(value: string): string[] {
return value.toLowerCase().match(/[a-z0-9]+/g) ?? [];
}
function compactSearchText(value: string): string {
return searchTokens(value).join('');
}
function resultMatchesQuery(result: SearchResult, query: string): boolean {
const queryTokens = searchTokens(query);
if (queryTokens.length === 0) {
return false;
}
const searchable = searchableTextForResult(result);
const resultText = searchTokens(searchable).join(' ');
const resultCompact = compactSearchText(searchable);
const queryCompact = queryTokens.join('');
return (
queryTokens.every((token) => resultText.includes(token)) ||
(queryCompact.length >= 2 && resultCompact.includes(queryCompact))
);
}
function filterResultsForQuery(results: SearchResult[], query: string): SearchResult[] {
return results.filter((result) => resultMatchesQuery(result, query)).slice(0, 20);
}
export type SearchResult = export type SearchResult =
| { type: 'postcode'; label: string } | { type: 'postcode'; label: string }
| {
type: 'address';
address: string;
postcode: string;
lat: number;
lon: number;
}
| { | {
type: 'place'; type: 'place';
name: string; name: string;
@ -38,10 +84,13 @@ export function useLocationSearch(mode?: string) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(); const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const latestQueryRef = useRef('');
const lastResultsRef = useRef<SearchResult[]>([]);
const handleInputChange = useCallback( const handleInputChange = useCallback(
(value: string) => { (value: string) => {
setQuery(value); setQuery(value);
latestQueryRef.current = value;
setActiveIndex(-1); setActiveIndex(-1);
abortRef.current?.abort(); abortRef.current?.abort();
@ -50,12 +99,16 @@ export function useLocationSearch(mode?: string) {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) { if (!trimmed) {
setResults([]); setResults([]);
lastResultsRef.current = [];
setOpen(false); setOpen(false);
return; return;
} }
if (!mode && looksLikePostcode(trimmed)) { if (!mode && looksLikePostcode(trimmed)) {
setResults([{ type: 'postcode', label: normalizePostcode(trimmed) }]); const postcodeResults: SearchResult[] = [
{ type: 'postcode', label: normalizePostcode(trimmed) },
];
setResults(postcodeResults);
setOpen(true); setOpen(true);
return; return;
} }
@ -66,19 +119,27 @@ export function useLocationSearch(mode?: string) {
return; return;
} }
const locallyFilteredResults = filterResultsForQuery(lastResultsRef.current, trimmed);
setResults(locallyFilteredResults);
setOpen(locallyFilteredResults.length > 0);
debounceRef.current = setTimeout(async () => { debounceRef.current = setTimeout(async () => {
const controller = new AbortController(); const controller = new AbortController();
abortRef.current = controller; abortRef.current = controller;
try { try {
const params = new URLSearchParams({ q: trimmed, limit: '7' }); const params = new URLSearchParams({ q: trimmed, limit: '20' });
if (mode) params.set('mode', mode); if (mode) params.set('mode', mode);
const res = await fetch( const res = await fetch(
`/api/places?${params}`, `/api/places?${params}`,
authHeaders({ signal: controller.signal }) authHeaders({ signal: controller.signal })
); );
if (!res.ok) return; if (!res.ok) return;
const json: { places: PlaceResult[] } = await res.json(); const json: {
const placeResults: SearchResult[] = json.places.map((p) => ({ places: PlaceResult[];
postcodes?: string[];
addresses?: AddressResult[];
} = await res.json();
const placeResults = json.places.map((p) => ({
type: 'place' as const, type: 'place' as const,
name: p.name, name: p.name,
slug: p.slug, slug: p.slug,
@ -87,8 +148,34 @@ export function useLocationSearch(mode?: string) {
lon: p.lon, lon: p.lon,
city: p.city === 'City of London' ? 'London' : p.city, city: p.city === 'City of London' ? 'London' : p.city,
})); }));
setResults(placeResults); const outcodeResults = placeResults.filter((result) => result.place_type === 'outcode');
setOpen(placeResults.length > 0); const otherPlaceResults = placeResults.filter(
(result) => result.place_type !== 'outcode'
);
const postcodeResults: SearchResult[] = (json.postcodes ?? []).map((postcode) => ({
type: 'postcode' as const,
label: postcode,
}));
const addressResults: SearchResult[] = (json.addresses ?? []).map((address) => ({
type: 'address' as const,
address: address.address,
postcode: address.postcode,
lat: address.lat,
lon: address.lon,
}));
const containsHouseNumber = /\d/.test(trimmed);
const combinedResults = (
containsHouseNumber
? [...outcodeResults, ...postcodeResults, ...addressResults, ...otherPlaceResults]
: [...outcodeResults, ...postcodeResults, ...otherPlaceResults, ...addressResults]
).slice(0, 20);
if (controller.signal.aborted || latestQueryRef.current.trim() !== trimmed) {
return;
}
lastResultsRef.current = combinedResults;
const matchingResults = filterResultsForQuery(combinedResults, trimmed);
setResults(matchingResults);
setOpen(matchingResults.length > 0);
} catch (err) { } catch (err) {
logNonAbortError('places search', err); logNonAbortError('places search', err);
} }
@ -101,7 +188,9 @@ export function useLocationSearch(mode?: string) {
const clear = useCallback(() => { const clear = useCallback(() => {
setQuery(''); setQuery('');
latestQueryRef.current = '';
setResults([]); setResults([]);
lastResultsRef.current = [];
setOpen(false); setOpen(false);
setActiveIndex(-1); setActiveIndex(-1);
}, []); }, []);

View file

@ -134,7 +134,7 @@ export const details: Record<string, Record<string, string>> = {
'Distance to nearest park (km)': 'Distance to nearest park (km)':
"Distance à vol d'oiseau en kilomètres depuis le code postal jusqu'à l'entrée du parc la plus proche. Couvre les parcs publics, jardins, terrains de jeux et espaces de loisirs. Utilise les emplacements des points d'accès issus du jeu de données OS Open Greenspace, de sorte que les propriétés bordant un grand parc affichent correctement une courte distance.", "Distance à vol d'oiseau en kilomètres depuis le code postal jusqu'à l'entrée du parc la plus proche. Couvre les parcs publics, jardins, terrains de jeux et espaces de loisirs. Utilise les emplacements des points d'accès issus du jeu de données OS Open Greenspace, de sorte que les propriétés bordant un grand parc affichent correctement une courte distance.",
'Number of parks within 1km': 'Number of parks within 1km':
'Nombre de parcs publics, jardins, terrains de jeux et espaces de loisirs dont au moins une entrée se trouve dans un rayon de 1km du centroïde du code postal de la propri<72><69>té. Dérivé du jeu de données OS Open Greenspace (Ordnance Survey), utilisant les emplacements des entrées de parcs pour une correspondance de proximité précise.', 'Nombre de parcs publics, jardins, terrains de jeux et espaces de loisirs dont au moins une entrée se trouve dans un rayon de 1 km du centroïde du code postal de la propriété. Dérivé du jeu de données OS Open Greenspace (Ordnance Survey), utilisant les emplacements des entrées de parcs pour une correspondance de proximité précise.',
'Number of restaurants within 2km': 'Number of restaurants within 2km':
'Restaurants, cafés et établissements de restauration dans un rayon de 2km du code postal. Source : OpenStreetMap.', 'Restaurants, cafés et établissements de restauration dans un rayon de 2km du code postal. Source : OpenStreetMap.',
'Number of grocery shops and supermarkets within 2km': 'Number of grocery shops and supermarkets within 2km':
@ -392,7 +392,7 @@ export const details: Record<string, Record<string, string>> = {
'% Black': '来自2021年Census。地方政府人口中认同为黑人、英国黑人、加勒比人或非洲人的百分比。', '% Black': '来自2021年Census。地方政府人口中认同为黑人、英国黑人、加勒比人或非洲人的百分比。',
'% East Asian': '来自2021年Census。地方政府人口中认同为华人的百分比。', '% East Asian': '来自2021年Census。地方政府人口中认同为华人的百分比。',
'% Mixed': '% Mixed':
'来自2021年Census。地方政府人口中认同为<EFBFBD><EFBFBD>血或多种族群体(白人与黑人加勒比裔、白人与黑人非洲裔、白人与亚洲裔,或其他混血或多种族背景)的百分比。', '来自2021年 Census。地方政府人口中认同为混血或多种族群体(白人与黑人加勒比裔、白人与黑人非洲裔、白人与亚洲裔,或其他混血或多种族背景)的百分比。',
'% Other': '% Other':
'来自2021年Census。地方政府人口中认同为其他族裔群体阿拉伯人或其他未被主要类别涵盖的族裔的百分比。', '来自2021年Census。地方政府人口中认同为其他族裔群体阿拉伯人或其他未被主要类别涵盖的族裔的百分比。',
'Voter turnout (%)': 'Voter turnout (%)':
@ -406,7 +406,7 @@ export const details: Record<string, Record<string, string>> = {
'% Other parties': '% Other parties':
'该选区中投给工党、保守党、自由民主党、英国改革党和绿党以外政党的有效选票百分比。包括独立候选人、议长和小型政党。', '该选区中投给工党、保守党、自由民主党、英国改革党和绿党以外政党的有效选票百分比。包括独立候选人、议长和小型政党。',
'Distance to nearest park (km)': 'Distance to nearest park (km)':
'从邮政编码到最近公园入口的直线距离km。涵盖公共公园、花园、运动<EFBFBD><EFBFBD><EFBFBD>和游乐场地。使用OS Open Greenspace数据集中的出入口位置,因此紧邻大型公园的房产可正确显示较短距离。', '从邮政编码到最近公园入口的直线距离km。涵盖公共公园、花园、运动场和游乐场地。使用 OS Open Greenspace 数据集中的出入口位置,因此紧邻大型公园的房产可正确显示较短距离。',
'Number of parks within 1km': 'Number of parks within 1km':
'以房产邮政编码中心点为圆心1km半径内至少有一个入口的公共公园、花园、运动场和游乐场地数量。来源于OS Open Greenspace数据集英国地形测量局使用公园入口位置进行精确近距离匹配。', '以房产邮政编码中心点为圆心1km半径内至少有一个入口的公共公园、花园、运动场和游乐场地数量。来源于OS Open Greenspace数据集英国地形测量局使用公园入口位置进行精确近距离匹配。',
'Number of restaurants within 2km': 'Number of restaurants within 2km':
@ -418,6 +418,146 @@ export const details: Record<string, Record<string, string>> = {
'Max available download speed (Mbps)': 'Max available download speed (Mbps)':
'来自Ofcom Connected Nations 2025的任意运营商可提供的最大固定宽带下载速度。代表理论最大值而非实际达到的速度。10 Mbps为基础级30为超快级100+为极速级1000为千兆级。', '来自Ofcom Connected Nations 2025的任意运营商可提供的最大固定宽带下载速度。代表理论最大值而非实际达到的速度。10 Mbps为基础级30为超快级100+为极速级1000为千兆级。',
}, },
hi: {
'Property type':
'HM Land Registry Price Paid डेटा और EPC प्रमाणपत्रों से लिया गया. अलग मकान, अर्ध-स्वतंत्र मकान, कतारबद्ध मकान (सभी उप-प्रकार सहित), फ्लैट/मेज़ोनेट या अन्य (बंगले, मोबाइल होम आदि).',
'Leasehold/Freehold':
'HM Land Registry Price Paid डेटा से लिया गया. फ्रीहोल्ड का मतलब है कि भवन और जिस जमीन पर वह है, दोनों आपके हैं. लीजहोल्ड का मतलब है कि भवन आपका है लेकिन जमीन नहीं: फ्रीहोल्डर से निश्चित वर्षों के लिए लीज मिला होता है.',
'Last known price':
'इस संपत्ति की अंतिम दर्ज बिक्री कीमत HM Land Registry Price Paid डेटा से आती है. यह England की आवासीय बिक्री को कवर करती है. अगर संपत्ति हाल में नहीं बिकी है तो यह कीमत कई साल पुरानी हो सकती है.',
'Estimated current price':
'अंतिम बिक्री कीमत से शुरू करके स्थानीय कीमत बदलावों के अनुसार repeat-sales index से समायोजित किया गया है (postcode sector और property type के अनुसार ट्रैक किया गया). अगर EPC रिकॉर्ड से बिक्री के बाद नवीनीकरण दिखता है, तो नवीनीकरण प्रीमियम जोड़ा जाता है. हाल की बिक्री मूल कीमत के करीब रहेगी; पुरानी बिक्री में ज्यादा समायोजन होगा.',
'Price per sqm':
'अंतिम ज्ञात बिक्री कीमत को EPC प्रमाणपत्र में दर्ज कुल फर्श क्षेत्र से भाग देकर निकाला गया. अलग-अलग आकार की संपत्तियों की मूल्य तुलना के लिए उपयोगी. केवल तब उपलब्ध जब कीमत और फर्श क्षेत्र, दोनों डेटा मौजूद हों.',
'Est. price per sqm':
'मुद्रास्फीति-समायोजित अनुमानित मौजूदा कीमत (किसी नवीनीकरण प्रीमियम सहित) को EPC प्रमाणपत्र में दर्ज कुल फर्श क्षेत्र से भाग देकर निकाला गया. ऐतिहासिक बिक्री कीमत पर आधारित प्रति वर्ग मी कीमत की तुलना में ज्यादा ताजा कीमत/क्षेत्र तुलना देता है.',
'Estimated monthly rent':
'ONS Price Index of Private Rents (PIPR) से औसत मासिक किराया, जिसे स्थानीय प्राधिकरण और बेडरूम संख्या से मिलाया गया है.',
'Total floor area (sqm)':
'Energy Performance Certificate (EPC) आकलन के दौरान मापा गया कुल उपयोगी फर्श क्षेत्र, वर्ग मीटर में. सभी रहने योग्य कमरे शामिल, लेकिन गैरेज, बाहरी इमारतें और बाहरी क्षेत्र शामिल नहीं.',
'Number of bedrooms & living rooms':
'Energy Performance Certificate (EPC) में दर्ज कुल रहने योग्य कमरों की संख्या (बेडरूम और लिविंग रूम). रसोई और बाथरूम आमतौर पर शामिल नहीं होते, जब तक वे रहने योग्य कमरा माने जाने लायक बड़े न हों.',
'Construction year':
"EPC में दर्ज निर्माण आयु-श्रेणी (जैसे '1930-1949') से मध्य बिंदु लेकर अनुमानित. पुराने भवनों के लिए कम सटीक हो सकता है, जहां आयु-श्रेणी कई दशकों तक फैली होती है.",
'Date of last transaction':
'इस संपत्ति की सबसे हाल की दर्ज बिक्री तारीख, HM Land Registry Price Paid डेटा से. डेटा में तारीख/समय के रूप में रखी होती है; फिल्टर और चार्ट के लिए fractional year में बदली जाती है.',
'Former council house':
'Energy Performance Certificate डेटा के TENURE field से निकाला गया. अगर इस संपत्ति के किसी EPC प्रमाणपत्र ने टेन्योर को social rented के रूप में दर्ज किया, तो यह संकेत देता है कि उस निरीक्षण के समय संपत्ति council या housing association stock में थी. बाद में बेची गई संपत्तियां (जैसे Right to Buy के जरिए) यह संकेतक बनाए रखती हैं.',
'Current energy rating':
'Energy Performance Certificate (EPC) से current energy efficiency rating. A (सबसे efficient) से G (सबसे कम efficient) तक. Property की floor area प्रति energy use पर आधारित.',
'Potential energy rating':
'Energy Performance Certificate (EPC) से potential energy efficiency rating, अगर EPC report की सभी cost-effective recommended improvements कर दी जाएं. A (सबसे efficient) से G (सबसे कम efficient) तक.',
'Interior height (m)':
'Energy Performance Certificate (EPC) assessment के दौरान दर्ज average internal floor-to-ceiling height, metres में. Total internal volume को total floor area से divide करके निकाली जाती है.',
'Distance to nearest train or tube station (km)':
'Postcode से निकटतम railway station या tube/metro/tram stop तक सीधी रेखा में दूरी, kilometres में.',
'Good+ primary schools within 2km':
'2km के भीतर state-funded primary schools जिनकी current Ofsted rating Good या Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'Good+ secondary schools within 2km':
'2km के भीतर state-funded secondary schools जिनकी current Ofsted rating Good या Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'Good+ primary schools within 5km':
'5km के भीतर state-funded primary schools जिनकी current Ofsted rating Good या Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'Good+ secondary schools within 5km':
'5km के भीतर state-funded secondary schools जिनकी current Ofsted rating Good या Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'Outstanding primary schools within 2km':
'2km के भीतर state-funded primary schools जिनकी current Ofsted rating Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'Outstanding secondary schools within 2km':
'2km के भीतर state-funded secondary schools जिनकी current Ofsted rating Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'Outstanding primary schools within 5km':
'5km के भीतर state-funded primary schools जिनकी current Ofsted rating Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'Outstanding secondary schools within 5km':
'5km के भीतर state-funded secondary schools जिनकी current Ofsted rating Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'Education, Skills and Training Score':
'English Indices of Deprivation से लिया गया (invert किया गया ताकि higher = better). School attainment, higher education entry, adult qualifications और English language proficiency को cover करता है. Higher scores कम deprivation दिखाते हैं.',
'Income Score (rate)':
'English Indices of Deprivation से लिया गया (invert किया गया ताकि higher = better). Higher values कम income deprivation दिखाते हैं. Income support, income-based Jobseekers Allowance, income-based Employment and Support Allowance, Pension Credit, Working and Child Tax Credit, Universal Credit और asylum seekers पर आधारित.',
'Employment Score (rate)':
'English Indices of Deprivation से लिया गया (invert किया गया ताकि higher = better). Higher values कम employment deprivation दिखाते हैं. Jobseekers Allowance, Employment and Support Allowance, Incapacity Benefit, Severe Disablement Allowance, Carers Allowance और relevant Universal Credit claimants पर आधारित.',
'Health Deprivation and Disability Score':
'English Indices of Deprivation से लिया गया (invert किया गया ताकि higher = better). Higher scores premature death का कम risk और बेहतर quality of life दिखाते हैं. Years of potential life lost, comparative illness and disability ratio, acute morbidity और mood/anxiety disorders से derived.',
'Living Environment Score':
'English Indices of Deprivation से लिया गया (invert किया गया ताकि higher = better). Housing quality (condition, central heating) और outdoor environment (air quality, road safety) को combine करता है. Higher scores बेहतर living environments दिखाते हैं.',
'Indoors Sub-domain Score':
'English Indices of Deprivation, Living Environment domain से लिया गया (invert किया गया ताकि higher = better). Housing stock की quality मापता है: central heating availability, housing condition और Decent Homes standards. Higher scores बेहतर housing conditions दिखाते हैं.',
'Outdoors Sub-domain Score':
'English Indices of Deprivation, Living Environment domain से लिया गया (invert किया गया ताकि higher = better). Air quality indicators और pedestrians/cyclists से जुड़े road traffic accident casualties के जरिए outdoor living environment quality मापता है. Higher scores बेहतर outdoor environments दिखाते हैं.',
'Serious crime per 1k residents (avg/yr)':
'LSOA में प्रति 1,000 usual residents प्रति वर्ष violence, robbery, burglary और possession of weapons. police.uk street-level crime data (2023-2025) और Census 2021 population counts का उपयोग करता है. Population density normalize करता है ताकि areas size की परवाह किए बिना comparable हों.',
'Minor crime per 1k residents (avg/yr)':
'LSOA में प्रति 1,000 usual residents प्रति वर्ष anti-social behaviour, shoplifting, bicycle theft और other lower-severity crimes. police.uk street-level crime data (2023-2025) और Census 2021 population counts का उपयोग करता है. Population density normalize करता है ताकि areas comparable हों.',
'Serious crime (avg/yr)':
'LSOA में प्रति वर्ष violence, robbery, burglary और possession of weapons का sum, police.uk street-level crime data (2023-2025) से. Serious crime का एक single indicator देता है.',
'Minor crime (avg/yr)':
'LSOA में प्रति वर्ष anti-social behaviour, shoplifting, bicycle theft और other lower-severity crimes का sum, police.uk street-level crime data (2023-2025) से. Minor crime का एक single indicator देता है.',
'Violence and sexual offences (avg/yr)':
'LSOA में प्रति वर्ष violence और sexual offences की average count, police.uk street-level crime data (2023-2025) से. Assaults, harassment और sexual offences शामिल.',
'Burglary (avg/yr)':
'LSOA में प्रति वर्ष burglaries की average count, police.uk street-level crime data (2023-2025) से. Residential और commercial burglaries शामिल.',
'Robbery (avg/yr)':
'LSOA में प्रति वर्ष robberies की average count, police.uk street-level crime data (2023-2025) से. Robbery में force या threat of force के साथ theft शामिल है.',
'Vehicle crime (avg/yr)':
'LSOA में प्रति वर्ष vehicle-related crime incidents की average count, police.uk street-level crime data (2023-2025) से. Vehicles की theft और vehicles के अंदर से theft शामिल.',
'Anti-social behaviour (avg/yr)':
'LSOA में प्रति वर्ष anti-social behaviour incidents की average count, police.uk street-level crime data (2023-2025) से. Nuisance, environmental और personal anti-social behaviour शामिल.',
'Criminal damage and arson (avg/yr)':
'LSOA में प्रति वर्ष criminal damage और arson incidents की average count, police.uk street-level crime data (2023-2025) से.',
'Other theft (avg/yr)':
"'Other theft' offences की LSOA में प्रति वर्ष average count, police.uk street-level crime data (2023-2025) से. Burglary, vehicle crime, shoplifting या bicycle theft में न आने वाली theft शामिल.",
'Theft from the person (avg/yr)':
'LSOA में प्रति वर्ष theft-from-the-person offences की average count, police.uk street-level crime data (2023-2025) से. Pickpocketing और बिना force के bag snatching शामिल.',
'Shoplifting (avg/yr)':
'LSOA में प्रति वर्ष shoplifting offences की average count, police.uk street-level crime data (2023-2025) से.',
'Bicycle theft (avg/yr)':
'LSOA में प्रति वर्ष bicycle theft offences की average count, police.uk street-level crime data (2023-2025) से.',
'Drugs (avg/yr)':
'LSOA में प्रति वर्ष drug offences की average count, police.uk street-level crime data (2023-2025) से. Possession और trafficking offences शामिल.',
'Possession of weapons (avg/yr)':
'LSOA में प्रति वर्ष possession-of-weapons offences की average count, police.uk street-level crime data (2023-2025) से.',
'Public order (avg/yr)':
'LSOA में प्रति वर्ष public order offences की average count, police.uk street-level crime data (2023-2025) से. Fear, alarm या distress पैदा करने वाले acts शामिल.',
'Other crime (avg/yr)':
'LSOA में प्रति वर्ष other criminal offences की average count, police.uk street-level crime data (2023-2025) से. कहीं और classified न होने वाले offences के लिए catch-all category.',
'Median age':
'Census 2021 (TS007A) से. LSOA में usual residents की median age, five-year age band counts से linear interpolation द्वारा calculated. Younger-population areas आमतौर पर urban, university towns या family-heavy होते हैं; higher medians rural और coastal areas में common हैं.',
'% White':
'Census 2021 से. Local authority population का percentage जो White (English, Welsh, Scottish, Northern Irish, British, Irish, Gypsy or Irish Traveller, Roma या any other white background) के रूप में identify करता है.',
'% South Asian':
'Census 2021 से. Local authority population का percentage जो Indian, Pakistani, Bangladeshi या any other Asian background के रूप में identify करता है.',
'% Black':
'Census 2021 से. Local authority population का percentage जो Black, Black British, Caribbean या African के रूप में identify करता है.',
'% East Asian':
'Census 2021 से. Local authority population का percentage जो Chinese के रूप में identify करता है.',
'% Mixed':
'Census 2021 से. Local authority population का percentage जो Mixed या multiple ethnic groups (White and Black Caribbean, White and Black African, White and Asian या other mixed/multiple background) के रूप में identify करता है.',
'% Other':
'Census 2021 से. Local authority population का percentage जो another ethnic group (Arab या main categories से बाहर any other ethnic group) के रूप में identify करता है.',
'Voter turnout (%)':
'July 2024 UK General Election में valid vote देने वाले registered electorate का proportion. Valid votes divided by electorate size. Higher turnout अक्सर affluent areas और closer contests से correlate करता है.',
'% Labour':
'July 2024 UK General Election में इस postcode को cover करने वाली constituency में Labour Party candidates को पड़े valid votes का percentage.',
'% Conservative':
'July 2024 UK General Election में इस postcode को cover करने वाली constituency में Conservative Party को पड़े valid votes का percentage.',
'% Liberal Democrat':
'July 2024 UK General Election में इस postcode को cover करने वाली constituency में Liberal Democrats को पड़े valid votes का percentage.',
'% Reform UK':
'July 2024 UK General Election में इस postcode को cover करने वाली constituency में Reform UK को पड़े valid votes का percentage.',
'% Green':
'July 2024 UK General Election में इस postcode को cover करने वाली constituency में Green Party को पड़े valid votes का percentage.',
'% Other parties':
'इस postcode की constituency में Labour, Conservative, Liberal Democrat, Reform UK और Green के अलावा अन्य parties को पड़े valid votes का percentage. Independents, Speaker और minor parties शामिल.',
'Distance to nearest park (km)':
'Postcode से nearest park entrance तक सीधी रेखा में दूरी, kilometres में. Public parks, gardens, playing fields और play spaces को cover करता है. OS Open Greenspace dataset के access-point locations का उपयोग करता है, इसलिए बड़े park के किनारे properties सही short distance दिखाती हैं.',
'Number of parks within 1km':
'Property postcode centroid से 1km radius के भीतर कम से कम एक entrance रखने वाले public parks, gardens, playing fields और play spaces की संख्या. OS Open Greenspace dataset (Ordnance Survey) से derived, accurate proximity matching के लिए park entrance locations उपयोग करता है.',
'Number of restaurants within 2km':
'Postcode से 2km के भीतर restaurants, cafés और food-service venues. Source: OpenStreetMap.',
'Number of grocery shops and supermarkets within 2km':
'Property postcode centroid से 2km radius के भीतर supermarkets, convenience stores और other grocery shops की संख्या. OpenStreetMap POI data से derived.',
'Noise (dB)':
'Defra Strategic Noise Mapping Round 4 (2022) से road-noise level in decibels (Lden, 24-hour weighted average). Ground से 4m ऊपर 10m grid पर modelled. लगभग 55 dB से ऊपर शोर आमतौर पर noticeable होता है; लगभग 70 dB से ऊपर WHO harmful मानता है.',
'Max available download speed (Mbps)':
'Ofcom Connected Nations 2025 से किसी भी provider द्वारा उपलब्ध maximum fixed-broadband download speed. Theoretical maximum दर्शाता है, achieved speed नहीं. 10 Mbps = basic, 30 = superfast, 100+ = ultrafast, 1000 = gigabit.',
},
hu: { hu: {
'Property type': 'Property type':
'Az HM Land Registry Price Paid adatokból és EPC tanúsítványokból. Különálló, Ikerház, Sorház (minden sorház altípust tartalmaz), Lakás/Maisonette, vagy Egyéb (bungaló, mobilház stb.).', 'Az HM Land Registry Price Paid adatokból és EPC tanúsítványokból. Különálló, Ikerház, Sorház (minden sorház altípust tartalmaz), Lakás/Maisonette, vagy Egyéb (bungaló, mobilház stb.).',

View file

@ -23,11 +23,6 @@ html.dark {
color-scheme: dark; color-scheme: dark;
} }
.home-page-scroll,
.dark .home-page-scroll {
background: linear-gradient(180deg, #080d19 0%, #080d19 50%, #16a34a 50%, #16a34a 100%);
}
/* Smooth theme transitions (scoped to avoid map performance issues) */ /* Smooth theme transitions (scoped to avoid map performance issues) */
body, body,
div, div,

View file

@ -4,6 +4,9 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin'); const CopyWebpackPlugin = require('copy-webpack-plugin');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const FaviconsWebpackPlugin = require('favicons-webpack-plugin'); const FaviconsWebpackPlugin = require('favicons-webpack-plugin');
const sharp = require('sharp');
const HOUSE_IMAGE_WIDTH = 260;
module.exports = (env, argv) => { module.exports = (env, argv) => {
const isProduction = argv.mode === 'production'; const isProduction = argv.mode === 'production';
@ -12,7 +15,8 @@ module.exports = (env, argv) => {
entry: './src/index.tsx', entry: './src/index.tsx',
output: { output: {
path: path.resolve(__dirname, 'dist'), path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js', filename: isProduction ? '[name].[contenthash:8].js' : 'bundle.js',
chunkFilename: isProduction ? '[name].[contenthash:8].js' : '[name].bundle.js',
clean: true, clean: true,
publicPath: '/', publicPath: '/',
@ -52,7 +56,34 @@ module.exports = (env, argv) => {
template: './src/index.html', template: './src/index.html',
}), }),
new CopyWebpackPlugin({ new CopyWebpackPlugin({
patterns: [{ from: 'public', noErrorOnMissing: true }], patterns: [
{
from: 'public',
noErrorOnMissing: true,
globOptions: {
ignore: ['**/house.png'],
},
},
isProduction
? {
from: 'public/house.png',
to: 'house.png',
noErrorOnMissing: true,
transform: {
transformer(content) {
return sharp(content)
.resize({ width: HOUSE_IMAGE_WIDTH, withoutEnlargement: true })
.png({ compressionLevel: 9, palette: true, quality: 85 })
.toBuffer();
},
},
}
: {
from: 'public/house.png',
to: 'house.png',
noErrorOnMissing: true,
},
],
}), }),
new FaviconsWebpackPlugin({ new FaviconsWebpackPlugin({
logo: './public/favicon.svg', logo: './public/favicon.svg',
@ -69,9 +100,111 @@ module.exports = (env, argv) => {
}, },
}), }),
...(isProduction ...(isProduction
? [new MiniCssExtractPlugin()] ? [
new MiniCssExtractPlugin({
filename: '[name].[contenthash:8].css',
chunkFilename: '[name].[contenthash:8].css',
}),
]
: [new ReactRefreshWebpackPlugin()]), : [new ReactRefreshWebpackPlugin()]),
], ],
optimization: isProduction
? {
splitChunks: {
chunks: 'all',
cacheGroups: {
maplibre: {
test: /[\\/]node_modules[\\/]maplibre-gl[\\/]/,
name: 'vendor-maplibre',
chunks: 'async',
priority: 50,
enforce: true,
reuseExistingChunk: true,
},
h3: {
test: /[\\/]node_modules[\\/]h3-js[\\/]/,
name: 'vendor-h3',
chunks: 'async',
priority: 45,
enforce: true,
reuseExistingChunk: true,
},
deckMapbox: {
test: /[\\/]node_modules[\\/]@deck\.gl[\\/]mapbox[\\/]/,
name: 'vendor-deck-mapbox',
chunks: 'async',
priority: 44,
enforce: true,
reuseExistingChunk: true,
},
deckCore: {
test: /[\\/]node_modules[\\/]@deck\.gl[\\/]core[\\/]/,
name: 'vendor-deck-core',
chunks: 'async',
priority: 43,
enforce: true,
reuseExistingChunk: true,
},
deckLayers: {
test: /[\\/]node_modules[\\/]@deck\.gl[\\/](?:layers|geo-layers)[\\/]/,
name: 'vendor-deck-layers',
chunks: 'async',
priority: 42,
enforce: true,
reuseExistingChunk: true,
},
deck: {
test: /[\\/]node_modules[\\/]@deck\.gl[\\/]/,
name: 'vendor-deck',
chunks: 'async',
priority: 40,
enforce: true,
reuseExistingChunk: true,
},
luma: {
test: /[\\/]node_modules[\\/]@luma\.gl[\\/]/,
name: 'vendor-luma',
chunks: 'async',
priority: 35,
enforce: true,
reuseExistingChunk: true,
},
webgpuSupport: {
test: /[\\/]node_modules[\\/]wgsl_reflect[\\/]/,
name: 'vendor-webgpu-support',
chunks: 'async',
priority: 34,
enforce: true,
reuseExistingChunk: true,
},
mapData: {
test: /[\\/]node_modules[\\/](?:@mapbox[\\/]tiny-sdf|@protomaps[\\/]basemaps|earcut|supercluster)[\\/]/,
name: 'vendor-map-data',
chunks: 'async',
priority: 33,
enforce: true,
reuseExistingChunk: true,
},
mapSupport: {
test: /[\\/]node_modules[\\/](?:@loaders\.gl|@math\.gl|@probe\.gl|@vis\.gl|mjolnir\.js|react-map-gl)[\\/]/,
name: 'vendor-map-support',
chunks: 'async',
priority: 30,
enforce: true,
reuseExistingChunk: true,
},
joyride: {
test: /[\\/]node_modules[\\/](?:react-joyride|deepmerge|scroll|scrollparent|react-innertext)[\\/]/,
name: 'vendor-joyride',
chunks: 'async',
priority: 25,
enforce: true,
reuseExistingChunk: true,
},
},
},
}
: undefined,
devServer: { devServer: {
host: '0.0.0.0', host: '0.0.0.0',
port: 3001, port: 3001,

View file

@ -1,7 +1,7 @@
"""Extract place=* nodes and railway stations from OSM PBF → data/places.parquet. """Extract place=* nodes and railway stations from OSM PBF → data/places.parquet.
Extracts named place nodes (cities, towns, suburbs, etc.) and railway stations Extracts named place nodes and railway stations (tube, national rail, DLR,
(tube, national rail, DLR, etc.) for typeahead search. etc.) for typeahead search.
Reuses the same england-latest.osm.pbf as pois.py. Reuses the same england-latest.osm.pbf as pois.py.
""" """
@ -21,7 +21,22 @@ from pipeline.utils.england_geometry import (
load_england_polygon, load_england_polygon,
) )
PLACE_TYPES = {"city"} # Search can use a wider set of OSM place nodes, but travel-time destinations
# must remain restricted to the historical city/station origin set.
SEARCH_PLACE_TYPES = {
"city",
"town",
"village",
"suburb",
"neighbourhood",
"quarter",
"borough",
"locality",
"hamlet",
"isolated_dwelling",
"island",
}
TRAVEL_DESTINATION_PLACE_TYPES = {"city"}
# Suffixes to strip from raw station names before appending the typed suffix. # Suffixes to strip from raw station names before appending the typed suffix.
_STATION_STRIP = ( _STATION_STRIP = (
@ -71,7 +86,13 @@ class PlaceHandler(osmium.SimpleHandler):
self._england = england_polygon self._england = england_polygon
def _add( def _add(
self, name: str, place_type: str, lat: float, lon: float, population: int self,
name: str,
place_type: str,
lat: float,
lon: float,
population: int,
travel_destination: bool,
) -> None: ) -> None:
self.places.append( self.places.append(
{ {
@ -80,6 +101,7 @@ class PlaceHandler(osmium.SimpleHandler):
"lat": lat, "lat": lat,
"lon": lon, "lon": lon,
"population": population, "population": population,
"travel_destination": travel_destination,
} }
) )
self._progress.set_postfix(places=f"{len(self.places):,}", refresh=False) self._progress.set_postfix(places=f"{len(self.places):,}", refresh=False)
@ -107,10 +129,17 @@ class PlaceHandler(osmium.SimpleHandler):
except ValueError: except ValueError:
population = 0 population = 0
# place=* nodes (cities, towns, suburbs, etc.) # place=* nodes
place_type = n.tags.get("place") place_type = n.tags.get("place")
if place_type in PLACE_TYPES: if place_type in SEARCH_PLACE_TYPES:
self._add(name, place_type, lat, lon, population) self._add(
name,
place_type,
lat,
lon,
population,
travel_destination=place_type in TRAVEL_DESTINATION_PLACE_TYPES,
)
return return
# Railway stations (tube, national rail, DLR, overground, Elizabeth line) # Railway stations (tube, national rail, DLR, overground, Elizabeth line)
@ -126,7 +155,14 @@ class PlaceHandler(osmium.SimpleHandler):
): ):
return return
display_name = _station_display_name(name, tags) display_name = _station_display_name(name, tags)
self._add(display_name, "station", lat, lon, population) self._add(
display_name,
"station",
lat,
lon,
population,
travel_destination=True,
)
return return
@ -147,7 +183,7 @@ def main() -> None:
pbf_file = args.pbf pbf_file = args.pbf
england_polygon = load_england_polygon(args.boundary) england_polygon = load_england_polygon(args.boundary)
print("Extracting place nodes: cities + railway stations") print("Extracting search place nodes + railway stations")
with tqdm( with tqdm(
unit=" elements", unit=" elements",
unit_scale=True, unit_scale=True,

View file

@ -65,7 +65,7 @@ public class App {
String[] originNames = places.names(); String[] originNames = places.names();
double[] originLats = places.lats(), originLons = places.lons(); double[] originLats = places.lats(), originLons = places.lons();
int nOrigins = originLats.length; int nOrigins = originLats.length;
System.err.printf(" %,d places (total)%n", nOrigins); System.err.printf(" %,d travel-eligible places%n", nOrigins);
// Filter places to England only (must be near at least one England postcode) // Filter places to England only (must be near at least one England postcode)
Set<Integer> englandIndices = filterEnglandPlaces( Set<Integer> englandIndices = filterEnglandPlaces(
@ -89,7 +89,7 @@ public class App {
System.err.printf("DEMO MODE: %d places (transit only)%n", originIndices.length); System.err.printf("DEMO MODE: %d places (transit only)%n", originIndices.length);
for (int i : originIndices) System.err.printf(" - %s%n", originNames[i]); for (int i : originIndices) System.err.printf(" - %s%n", originNames[i]);
} else { } else {
// Normal mode: use all England places // Normal mode: use all travel-eligible England places
originIndices = englandIndices.stream().sorted() originIndices = englandIndices.stream().sorted()
.mapToInt(Integer::intValue).toArray(); .mapToInt(Integer::intValue).toArray();
modes = MODES; modes = MODES;

View file

@ -59,9 +59,15 @@ public class Parquet {
/** Load places deduplicated by lat/lon, write reference parquet, return names + flat lat/lon arrays. */ /** Load places deduplicated by lat/lon, write reference parquet, return names + flat lat/lon arrays. */
static Places loadPlaces(String parquetPath, Path refOut) throws Exception { static Places loadPlaces(String parquetPath, Path refOut) throws Exception {
try (DuckDBConnection conn = connect(); Statement stmt = conn.createStatement()) { try (DuckDBConnection conn = connect(); Statement stmt = conn.createStatement()) {
stmt.execute("CREATE TABLE all_places AS SELECT * FROM read_parquet('"
+ escapePath(parquetPath) + "')");
boolean hasTravelDestination = tableHasColumn(stmt, "all_places", "travel_destination");
String source = hasTravelDestination
? "(SELECT * FROM all_places WHERE COALESCE(travel_destination, true))"
: "all_places";
stmt.execute("CREATE TABLE places AS SELECT * EXCLUDE (rn) FROM (" stmt.execute("CREATE TABLE places AS SELECT * EXCLUDE (rn) FROM ("
+ "SELECT *, ROW_NUMBER() OVER (PARTITION BY lat, lon) AS rn " + "SELECT *, ROW_NUMBER() OVER (PARTITION BY lat, lon) AS rn "
+ "FROM read_parquet('" + escapePath(parquetPath) + "')) WHERE rn = 1"); + "FROM " + source + " AS p) WHERE rn = 1");
copyToParquet(stmt, "SELECT * FROM places", refOut); copyToParquet(stmt, "SELECT * FROM places", refOut);
try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM places")) { try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM places")) {
@ -85,6 +91,17 @@ public class Parquet {
} }
} }
private static boolean tableHasColumn(Statement stmt, String tableName, String columnName)
throws Exception {
try (ResultSet rs = stmt.executeQuery(
"SELECT COUNT(*) FROM information_schema.columns "
+ "WHERE table_name = '" + tableName + "' "
+ "AND column_name = '" + columnName + "'")) {
rs.next();
return rs.getInt(1) > 0;
}
}
/** Write postcode travel times as a ZSTD-compressed parquet (atomic via tmp + rename). */ /** Write postcode travel times as a ZSTD-compressed parquet (atomic via tmp + rename). */
static void writeTravelTimes(DuckDBConnection conn, Path outPath, String[] postcodes, short[] times) static void writeTravelTimes(DuckDBConnection conn, Path outPath, String[] postcodes, short[] times)
throws Exception { throws Exception {

View file

@ -1618,3 +1618,973 @@
2026-05-04T21:01:19.544562Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11 2026-05-04T21:01:19.544562Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:01:19.556893Z INFO property_map_server::routes::features: GET /api/features 2026-05-04T21:01:19.556893Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:01:19.558130Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11 2026-05-04T21:01:19.558130Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:07:10.355869Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=64311 parallel=true cells_before_filter=249 cells_after_filter=231 truncated=false bounds=51.4958,-0.1636,51.5342,-0.0964 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-04T21:07:29.438731Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:07:30.930722Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:07:30.932184Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:07:32.691536Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=1.7 json_ms=0.4 total_ms=2.0
2026-05-04T21:07:51.398221Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=52 cells_after_filter=37 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=4 filters_raw="Estimated current price:0:600000;;Number of bedrooms & living rooms:4:6;;Property type:Detached|Semi-Detached|Terraced;;Distance to nearest train or tube station (km):0:1" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=1.8 json_ms=0.0 total_ms=1.8
2026-05-04T21:07:51.649551Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=152340 filters=4 travel=0 total=192 filters_raw="Estimated current price:0:600000;;Number of bedrooms & living rooms:4:6;;Property type:Detached|Semi-Detached|Terraced;;Distance to nearest train or tube station (km):0:1" ms=3.0
2026-05-04T21:07:55.810502Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=392 cells_after_filter=297 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=3 filters_raw="Number of bedrooms & living rooms:4:6;;Property type:Detached|Semi-Detached|Terraced;;Distance to nearest train or tube station (km):0:1" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=2.6 json_ms=0.4 total_ms=3.0
2026-05-04T21:07:57.323367Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=52 cells_after_filter=37 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=4 filters_raw="Estimated current price:0:600000;;Number of bedrooms & living rooms:4:6;;Property type:Detached|Semi-Detached|Terraced;;Distance to nearest train or tube station (km):0:1" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=1.7 json_ms=0.1 total_ms=1.8
2026-05-04T21:08:28.517850Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=261 cells_after_filter=182 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=4 filters_raw="Estimated current price:12976.594:1380000;;Number of bedrooms & living rooms:4:6;;Property type:Detached|Semi-Detached|Terraced;;Distance to nearest train or tube station (km):0:1" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=2.5 json_ms=0.2 total_ms=2.7
2026-05-04T21:08:28.518933Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=152340 filters=4 travel=0 total=2266 filters_raw="Estimated current price:12976.594:1380000;;Number of bedrooms & living rooms:4:6;;Property type:Detached|Semi-Detached|Terraced;;Distance to nearest train or tube station (km):0:1" ms=3.7
2026-05-04T21:08:38.632679Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89194ad325bffff resolution=9 total_count=1 filters=4 filters_raw="Estimated current price:12976.594:1380000;;Number of bedrooms & living rooms:4:6;;Property type:Detached|Semi-Detached|Terraced;;Distance to nearest train or tube station (km):0:1" ms=0.1
2026-05-04T21:08:39.686104Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=108397 parallel=true cells_before_filter=183 cells_after_filter=134 truncated=false bounds=51.4943,-0.1671,51.5357,-0.0929 filters=4 filters_raw="Estimated current price:12976.594:1380000;;Number of bedrooms & living rooms:4:6;;Property type:Detached|Semi-Detached|Terraced;;Distance to nearest train or tube station (km):0:1" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=3.2 json_ms=0.2 total_ms=3.3
2026-05-04T21:08:39.710272Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=108397 filters=4 travel=0 total=1583 filters_raw="Estimated current price:12976.594:1380000;;Number of bedrooms & living rooms:4:6;;Property type:Detached|Semi-Detached|Terraced;;Distance to nearest train or tube station (km):0:1" ms=2.3
2026-05-04T21:10:59.962261Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:10:59.962264Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:11:29.998393Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:11:29.998405Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:11:30.044607Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:11:30.045945Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:11:52.906468Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:11:52.907285Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:11:53.875945Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:11:53.900406Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:12:02.485327Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:12:02.485341Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:12:02.871760Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:12:02.873319Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:12:15.721128Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:12:15.721131Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:12:15.873635Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:12:15.873891Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:13:41.600447Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:13:41.600448Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:14:10.905771Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:14:10.906312Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:14:10.917360Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:14:10.926781Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:14:14.273968Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:14:14.274980Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:14:14.288306Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:14:14.289553Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:16:30.344962Z INFO property_map_server: Prometheus metrics initialized
2026-05-04T21:16:30.345109Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet
2026-05-04T21:16:30.345114Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
2026-05-04T21:16:30.426500Z INFO property_map_server::data::property: Postcode features loaded rows=1263786
2026-05-04T21:16:30.426510Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-05-04T21:16:33.162256Z INFO property_map_server::data::property: Properties joined with postcodes rows=15268176
2026-05-04T21:16:33.162293Z INFO property_map_server::data::property: Feature columns from config numeric=63 enums=6 total=69
2026-05-04T21:16:36.375023Z WARN property_map_server::data::property: Dropped properties with missing postcode coordinates rows=743076
2026-05-04T21:16:36.375034Z INFO property_map_server::data::property: Combined data selected rows=14525100
2026-05-04T21:16:36.504029Z INFO property_map_server::data::property: Extracting numeric feature columns
2026-05-04T21:16:36.905967Z INFO property_map_server::data::property: Computing histograms for numeric features
2026-05-04T21:16:38.095951Z INFO property_map_server::data::property: Extracting string columns
2026-05-04T21:16:39.478199Z INFO property_map_server::data::property: Building enum features
2026-05-04T21:16:40.751598Z INFO property_map_server::data::property: Extracting renovation history
2026-05-04T21:16:45.902894Z INFO property_map_server: Prometheus metrics initialized
2026-05-04T21:16:45.903095Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet
2026-05-04T21:16:45.903117Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
2026-05-04T21:16:45.975537Z INFO property_map_server::data::property: Postcode features loaded rows=1263786
2026-05-04T21:16:45.975550Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-05-04T21:16:48.852633Z INFO property_map_server::data::property: Properties joined with postcodes rows=15268176
2026-05-04T21:16:48.852686Z INFO property_map_server::data::property: Feature columns from config numeric=63 enums=6 total=69
2026-05-04T21:16:51.727345Z WARN property_map_server::data::property: Dropped properties with missing postcode coordinates rows=743076
2026-05-04T21:16:51.727354Z INFO property_map_server::data::property: Combined data selected rows=14525100
2026-05-04T21:16:51.853922Z INFO property_map_server::data::property: Extracting numeric feature columns
2026-05-04T21:16:52.259794Z INFO property_map_server::data::property: Computing histograms for numeric features
2026-05-04T21:16:53.519465Z INFO property_map_server::data::property: Extracting string columns
2026-05-04T21:16:54.902888Z INFO property_map_server::data::property: Building enum features
2026-05-04T21:16:56.099583Z INFO property_map_server::data::property: Extracting renovation history
2026-05-04T21:16:58.328466Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1741939
2026-05-04T21:16:58.328474Z INFO property_map_server::data::property: Sorting rows by spatial locality
2026-05-04T21:16:59.302215Z INFO property_map_server::data::property: Building interned strings
2026-05-04T21:17:05.078928Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
2026-05-04T21:17:13.299855Z INFO property_map_server: Prometheus metrics initialized
2026-05-04T21:17:13.300056Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet
2026-05-04T21:17:13.300070Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
2026-05-04T21:17:13.388566Z INFO property_map_server::data::property: Postcode features loaded rows=1263786
2026-05-04T21:17:13.388576Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-05-04T21:17:16.112850Z INFO property_map_server::data::property: Properties joined with postcodes rows=15268176
2026-05-04T21:17:16.112896Z INFO property_map_server::data::property: Feature columns from config numeric=63 enums=6 total=69
2026-05-04T21:17:18.943990Z WARN property_map_server::data::property: Dropped properties with missing postcode coordinates rows=743076
2026-05-04T21:17:18.944000Z INFO property_map_server::data::property: Combined data selected rows=14525100
2026-05-04T21:17:19.074139Z INFO property_map_server::data::property: Extracting numeric feature columns
2026-05-04T21:17:19.478164Z INFO property_map_server::data::property: Computing histograms for numeric features
2026-05-04T21:17:20.900871Z INFO property_map_server::data::property: Extracting string columns
2026-05-04T21:17:22.270617Z INFO property_map_server::data::property: Building enum features
2026-05-04T21:17:23.464560Z INFO property_map_server::data::property: Extracting renovation history
2026-05-04T21:17:25.627267Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1741939
2026-05-04T21:17:25.627275Z INFO property_map_server::data::property: Sorting rows by spatial locality
2026-05-04T21:17:26.514957Z INFO property_map_server::data::property: Building interned strings
2026-05-04T21:17:32.201475Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
2026-05-04T21:17:39.253903Z INFO property_map_server::data::property: Data loading complete
2026-05-04T21:17:40.705499Z INFO property_map_server: Allocator trim label="property data load" trimmed=true rss_before_mib=12930.5 rss_after_mib=3177.9 released_mib=9752.6
2026-05-04T21:17:40.705511Z INFO property_map_server: Property data loaded rows=14525100 features=69 enums=6
2026-05-04T21:17:40.705515Z INFO property_map_server: Building spatial grid index (0.01° cells)
2026-05-04T21:17:40.809720Z INFO property_map_server: Precomputing H3 cells at resolution 12
2026-05-04T21:17:40.809729Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
2026-05-04T21:17:41.203126Z INFO property_map_server::data::property: H3 precomputation complete (14525100 cells)
2026-05-04T21:17:41.203149Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
2026-05-04T21:17:41.203156Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
2026-05-04T21:17:41.231149Z INFO property_map_server::data::poi: Loaded 567534 POIs
2026-05-04T21:17:41.381403Z INFO property_map_server::data::poi: POI string columns interned category_unique=94 group_unique=11 emoji_unique=71
2026-05-04T21:17:41.382547Z INFO property_map_server::data::poi: POI data loading complete.
2026-05-04T21:17:41.429667Z INFO property_map_server: Allocator trim label="poi data load" trimmed=true rss_before_mib=3584.4 rss_after_mib=3395.2 released_mib=189.1
2026-05-04T21:17:41.429679Z INFO property_map_server: POI data loaded pois=567534
2026-05-04T21:17:41.429682Z INFO property_map_server: Building POI spatial grid index
2026-05-04T21:17:41.437592Z INFO property_map_server: Loading place data from /app/data/places.parquet
2026-05-04T21:17:41.437604Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
2026-05-04T21:17:41.438262Z INFO property_map_server::data::places: Loaded 3474 places
2026-05-04T21:17:41.439221Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
2026-05-04T21:17:41.443763Z INFO property_map_server: Allocator trim label="place data load" trimmed=true rss_before_mib=3404.3 rss_after_mib=3399.9 released_mib=4.4
2026-05-04T21:17:41.443773Z INFO property_map_server: Place data loaded places=3474
2026-05-04T21:17:41.443782Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
2026-05-04T21:17:41.443794Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
2026-05-04T21:17:41.469434Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
2026-05-04T21:17:50.535745Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
2026-05-04T21:17:50.911994Z INFO property_map_server: Allocator trim label="postcode boundary load" trimmed=true rss_before_mib=10725.6 rss_after_mib=10526.1 released_mib=199.6
2026-05-04T21:17:50.912004Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
2026-05-04T21:17:51.067150Z INFO property_map_server::data::postcodes: Outcode data derived from postcodes outcodes=2361
2026-05-04T21:17:51.067221Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
2026-05-04T21:17:51.067464Z INFO property_map_server: PMTiles loaded successfully
2026-05-04T21:17:51.106375Z INFO property_map_server: No --dist provided; static serving and OG injection disabled
2026-05-04T21:17:51.136547Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
2026-05-04T21:17:51.136731Z INFO property_map_server: Precomputed features response groups=8
2026-05-04T21:17:51.136746Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
2026-05-04T21:17:51.212590Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
2026-05-04T21:17:51.515914Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
2026-05-04T21:17:51.519337Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
2026-05-04T21:18:27.672179Z INFO property_map_server: Prometheus metrics initialized
2026-05-04T21:18:27.672342Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet
2026-05-04T21:18:27.672350Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
2026-05-04T21:18:27.738364Z INFO property_map_server::data::property: Postcode features loaded rows=1263786
2026-05-04T21:18:27.738378Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-05-04T21:18:30.329460Z INFO property_map_server::data::property: Properties joined with postcodes rows=15268176
2026-05-04T21:18:30.329500Z INFO property_map_server::data::property: Feature columns from config numeric=63 enums=6 total=69
2026-05-04T21:18:32.935500Z WARN property_map_server::data::property: Dropped properties with missing postcode coordinates rows=743076
2026-05-04T21:18:32.935509Z INFO property_map_server::data::property: Combined data selected rows=14525100
2026-05-04T21:18:33.057215Z INFO property_map_server::data::property: Extracting numeric feature columns
2026-05-04T21:18:33.411442Z INFO property_map_server::data::property: Computing histograms for numeric features
2026-05-04T21:18:34.615065Z INFO property_map_server::data::property: Extracting string columns
2026-05-04T21:18:35.946034Z INFO property_map_server::data::property: Building enum features
2026-05-04T21:18:37.161827Z INFO property_map_server::data::property: Extracting renovation history
2026-05-04T21:18:39.203014Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1741939
2026-05-04T21:18:39.203022Z INFO property_map_server::data::property: Sorting rows by spatial locality
2026-05-04T21:18:40.060146Z INFO property_map_server::data::property: Building interned strings
2026-05-04T21:19:00.964738Z INFO property_map_server: Prometheus metrics initialized
2026-05-04T21:19:00.964898Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet
2026-05-04T21:19:00.964909Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
2026-05-04T21:19:01.045232Z INFO property_map_server::data::property: Postcode features loaded rows=1263786
2026-05-04T21:19:01.045241Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-05-04T21:19:03.683522Z INFO property_map_server::data::property: Properties joined with postcodes rows=15268176
2026-05-04T21:19:03.683554Z INFO property_map_server::data::property: Feature columns from config numeric=63 enums=6 total=69
2026-05-04T21:19:06.678528Z WARN property_map_server::data::property: Dropped properties with missing postcode coordinates rows=743076
2026-05-04T21:19:06.678539Z INFO property_map_server::data::property: Combined data selected rows=14525100
2026-05-04T21:19:06.820178Z INFO property_map_server::data::property: Extracting numeric feature columns
2026-05-04T21:19:07.223089Z INFO property_map_server::data::property: Computing histograms for numeric features
2026-05-04T21:19:08.782981Z INFO property_map_server::data::property: Extracting string columns
2026-05-04T21:19:10.395522Z INFO property_map_server::data::property: Building enum features
2026-05-04T21:19:11.637823Z INFO property_map_server::data::property: Extracting renovation history
2026-05-04T21:19:13.851485Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1741939
2026-05-04T21:19:13.851495Z INFO property_map_server::data::property: Sorting rows by spatial locality
2026-05-04T21:19:14.789536Z INFO property_map_server::data::property: Building interned strings
2026-05-04T21:19:20.543430Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
2026-05-04T21:19:23.642436Z INFO property_map_server::data::property: Data loading complete
2026-05-04T21:19:25.249114Z INFO property_map_server: Allocator trim label="property data load" trimmed=true rss_before_mib=12859.7 rss_after_mib=3226.5 released_mib=9633.2
2026-05-04T21:19:25.249127Z INFO property_map_server: Property data loaded rows=14525100 features=69 enums=6
2026-05-04T21:19:25.249130Z INFO property_map_server: Building spatial grid index (0.01° cells)
2026-05-04T21:19:25.355938Z INFO property_map_server: Precomputing H3 cells at resolution 12
2026-05-04T21:19:25.355947Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
2026-05-04T21:19:25.761535Z INFO property_map_server::data::property: H3 precomputation complete (14525100 cells)
2026-05-04T21:19:25.761575Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
2026-05-04T21:19:25.761589Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
2026-05-04T21:19:25.788010Z INFO property_map_server::data::poi: Loaded 567534 POIs
2026-05-04T21:19:25.941519Z INFO property_map_server::data::poi: POI string columns interned category_unique=94 group_unique=11 emoji_unique=71
2026-05-04T21:19:25.942891Z INFO property_map_server::data::poi: POI data loading complete.
2026-05-04T21:19:25.989855Z INFO property_map_server: Allocator trim label="poi data load" trimmed=true rss_before_mib=3634.0 rss_after_mib=3444.1 released_mib=189.9
2026-05-04T21:19:25.989867Z INFO property_map_server: POI data loaded pois=567534
2026-05-04T21:19:25.989870Z INFO property_map_server: Building POI spatial grid index
2026-05-04T21:19:25.998237Z INFO property_map_server: Loading place data from /app/data/places.parquet
2026-05-04T21:19:25.998254Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
2026-05-04T21:19:25.998909Z INFO property_map_server::data::places: Loaded 3474 places
2026-05-04T21:19:25.999902Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
2026-05-04T21:19:26.003577Z INFO property_map_server: Allocator trim label="place data load" trimmed=true rss_before_mib=3453.3 rss_after_mib=3448.8 released_mib=4.5
2026-05-04T21:19:26.003589Z INFO property_map_server: Place data loaded places=3474
2026-05-04T21:19:26.003600Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
2026-05-04T21:19:26.003611Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
2026-05-04T21:19:26.006554Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
2026-05-04T21:19:33.580078Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
2026-05-04T21:19:33.940360Z INFO property_map_server: Allocator trim label="postcode boundary load" trimmed=true rss_before_mib=10756.3 rss_after_mib=10557.9 released_mib=198.4
2026-05-04T21:19:33.940372Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
2026-05-04T21:19:34.095945Z INFO property_map_server::data::postcodes: Outcode data derived from postcodes outcodes=2361
2026-05-04T21:19:34.095996Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
2026-05-04T21:19:34.149362Z INFO property_map_server: PMTiles loaded successfully
2026-05-04T21:19:34.188674Z INFO property_map_server: No --dist provided; static serving disabled
2026-05-04T21:19:34.220411Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
2026-05-04T21:19:34.220659Z INFO property_map_server: Precomputed features response groups=8
2026-05-04T21:19:34.220676Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
2026-05-04T21:19:34.272045Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
2026-05-04T21:19:34.276489Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
2026-05-04T21:19:34.281445Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
2026-05-04T21:19:34.364449Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfect-postcodes.co.uk/pb
2026-05-04T21:19:34.372757Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
2026-05-04T21:19:34.372842Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
2026-05-04T21:19:34.372864Z INFO property_map_server: Loading travel time data from /app/data/travel-times
2026-05-04T21:19:34.382336Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2753
2026-05-04T21:19:34.389548Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=2753
2026-05-04T21:19:34.399662Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=2753
2026-05-04T21:19:34.408881Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=2752
2026-05-04T21:19:34.408917Z INFO property_map_server: Travel time store loaded modes=4
2026-05-04T21:19:34.408966Z INFO property_map_server: Precomputed AI filters system prompt
2026-05-04T21:19:44.689686Z INFO property_map_server: All memory pages locked (mlockall)
2026-05-04T21:19:44.689720Z INFO property_map_server: Server listening on 0.0.0.0:8001
2026-05-04T21:19:45.521046Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:19:46.703870Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:19:46.705139Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:19:48.457712Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:19:48.459328Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:19:49.862812Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:19:49.864269Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:19:50.686286Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=11.5 json_ms=0.3 total_ms=11.8
2026-05-04T21:20:08.572854Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=52 cells_after_filter=37 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=4 filters_raw="Estimated current price:0:600000;;Number of bedrooms & living rooms:4:6;;Property type:Detached|Semi-Detached|Terraced;;Distance to nearest train or tube station (km):0:1" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=3.1 json_ms=0.1 total_ms=3.2
2026-05-04T21:20:08.820170Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=152340 filters=4 travel=0 total=192 filters_raw="Estimated current price:0:600000;;Number of bedrooms & living rooms:4:6;;Property type:Detached|Semi-Detached|Terraced;;Distance to nearest train or tube station (km):0:1" ms=3.0
2026-05-04T21:20:13.123166Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=392 cells_after_filter=297 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=3 filters_raw="Number of bedrooms & living rooms:4:6;;Property type:Detached|Semi-Detached|Terraced;;Distance to nearest train or tube station (km):0:1" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=3.8 json_ms=0.5 total_ms=4.4
2026-05-04T21:20:15.004875Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=52 cells_after_filter=37 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=4 filters_raw="Estimated current price:0:600000;;Number of bedrooms & living rooms:4:6;;Property type:Detached|Semi-Detached|Terraced;;Distance to nearest train or tube station (km):0:1" fields=1 travel_entries=0 grid_ms=0.0 agg_ms=2.2 json_ms=0.1 total_ms=2.3
2026-05-04T21:20:43.935003Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=152340 filters=4 travel=0 total=2266 filters_raw="Estimated current price:12976.594:1380000;;Number of bedrooms & living rooms:4:6;;Property type:Detached|Semi-Detached|Terraced;;Distance to nearest train or tube station (km):0:1" ms=2.9
2026-05-04T21:20:43.940349Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=261 cells_after_filter=182 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=4 filters_raw="Estimated current price:12976.594:1380000;;Number of bedrooms & living rooms:4:6;;Property type:Detached|Semi-Detached|Terraced;;Distance to nearest train or tube station (km):0:1" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=8.0 json_ms=0.2 total_ms=8.2
2026-05-04T21:20:53.152011Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89194ad325bffff resolution=9 total_count=1 filters=4 filters_raw="Estimated current price:12976.594:1380000;;Number of bedrooms & living rooms:4:6;;Property type:Detached|Semi-Detached|Terraced;;Distance to nearest train or tube station (km):0:1" ms=0.1
2026-05-04T21:20:53.886934Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=108397 parallel=true cells_before_filter=183 cells_after_filter=134 truncated=false bounds=51.4943,-0.1671,51.5357,-0.0929 filters=4 filters_raw="Estimated current price:12976.594:1380000;;Number of bedrooms & living rooms:4:6;;Property type:Detached|Semi-Detached|Terraced;;Distance to nearest train or tube station (km):0:1" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=0.9 json_ms=0.1 total_ms=1.0
2026-05-04T21:20:54.311995Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=108397 filters=4 travel=0 total=1583 filters_raw="Estimated current price:12976.594:1380000;;Number of bedrooms & living rooms:4:6;;Property type:Detached|Semi-Detached|Terraced;;Distance to nearest train or tube station (km):0:1" ms=3.4
2026-05-04T21:23:14.002548Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:23:14.002553Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:23:14.885571Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:23:14.885899Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:28:24.898600Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:28:24.898612Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:28:40.888196Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:28:40.888204Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:30:09.864228Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:30:09.867843Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:30:09.879658Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:30:09.879680Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:33:13.462061Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:33:16.976720Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:33:57.226080Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.6
2026-05-04T21:34:03.822735Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:34:03.824308Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:34:24.874278Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:34:24.879329Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:34:48.816750Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:34:53.723428Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:34:53.723676Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:34:53.903706Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:34:53.903714Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:35:21.885930Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:35:21.889745Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:35:28.878329Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:35:28.878578Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:36:03.824012Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:36:03.824207Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:36:03.894459Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:36:03.894603Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:36:03.896824Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:36:03.900692Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:37:35.949585Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:37:35.950905Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:37:50.747341Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:37:50.757709Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:37:50.875450Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:37:50.881785Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:37:50.881790Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:37:50.882098Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:38:45.015175Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:38:45.015179Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:38:45.896227Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:38:45.901008Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:39:03.876874Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:39:03.877337Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:39:09.027276Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:39:09.035644Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:39:10.034456Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:39:10.034459Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:39:37.942331Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:39:37.942548Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:39:38.905987Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:39:38.909982Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:40:36.876309Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:40:36.876510Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:40:39.913835Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:40:39.915358Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:40:39.915444Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:40:39.919463Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:40:40.032873Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:40:40.037649Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:41:03.975071Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:41:03.975346Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:41:04.913909Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:41:04.919871Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:42:54.181539Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:42:55.702312Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:42:55.703626Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:42:56.110756Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=3.8 json_ms=0.3 total_ms=4.1
2026-05-04T21:43:10.163996Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.7
2026-05-04T21:43:10.184457Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.7
2026-05-04T21:43:10.582333Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=182.8 agg_ms=1.3 json_ms=0.0 total_ms=184.2
2026-05-04T21:43:10.673220Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=99 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.2
2026-05-04T21:43:19.396620Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.6 json_ms=0.1 total_ms=1.7
2026-05-04T21:43:20.185266Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.9 json_ms=0.1 total_ms=3.0
2026-05-04T21:43:32.458294Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=32 cells_after_filter=30 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.3 json_ms=0.0 total_ms=1.4
2026-05-04T21:43:32.731139Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=76 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.7
2026-05-04T21:43:47.913139Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:43:47.915273Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:43:47.918535Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:43:47.922605Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:43:47.930124Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:43:47.937684Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:44:02.476219Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:44:02.481022Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:44:02.635976Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.6
2026-05-04T21:44:02.888032Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=32 cells_after_filter=30 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0400 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.8 json_ms=0.0 total_ms=2.9
2026-05-04T21:44:03.777803Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=76 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.8
2026-05-04T21:44:04.332655Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=32 cells_after_filter=30 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0400 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.9 json_ms=0.1 total_ms=1.9
2026-05-04T21:44:04.579701Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=76 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.7
2026-05-04T21:45:31.271440Z INFO property_map_server::routes::pois: GET /api/pois results=2 candidates=106 categories=1 categories_raw="Bakery" ms=0.0
2026-05-04T21:45:31.271748Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=5749 parallel=false cells_before_filter=205 cells_after_filter=193 truncated=false bounds=52.4771,0.8684,52.5473,1.0384 filters=2 filters_raw="Outstanding primary schools within 5km:0:13;;Outstanding secondary schools within 5km:0:4" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=0.2 json_ms=0.1 total_ms=0.3
2026-05-04T21:45:31.521188Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=5749 filters=2 travel=0 total=5749 filters_raw="Outstanding primary schools within 5km:0:13;;Outstanding secondary schools within 5km:0:4" ms=0.1
2026-05-04T21:45:45.569748Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:45:45.569757Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:45:46.085245Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:45:46.085435Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:46:27.779500Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:46:27.781022Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:46:27.895780Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:46:27.895880Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:46:27.895962Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:46:27.896163Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:46:31.868072Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:46:31.868078Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:46:31.887443Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:46:31.888421Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:46:32.880541Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:46:32.881908Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:46:32.895414Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:46:32.897862Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:47:53.098117Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:47:53.099184Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:47:53.881339Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:47:53.883980Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:47:53.893566Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:47:53.893593Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:47:53.896287Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:47:53.897850Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:49:06.869789Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:49:06.874076Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:49:06.890936Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:49:06.893924Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:49:06.911752Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:49:06.912758Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:49:06.925912Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:49:06.927142Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:49:45.400194Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:49:46.755368Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:49:46.756851Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:49:47.134339Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=3.0 json_ms=0.3 total_ms=3.3
2026-05-04T21:50:01.449061Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.5
2026-05-04T21:50:01.477122Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.8
2026-05-04T21:50:01.699749Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.9 json_ms=0.1 total_ms=1.9
2026-05-04T21:50:02.021720Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=99 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=3.2
2026-05-04T21:50:08.430182Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=0.9 json_ms=0.1 total_ms=0.9
2026-05-04T21:50:10.874075Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.2 json_ms=0.1 total_ms=1.3
2026-05-04T21:50:19.601888Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:50:19.603766Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:50:19.632454Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:50:19.632632Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:50:19.896460Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:50:19.898391Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:50:19.903939Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:50:19.905530Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:50:19.908881Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:50:19.913126Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:50:20.428655Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.9
2026-05-04T21:50:20.434364Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=6.7 json_ms=0.1 total_ms=6.8
2026-05-04T21:50:20.468217Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.9 json_ms=0.1 total_ms=3.0
2026-05-04T21:50:22.089671Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.9 json_ms=0.1 total_ms=2.0
2026-05-04T21:50:22.090509Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=99 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.8
2026-05-04T21:50:40.042107Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:50:40.042113Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:50:48.042644Z 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.3 json_ms=0.2 total_ms=1.5
2026-05-04T21:50:49.345436Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=1214362 parallel=true cells_before_filter=5967 cells_after_filter=5802 truncated=false bounds=51.4334,-0.3126,51.6124,0.1116 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=11.3 json_ms=3.6 total_ms=15.0
2026-05-04T21:50:50.135421Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=8 rows=2442848 parallel=true cells_before_filter=3898 cells_after_filter=3890 truncated=false bounds=51.3375,-0.5231,51.7322,0.4127 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=10.8 json_ms=1.9 total_ms=12.8
2026-05-04T21:50:51.354636Z INFO property_map_server::routes::postcodes: GET /api/postcodes postcodes_before_filter=1725 postcodes_after_filter=219 filtered_out=1506 truncated=false bounds=51.510465,-0.143230,51.515758,-0.130685 filters=0 filters_raw="-" fields=0 travel_entries=0 agg_ms=1.3 json_ms=0.7 total_ms=1.9
2026-05-04T21:50:51.941610Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=40248 parallel=false cells_before_filter=186 cells_after_filter=150 truncated=false bounds=51.5023,-0.1612,51.5260,-0.1049 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-04T21:50:53.589925Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=40248 parallel=false cells_before_filter=186 cells_after_filter=150 truncated=false bounds=51.5023,-0.1612,51.5260,-0.1049 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=1.1 json_ms=0.1 total_ms=1.3
2026-05-04T21:50:57.385975Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89195da4987ffff resolution=9 total_count=244 filters=0 filters_raw="-" ms=0.3
2026-05-04T21:50:57.735827Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=40248 parallel=false cells_before_filter=186 cells_after_filter=122 truncated=false bounds=51.5023,-0.1536,51.5260,-0.1126 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=1.0 json_ms=0.1 total_ms=1.2
2026-05-04T21:51:36.199186Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=40248 parallel=false cells_before_filter=186 cells_after_filter=122 truncated=false bounds=51.5023,-0.1536,51.5260,-0.1126 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=0.9 json_ms=0.1 total_ms=1.0
2026-05-04T21:51:40.435673Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:51:40.436199Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:51:40.436621Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:51:40.439811Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:51:40.451919Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:51:40.451948Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:51:40.461983Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:51:40.462001Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:51:40.756926Z 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.8 json_ms=0.2 total_ms=2.0
2026-05-04T21:51:41.685644Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:51:41.686016Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:51:41.690808Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:51:41.690945Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:51:41.708144Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:51:41.708177Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:51:41.719164Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:51:41.719177Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:51:41.998854Z 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.2
2026-05-04T21:52:20.289809Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:52:20.291154Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:52:20.475111Z 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.2 json_ms=0.2 total_ms=1.4
2026-05-04T21:52:20.890339Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:52:20.890372Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:52:20.898898Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:52:20.902649Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:52:20.907465Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:52:20.907472Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:52:58.073984Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:52:58.076101Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:52:58.249258Z 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.2
2026-05-04T21:52:58.889010Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:52:58.889757Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:52:58.898342Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:52:58.902657Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:52:58.902725Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:52:58.907164Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:53:35.425317Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:53:35.425338Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:53:35.613332Z 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.4 json_ms=0.2 total_ms=1.6
2026-05-04T21:53:35.883634Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:53:35.883704Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:53:35.894889Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:53:35.901771Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:53:35.902111Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:53:35.902628Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:55:46.371570Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:55:46.373081Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:55:46.553405Z 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.2 json_ms=0.2 total_ms=1.4
2026-05-04T21:55:46.891885Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:55:46.893094Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:55:46.903844Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:55:46.906495Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:55:46.906576Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:55:46.915610Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:55:57.716037Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:56:00.226058Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:56:00.227285Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:56:00.576766Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=1.5 json_ms=0.3 total_ms=1.8
2026-05-04T21:56:10.593840Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:56:10.594012Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:56:10.611505Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:56:10.611527Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:56:10.765236Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=1.8 json_ms=0.4 total_ms=2.2
2026-05-04T21:56:10.797375Z 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=2.7 json_ms=0.3 total_ms=3.0
2026-05-04T21:56:10.910205Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:56:10.910231Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:56:10.916559Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:56:10.921383Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:56:10.925627Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:56:10.925657Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:56:14.846854Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.9
2026-05-04T21:56:14.847537Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.6
2026-05-04T21:56:14.853045Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=14 cells_after_filter=9 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=7.0 json_ms=0.0 total_ms=7.0
2026-05-04T21:56:15.343655Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.0 json_ms=0.1 total_ms=2.1
2026-05-04T21:56:15.356716Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=99 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.2
2026-05-04T21:56:21.467200Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.5 json_ms=0.1 total_ms=1.5
2026-05-04T21:56:21.805660Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.0 json_ms=0.1 total_ms=1.0
2026-05-04T21:56:40.924937Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=32 cells_after_filter=30 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.7 json_ms=0.1 total_ms=1.7
2026-05-04T21:56:41.209756Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=76 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.6
2026-05-04T21:56:52.609815Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=79996 parallel=true cells_before_filter=14 cells_after_filter=12 truncated=false bounds=51.4966,-0.1310,51.5308,-0.0491 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.9 json_ms=0.0 total_ms=1.9
2026-05-04T21:56:53.571886Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=72082 parallel=true cells_before_filter=7 cells_after_filter=7 truncated=false bounds=51.5001,-0.1244,51.5284,-0.0568 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=0.8 json_ms=0.0 total_ms=0.9
2026-05-04T21:56:53.894537Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=72082 filters=5 travel=1 total=11 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=1.3
2026-05-04T21:56:54.926593Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:56:54.926607Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:56:55.104320Z 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.9 json_ms=0.2 total_ms=2.1
2026-05-04T21:56:55.889693Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:56:55.889752Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:56:55.905455Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:56:55.905628Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:56:55.914181Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:56:55.920824Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:56:58.704315Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:56:58.704320Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:56:58.872821Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=72082 parallel=true cells_before_filter=7 cells_after_filter=7 truncated=false bounds=51.5001,-0.1244,51.5284,-0.0568 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=0.8 json_ms=0.0 total_ms=0.8
2026-05-04T21:56:59.121385Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=72082 filters=5 travel=1 total=11 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=1.0
2026-05-04T21:57:56.775802Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:57:56.777300Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:57:56.885572Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:57:56.886980Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:57:56.898376Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:57:56.898392Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:57:56.903289Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:57:56.905401Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:57:56.946078Z 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.3 json_ms=0.2 total_ms=1.5
2026-05-04T21:58:10.729026Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:58:12.148740Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:58:12.151290Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:58:12.523672Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=2.2 json_ms=0.3 total_ms=2.5
2026-05-04T21:58:27.003322Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.7
2026-05-04T21:58:27.003894Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.4
2026-05-04T21:58:27.004089Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=14 cells_after_filter=9 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.1 json_ms=0.0 total_ms=1.1
2026-05-04T21:58:27.008726Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=152340 filters=5 travel=1 total=25 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=3.4
2026-05-04T21:58:27.069148Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:58:27.070840Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:58:27.089231Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:58:27.089647Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:58:27.249044Z 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=2.3 json_ms=0.3 total_ms=2.6
2026-05-04T21:58:27.325395Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.5 json_ms=0.0 total_ms=1.6
2026-05-04T21:58:27.580214Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=99 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.9
2026-05-04T21:58:27.888907Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:58:27.892491Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:58:27.900029Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:58:27.901909Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:58:27.903069Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:58:27.906552Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:58:33.559096Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.5 json_ms=0.1 total_ms=2.5
2026-05-04T21:58:35.972273Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.0 json_ms=0.1 total_ms=2.0
2026-05-04T21:58:47.839690Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=32 cells_after_filter=30 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.0 json_ms=0.0 total_ms=1.1
2026-05-04T21:58:48.120664Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=76 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.2
2026-05-04T21:58:56.219997Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=72082 parallel=true cells_before_filter=7 cells_after_filter=7 truncated=false bounds=51.4984,-0.1275,51.5295,-0.0532 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.8 json_ms=0.0 total_ms=2.8
2026-05-04T21:58:56.518264Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=72082 parallel=true cells_before_filter=7 cells_after_filter=7 truncated=false bounds=51.5001,-0.1244,51.5284,-0.0568 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.7 json_ms=0.0 total_ms=1.7
2026-05-04T21:59:00.346359Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=72082 filters=5 travel=1 total=11 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=1.4
2026-05-04T21:59:54.147033Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:59:54.148019Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:59:54.166292Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:59:54.168495Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:59:54.181021Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:59:54.184809Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:59:54.194227Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T21:59:54.197244Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T21:59:54.585195Z 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.2
2026-05-04T22:00:14.301897Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:00:15.630685Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:00:15.630884Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:00:15.964464Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=2.0 json_ms=0.3 total_ms=2.3
2026-05-04T22:00:24.946312Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:00:24.946320Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:00:24.946412Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:00:24.952388Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:00:30.022362Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.2
2026-05-04T22:00:30.043112Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.9
2026-05-04T22:00:30.261451Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.3 json_ms=0.1 total_ms=1.4
2026-05-04T22:00:30.592129Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=99 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.8
2026-05-04T22:00:37.007421Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.4 json_ms=0.0 total_ms=1.4
2026-05-04T22:00:37.462647Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.7 json_ms=0.1 total_ms=2.8
2026-05-04T22:00:45.840231Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=32 cells_after_filter=30 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.7 json_ms=0.0 total_ms=3.8
2026-05-04T22:00:46.316882Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=76 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=3.0
2026-05-04T22:00:57.236646Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=72082 parallel=true cells_before_filter=7 cells_after_filter=7 truncated=false bounds=51.5001,-0.1244,51.5284,-0.0568 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.6 json_ms=0.0 total_ms=1.7
2026-05-04T22:00:57.427349Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=72082 filters=5 travel=1 total=11 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=1.5
2026-05-04T22:02:03.462288Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:02:04.824984Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:02:04.826419Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:02:05.162248Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=3.7 json_ms=0.3 total_ms=4.1
2026-05-04T22:02:17.722889Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.7
2026-05-04T22:02:17.740905Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.0
2026-05-04T22:02:17.967131Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.5 json_ms=0.1 total_ms=1.6
2026-05-04T22:02:18.518303Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=99 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.2
2026-05-04T22:02:21.675085Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:02:21.675453Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:02:21.680135Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:02:21.680216Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:02:21.853343Z 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.5 json_ms=0.2 total_ms=1.7
2026-05-04T22:02:21.861053Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.3 json_ms=0.1 total_ms=1.4
2026-05-04T22:02:21.882614Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:02:21.884775Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:02:21.896884Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:02:21.896945Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:02:21.913545Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:02:21.920368Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:02:22.344366Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=99 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.9
2026-05-04T22:02:23.330984Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.3 json_ms=0.1 total_ms=3.4
2026-05-04T22:02:23.556756Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.8 json_ms=0.0 total_ms=1.8
2026-05-04T22:02:29.980499Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=32 cells_after_filter=30 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.8 json_ms=0.1 total_ms=1.8
2026-05-04T22:02:29.985702Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=76 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.9
2026-05-04T22:02:38.177070Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=72082 parallel=true cells_before_filter=7 cells_after_filter=7 truncated=false bounds=51.5001,-0.1244,51.5284,-0.0568 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.5 json_ms=0.0 total_ms=1.5
2026-05-04T22:02:38.177246Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=72082 filters=5 travel=1 total=11 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=1.6
2026-05-04T22:02:51.694677Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:02:51.694701Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:02:51.873538Z 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=2.2 json_ms=0.3 total_ms=2.4
2026-05-04T22:02:51.887968Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:02:51.889611Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:02:51.893970Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:02:51.897409Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:02:51.904091Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:02:51.904150Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:05:40.782606Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:05:40.784162Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:05:41.884454Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:05:41.885815Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:06:47.676019Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:06:48.950663Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:06:48.952417Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:06:49.286097Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=1.6 json_ms=0.3 total_ms=1.8
2026-05-04T22:07:01.856777Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.9
2026-05-04T22:07:01.875908Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.9
2026-05-04T22:07:02.096680Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.1 json_ms=0.1 total_ms=1.2
2026-05-04T22:07:02.387635Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=99 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.5
2026-05-04T22:07:07.234158Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.3 json_ms=0.1 total_ms=1.4
2026-05-04T22:07:07.780854Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.0 json_ms=0.1 total_ms=1.0
2026-05-04T22:07:09.771342Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:07:11.430730Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:07:11.432677Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:07:11.870399Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=5.2 json_ms=0.4 total_ms=5.6
2026-05-04T22:07:18.738335Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=32 cells_after_filter=30 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.6 json_ms=0.0 total_ms=1.6
2026-05-04T22:07:18.758383Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=76 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=3.2
2026-05-04T22:07:24.564862Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.0
2026-05-04T22:07:24.584804Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.5
2026-05-04T22:07:25.434415Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.6 json_ms=0.0 total_ms=1.7
2026-05-04T22:07:25.435484Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=99 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.8
2026-05-04T22:07:30.016548Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.4 json_ms=0.1 total_ms=2.5
2026-05-04T22:07:30.166842Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.9 json_ms=0.1 total_ms=2.0
2026-05-04T22:07:43.696232Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=76 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.8
2026-05-04T22:07:43.696849Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=32 cells_after_filter=30 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.5 json_ms=0.1 total_ms=3.5
2026-05-04T22:09:15.053435Z 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.9 json_ms=0.2 total_ms=1.0
2026-05-04T22:09:15.986390Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:09:15.986629Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:09:16.282846Z 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.9 json_ms=0.2 total_ms=1.1
2026-05-04T22:09:16.985235Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89195da49c3ffff resolution=9 total_count=238 filters=0 filters_raw="-" ms=0.2
2026-05-04T22:09:17.396636Z 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.9 json_ms=0.1 total_ms=1.0
2026-05-04T22:09:20.390663Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:09:21.638150Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:09:21.639834Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:09:21.968978Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=4.2 json_ms=0.4 total_ms=4.6
2026-05-04T22:09:34.546910Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.5
2026-05-04T22:09:34.564980Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.4
2026-05-04T22:09:34.582175Z INFO property_map_server::routes::hexagon_stats: GET /api/hexagon-stats h3=89194ad3257ffff resolution=9 total_count=87 filters=0 filters_raw="-" ms=0.2
2026-05-04T22:09:34.796975Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.0 json_ms=0.1 total_ms=1.0
2026-05-04T22:09:35.096462Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=99 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.8
2026-05-04T22:09:35.791825Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=503301 parallel=true cells_before_filter=1899 cells_after_filter=1773 truncated=false bounds=51.4632,-0.2509,51.5723,-0.0624 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=5.4 json_ms=1.2 total_ms=6.6
2026-05-04T22:09:39.578502Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=3047 parallel=false cells_before_filter=155 cells_after_filter=134 truncated=false bounds=51.0079,-1.2718,51.0856,-1.0894 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=0.2 json_ms=0.1 total_ms=0.3
2026-05-04T22:09:39.932966Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.4 json_ms=0.1 total_ms=1.5
2026-05-04T22:09:40.921779Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.5 json_ms=0.0 total_ms=1.6
2026-05-04T22:09:48.386770Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=32 cells_after_filter=30 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.7 json_ms=0.0 total_ms=1.7
2026-05-04T22:09:49.833150Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=76 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=3.0
2026-05-04T22:09:59.625699Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:09:59.625804Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:10:18.276194Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:10:18.277375Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:10:50.331384Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:10:51.624480Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:10:51.624857Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:10:51.972591Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=1.7 json_ms=0.2 total_ms=1.9
2026-05-04T22:12:49.115326Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:12:59.972207Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:13:01.286073Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:13:01.287792Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:13:01.639675Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=2.0 json_ms=0.4 total_ms=2.4
2026-05-04T22:13:14.259373Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.4
2026-05-04T22:13:14.279347Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.0
2026-05-04T22:13:14.524168Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.2 json_ms=0.1 total_ms=4.3
2026-05-04T22:13:14.856309Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=99 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=3.4
2026-05-04T22:13:32.500450Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:13:45.469478Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:13:58.113964Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:14:20.006022Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:15:16.034638Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:15:16.034655Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:15:16.049735Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:15:16.052310Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:15:16.067959Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:15:16.067966Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:15:23.544443Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:15:23.545908Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:15:24.793212Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:15:24.793390Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:15:38.499700Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:15:38.499937Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:15:38.888798Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:15:38.890273Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:15:38.901972Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:15:38.901980Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:15:43.175411Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:15:43.175417Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:15:52.910078Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:15:54.157456Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:15:54.157703Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:15:54.558417Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=2.3 json_ms=0.3 total_ms=2.6
2026-05-04T22:16:17.924667Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:16:23.301799Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:16:24.620993Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:16:24.622323Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:16:25.132503Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=2.2 json_ms=0.4 total_ms=2.6
2026-05-04T22:16:30.018214Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:16:30.021061Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:16:30.044102Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:16:30.045412Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:16:30.124918Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:16:30.126318Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:16:30.126371Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:16:30.130716Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:16:30.190264Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=2.2 json_ms=0.3 total_ms=2.5
2026-05-04T22:16:35.175651Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:16:35.790396Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:16:36.550571Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:16:36.550572Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:16:36.953085Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=5.3 json_ms=0.4 total_ms=5.7
2026-05-04T22:16:37.297770Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:16:37.297798Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:16:37.766664Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=10.7 json_ms=0.4 total_ms=11.1
2026-05-04T22:16:50.090443Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:16:50.714483Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.5
2026-05-04T22:16:50.737277Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.8
2026-05-04T22:16:50.988254Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.8 json_ms=0.0 total_ms=1.9
2026-05-04T22:16:51.380753Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=99 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.4
2026-05-04T22:16:51.728458Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:16:51.730984Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:16:52.544870Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:16:52.545173Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:16:53.170483Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=2.8 json_ms=0.3 total_ms=3.1
2026-05-04T22:16:56.117207Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.8 json_ms=0.1 total_ms=1.9
2026-05-04T22:16:58.078086Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.0 json_ms=0.1 total_ms=3.0
2026-05-04T22:17:05.602271Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.5
2026-05-04T22:17:05.627414Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.1
2026-05-04T22:17:06.887654Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.0 json_ms=0.1 total_ms=2.1
2026-05-04T22:17:06.891207Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=99 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=3.2
2026-05-04T22:17:11.374500Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=0.9 json_ms=0.1 total_ms=0.9
2026-05-04T22:17:11.787823Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.9 json_ms=0.1 total_ms=2.0
2026-05-04T22:17:12.104759Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=32 cells_after_filter=30 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.4 json_ms=0.1 total_ms=3.5
2026-05-04T22:17:12.614981Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=76 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=3.1
2026-05-04T22:17:16.298914Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:17:16.299362Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:17:16.652247Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:17:16.654687Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:17:16.887024Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:17:16.888739Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:17:16.895319Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:17:16.895348Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:17:16.937631Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.6 json_ms=0.1 total_ms=2.7
2026-05-04T22:17:17.394683Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.0 json_ms=0.1 total_ms=4.1
2026-05-04T22:17:17.395093Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=99 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=4.2
2026-05-04T22:17:18.394512Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:17:18.398180Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:17:18.736343Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=32 cells_after_filter=30 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.3 json_ms=0.1 total_ms=3.3
2026-05-04T22:17:19.156516Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=76 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=3.0
2026-05-04T22:17:20.079176Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=79996 parallel=true cells_before_filter=14 cells_after_filter=13 truncated=false bounds=51.4958,-0.1316,51.5310,-0.0473 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.1 json_ms=0.0 total_ms=1.1
2026-05-04T22:17:20.318654Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=79996 filters=5 travel=1 total=37 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=1.8
2026-05-04T22:17:21.165131Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=32 cells_after_filter=30 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.4 json_ms=0.1 total_ms=4.4
2026-05-04T22:17:22.568170Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=76 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=3.0
2026-05-04T22:17:23.643832Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:17:23.645798Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:17:23.676422Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:17:23.676427Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:17:23.883967Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:17:23.890107Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:17:23.901175Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:17:23.904281Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:17:26.512955Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=32 cells_after_filter=30 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.0 json_ms=0.0 total_ms=1.0
2026-05-04T22:17:26.514608Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=76 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.9
2026-05-04T22:17:28.082381Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:17:28.082407Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:17:28.591764Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=79996 parallel=true cells_before_filter=14 cells_after_filter=13 truncated=false bounds=51.4958,-0.1316,51.5310,-0.0473 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.0 json_ms=0.0 total_ms=1.1
2026-05-04T22:17:28.592307Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=79996 filters=5 travel=1 total=37 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=1.6
2026-05-04T22:17:30.502763Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=79996 parallel=true cells_before_filter=14 cells_after_filter=13 truncated=false bounds=51.4958,-0.1316,51.5310,-0.0473 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.0 json_ms=0.0 total_ms=1.0
2026-05-04T22:17:30.730121Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=79996 filters=5 travel=1 total=37 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=1.8
2026-05-04T22:17:56.113083Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:17:57.975715Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:17:57.975718Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:17:58.431515Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=1.6 json_ms=0.3 total_ms=1.9
2026-05-04T22:18:01.100097Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:18:01.100139Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:18:01.147582Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:18:01.147587Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:18:01.157499Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:18:01.158423Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:18:01.161998Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:18:01.164292Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:18:01.388520Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=1.7 json_ms=0.3 total_ms=2.0
2026-05-04T22:18:11.402161Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:18:12.712627Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:18:12.712664Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:18:13.073370Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=1.9 json_ms=0.4 total_ms=2.3
2026-05-04T22:18:30.994562Z INFO property_map_server: Prometheus metrics initialized
2026-05-04T22:18:30.994761Z INFO property_map_server: Loading property data from /app/data/properties.parquet, /app/data/postcode.parquet
2026-05-04T22:18:30.994774Z INFO property_map_server::data::property: Loading postcode features from "/app/data/postcode.parquet"
2026-05-04T22:18:31.122555Z INFO property_map_server::data::property: Postcode features loaded rows=1263786
2026-05-04T22:18:31.122565Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-05-04T22:18:33.611506Z INFO property_map_server::data::property: Properties joined with postcodes rows=15268176
2026-05-04T22:18:33.611537Z INFO property_map_server::data::property: Feature columns from config numeric=63 enums=6 total=69
2026-05-04T22:18:36.538024Z WARN property_map_server::data::property: Dropped properties with missing postcode coordinates rows=743076
2026-05-04T22:18:36.538033Z INFO property_map_server::data::property: Combined data selected rows=14525100
2026-05-04T22:18:36.678044Z INFO property_map_server::data::property: Extracting numeric feature columns
2026-05-04T22:18:37.038480Z INFO property_map_server::data::property: Computing histograms for numeric features
2026-05-04T22:18:38.526523Z INFO property_map_server::data::property: Extracting string columns
2026-05-04T22:18:39.908807Z INFO property_map_server::data::property: Building enum features
2026-05-04T22:18:41.719020Z INFO property_map_server::data::property: Extracting renovation history
2026-05-04T22:18:43.770345Z INFO property_map_server::data::property: Renovation history extracted properties_with_events=1741939
2026-05-04T22:18:43.770354Z INFO property_map_server::data::property: Sorting rows by spatial locality
2026-05-04T22:18:44.674520Z INFO property_map_server::data::property: Building interned strings
2026-05-04T22:18:50.148935Z INFO property_map_server::data::property: Transposing to row-major layout (spatially sorted, quantized to u16)
2026-05-04T22:18:54.062453Z INFO property_map_server::data::property: Data loading complete
2026-05-04T22:18:55.930791Z INFO property_map_server: Allocator trim label="property data load" trimmed=true rss_before_mib=11749.0 rss_after_mib=3312.9 released_mib=8436.0
2026-05-04T22:18:55.930802Z INFO property_map_server: Property data loaded rows=14525100 features=69 enums=6
2026-05-04T22:18:55.930805Z INFO property_map_server: Building spatial grid index (0.01° cells)
2026-05-04T22:18:56.051230Z INFO property_map_server: Precomputing H3 cells at resolution 12
2026-05-04T22:18:56.051241Z INFO property_map_server::data::property: Precomputing H3 cells at resolution 12
2026-05-04T22:18:56.475680Z INFO property_map_server::data::property: H3 precomputation complete (14525100 cells)
2026-05-04T22:18:56.475701Z INFO property_map_server: Loading POI data from /app/data/filtered_uk_pois.parquet
2026-05-04T22:18:56.475707Z INFO property_map_server::data::poi: Loading POI data from "/app/data/filtered_uk_pois.parquet"...
2026-05-04T22:18:56.506199Z INFO property_map_server::data::poi: Loaded 567534 POIs
2026-05-04T22:18:56.662262Z INFO property_map_server::data::poi: POI string columns interned category_unique=94 group_unique=11 emoji_unique=71
2026-05-04T22:18:56.663650Z INFO property_map_server::data::poi: POI data loading complete.
2026-05-04T22:18:56.710693Z INFO property_map_server: Allocator trim label="poi data load" trimmed=true rss_before_mib=3717.6 rss_after_mib=3530.6 released_mib=187.0
2026-05-04T22:18:56.710705Z INFO property_map_server: POI data loaded pois=567534
2026-05-04T22:18:56.710707Z INFO property_map_server: Building POI spatial grid index
2026-05-04T22:18:56.718782Z INFO property_map_server: Loading place data from /app/data/places.parquet
2026-05-04T22:18:56.718799Z INFO property_map_server::data::places: Loading place data from "/app/data/places.parquet"...
2026-05-04T22:18:56.720493Z INFO property_map_server::data::places: Loaded 3474 places
2026-05-04T22:18:56.721814Z INFO property_map_server::data::places: Place data loaded places=3474 types=2 with_population=71 with_city=3392
2026-05-04T22:18:56.725737Z INFO property_map_server: Allocator trim label="place data load" trimmed=true rss_before_mib=3539.8 rss_after_mib=3535.4 released_mib=4.4
2026-05-04T22:18:56.725748Z INFO property_map_server: Place data loaded places=3474
2026-05-04T22:18:56.725757Z INFO property_map_server: Loading postcode boundaries from /app/data/postcode_boundaries
2026-05-04T22:18:56.725762Z INFO property_map_server::data::postcodes: Loading postcode boundaries from "/app/data/postcode_boundaries"
2026-05-04T22:18:56.727448Z INFO property_map_server::data::postcodes: Found GeoJSON files to process files=2361
2026-05-04T22:19:05.022680Z INFO property_map_server::data::postcodes: Postcode boundary data ready postcodes=1490140
2026-05-04T22:19:05.421317Z INFO property_map_server: Allocator trim label="postcode boundary load" trimmed=true rss_before_mib=10855.9 rss_after_mib=10659.3 released_mib=196.5
2026-05-04T22:19:05.421335Z INFO property_map_server: Postcode boundaries loaded postcodes=1490140
2026-05-04T22:19:05.580884Z INFO property_map_server::data::postcodes: Outcode data derived from postcodes outcodes=2361
2026-05-04T22:19:05.580958Z INFO property_map_server: Loading PMTiles from /app/data/uk.pmtiles
2026-05-04T22:19:05.581190Z INFO property_map_server: PMTiles loaded successfully
2026-05-04T22:19:05.620437Z INFO property_map_server: No --dist provided; static serving disabled
2026-05-04T22:19:05.653852Z INFO property_map_server: Screenshot service configured: http://screenshot:8002
2026-05-04T22:19:05.654023Z INFO property_map_server: Precomputed features response groups=8
2026-05-04T22:19:05.654040Z INFO property_map_server: PocketBase configured: http://pocketbase:8090
2026-05-04T22:19:05.712851Z INFO property_map_server::pocketbase: PocketBase users collection already has all required fields
2026-05-04T22:19:05.727511Z INFO property_map_server::pocketbase: PocketBase collection 'saved_searches' API rules updated
2026-05-04T22:19:05.731610Z INFO property_map_server::pocketbase: PocketBase collection 'saved_properties' API rules updated
2026-05-04T22:19:07.308379Z INFO property_map_server::pocketbase: PocketBase meta.appURL set to https://perfect-postcodes.co.uk/pb
2026-05-04T22:19:07.315235Z INFO property_map_server::pocketbase: PocketBase OAuth configured on users collection
2026-05-04T22:19:07.315281Z INFO property_map_server: Gemini configured (model: gemini-3-flash-preview)
2026-05-04T22:19:07.315297Z INFO property_map_server: Loading travel time data from /app/data/travel-times
2026-05-04T22:19:07.317153Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="bicycle" destinations=2753
2026-05-04T22:19:07.318775Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="walking" destinations=2753
2026-05-04T22:19:07.320369Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="car" destinations=2753
2026-05-04T22:19:07.321670Z INFO property_map_server::data::travel_time: Travel time mode discovered mode="transit" destinations=2752
2026-05-04T22:19:07.321691Z INFO property_map_server: Travel time store loaded modes=4
2026-05-04T22:19:07.321753Z INFO property_map_server: Precomputed AI filters system prompt
2026-05-04T22:19:10.359858Z INFO property_map_server: All memory pages locked (mlockall)
2026-05-04T22:19:10.359896Z INFO property_map_server: Server listening on 0.0.0.0:8001
2026-05-04T22:19:10.362406Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:19:11.282862Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:19:11.998512Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:19:12.001101Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:19:12.651633Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:19:12.653579Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:19:12.847710Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=10.2 json_ms=0.3 total_ms=10.5
2026-05-04T22:19:13.876585Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:19:13.876992Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:19:14.398559Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=8.1 json_ms=0.4 total_ms=8.5
2026-05-04T22:19:14.829985Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:19:14.832130Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:19:17.878833Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:19:17.885215Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:19:17.885219Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:19:17.885248Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:19:27.189284Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.6
2026-05-04T22:19:27.207964Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.2
2026-05-04T22:19:27.673388Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=226.0 agg_ms=2.4 json_ms=0.1 total_ms=228.5
2026-05-04T22:19:27.698162Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=99 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.9
2026-05-04T22:19:27.838540Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.7
2026-05-04T22:19:27.860257Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.2
2026-05-04T22:19:28.165307Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.9 json_ms=0.1 total_ms=3.0
2026-05-04T22:19:28.463880Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=99 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.9
2026-05-04T22:19:32.718769Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.4 json_ms=0.0 total_ms=2.4
2026-05-04T22:19:32.890348Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.5 json_ms=0.1 total_ms=3.6
2026-05-04T22:19:33.342435Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.2 json_ms=0.1 total_ms=2.3
2026-05-04T22:19:33.562465Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.8 json_ms=0.1 total_ms=3.8
2026-05-04T22:19:47.613869Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=76 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=3.7
2026-05-04T22:19:47.615091Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=32 cells_after_filter=30 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=4.7 json_ms=0.0 total_ms=4.8
2026-05-04T22:19:49.409784Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=32 cells_after_filter=30 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.8 json_ms=0.0 total_ms=3.8
2026-05-04T22:19:49.410138Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=76 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=4.6
2026-05-04T22:19:56.769120Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=79996 parallel=true cells_before_filter=14 cells_after_filter=13 truncated=false bounds=51.4958,-0.1316,51.5310,-0.0473 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.7 json_ms=0.0 total_ms=3.8
2026-05-04T22:19:56.969498Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=79996 filters=5 travel=1 total=37 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.1
2026-05-04T22:19:57.508622Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=79996 parallel=true cells_before_filter=14 cells_after_filter=13 truncated=false bounds=51.4958,-0.1316,51.5310,-0.0473 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.8 json_ms=0.0 total_ms=2.8
2026-05-04T22:19:57.852562Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=79996 filters=5 travel=1 total=37 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.9
2026-05-04T22:20:54.025076Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:20:56.324428Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:20:56.324721Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:20:58.125348Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:20:58.130229Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:20:58.561997Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=3.4 json_ms=0.5 total_ms=3.9
2026-05-04T22:21:11.221943Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.6
2026-05-04T22:21:11.242460Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.6
2026-05-04T22:21:11.465642Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.7 json_ms=0.0 total_ms=1.8
2026-05-04T22:21:11.869894Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=99 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=3.1
2026-05-04T22:21:13.728615Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:21:13.728630Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:21:13.735932Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:21:13.738239Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:21:13.901248Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:21:13.902849Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:21:13.918576Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=6.7 json_ms=0.1 total_ms=6.7
2026-05-04T22:21:13.932881Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:21:13.932890Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:21:14.491621Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=99 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.8
2026-05-04T22:21:26.371953Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:21:26.371987Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:21:26.887586Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:21:26.889234Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:21:26.899961Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:21:26.904381Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:21:27.652641Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:21:27.652644Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:21:27.822545Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.0 json_ms=0.0 total_ms=1.0
2026-05-04T22:21:28.074620Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=99 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=3.6
2026-05-04T22:22:29.069537Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:22:29.071715Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:22:29.075423Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:22:29.075586Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:22:29.106537Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:22:29.106764Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:22:36.081084Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:22:36.082306Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:22:51.954249Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:22:51.954310Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:24:05.699059Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:24:07.592008Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:24:07.592432Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:24:08.414940Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=4.4 json_ms=0.4 total_ms=4.9
2026-05-04T22:24:20.861836Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.2
2026-05-04T22:24:20.881879Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.7
2026-05-04T22:24:21.122125Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=3.0 json_ms=0.1 total_ms=3.0
2026-05-04T22:24:21.381382Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=99 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=3.2
2026-05-04T22:24:26.240753Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.1 json_ms=0.0 total_ms=1.1
2026-05-04T22:24:26.464684Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.4 json_ms=0.1 total_ms=1.4
2026-05-04T22:24:27.540146Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:24:27.581298Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:24:36.826348Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=32 cells_after_filter=30 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.3 json_ms=0.0 total_ms=1.4
2026-05-04T22:24:36.827918Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=76 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=3.0
2026-05-04T22:24:39.974077Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:24:39.975731Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:24:44.627129Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=79996 parallel=true cells_before_filter=14 cells_after_filter=13 truncated=false bounds=51.4958,-0.1316,51.5310,-0.0473 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=6.1 json_ms=0.0 total_ms=6.1
2026-05-04T22:24:49.063892Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=79996 filters=5 travel=1 total=37 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=1.9
2026-05-04T22:24:52.794208Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:24:52.795748Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:25:01.609360Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:25:01.611052Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:25:02.161215Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:25:02.170041Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:26:43.296479Z 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.9 json_ms=0.2 total_ms=1.2
2026-05-04T22:26:45.570271Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=64311 parallel=true cells_before_filter=249 cells_after_filter=231 truncated=false bounds=51.4958,-0.1636,51.5342,-0.0964 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=0.9 json_ms=0.1 total_ms=1.0
2026-05-04T22:26:46.266290Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:26:46.267461Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:26:46.678793Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=64311 parallel=true cells_before_filter=249 cells_after_filter=231 truncated=false bounds=51.4958,-0.1636,51.5342,-0.0964 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-04T22:27:31.950199Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:27:31.953653Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:27:32.743995Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:27:32.745372Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:27:36.705103Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:27:36.706272Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:27:40.742291Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:27:42.048355Z INFO property_map_server::routes::features: GET /api/features
2026-05-04T22:27:42.049491Z INFO property_map_server::routes::pois: GET /api/poi-categories count=94 groups=11
2026-05-04T22:27:42.388603Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=152340 parallel=true cells_before_filter=537 cells_after_filter=412 truncated=false bounds=51.4943,-0.1794,51.5357,-0.0806 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=3.7 json_ms=0.4 total_ms=4.1
2026-05-04T22:27:48.759093Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=3047 parallel=false cells_before_filter=155 cells_after_filter=134 truncated=false bounds=51.0079,-1.2718,51.0856,-1.0894 filters=0 filters_raw="-" fields=0 travel_entries=0 grid_ms=0.0 agg_ms=0.1 json_ms=0.1 total_ms=0.2
2026-05-04T22:27:54.979353Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=1.4
2026-05-04T22:27:54.998605Z INFO property_map_server::routes::travel_destinations: GET /api/travel-destinations mode="transit" results=2752 ms=2.0
2026-05-04T22:27:55.235313Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.4 json_ms=0.0 total_ms=2.5
2026-05-04T22:27:55.543332Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=99 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=3.0
2026-05-04T22:28:00.358684Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.0 json_ms=0.1 total_ms=2.1
2026-05-04T22:28:02.432112Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=41 cells_after_filter=32 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.5 json_ms=0.0 total_ms=1.5
2026-05-04T22:28:11.242530Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=122395 parallel=true cells_before_filter=32 cells_after_filter=30 truncated=false bounds=51.4924,-0.1388,51.5338,-0.0399 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=2.1 json_ms=0.0 total_ms=2.2
2026-05-04T22:28:11.243381Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=122395 filters=5 travel=1 total=76 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=2.9
2026-05-04T22:28:19.048500Z INFO property_map_server::routes::hexagons: GET /api/hexagons resolution=9 rows=79996 parallel=true cells_before_filter=14 cells_after_filter=13 truncated=false bounds=51.4958,-0.1316,51.5310,-0.0473 filters=4 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" fields=0 travel_entries=1 grid_ms=0.0 agg_ms=1.8 json_ms=0.0 total_ms=1.9
2026-05-04T22:28:19.274325Z INFO property_map_server::routes::filter_counts: GET /api/filter-counts rows=79996 filters=5 travel=1 total=37 filters_raw="Property type:Terraced;;Estimated current price:350000:450000;;Serious crime per 1k residents (avg/yr):0:30;;Noise (dB):50:60" ms=1.5

View file

@ -4,7 +4,7 @@ mod postcodes;
mod property; mod property;
pub mod travel_time; pub mod travel_time;
pub use places::PlaceData; pub use places::{normalize_search_text, PlaceData};
pub use poi::{POICategoryGroup, POIData}; pub use poi::{POICategoryGroup, POIData};
pub use postcodes::{OutcodeData, PostcodeData}; pub use postcodes::{OutcodeData, PostcodeData};
pub use property::{ pub use property::{

View file

@ -11,22 +11,127 @@ use crate::utils::InternedColumn;
pub struct PlaceData { pub struct PlaceData {
pub name: Vec<String>, pub name: Vec<String>,
pub name_lower: Vec<String>, pub name_lower: Vec<String>,
pub name_search: Vec<String>,
pub place_type: InternedColumn, pub place_type: InternedColumn,
pub type_rank: Vec<u8>, pub type_rank: Vec<u8>,
pub population: Vec<u32>, pub population: Vec<u32>,
pub lat: Vec<f32>, pub lat: Vec<f32>,
pub lon: Vec<f32>, pub lon: Vec<f32>,
pub city: Vec<Option<String>>, pub city: Vec<Option<String>>,
pub travel_destination: Vec<bool>,
} }
fn type_rank(place_type: &str) -> u8 { fn type_rank(place_type: &str) -> u8 {
match place_type { match place_type {
"city" => 0, "city" => 0,
"station" => 1, "town" => 1,
_ => 2, "village" => 2,
"suburb" | "neighbourhood" | "quarter" | "borough" | "locality" => 3,
"station" => 4,
"hamlet" | "isolated_dwelling" | "island" => 5,
_ => 6,
} }
} }
pub fn is_travel_destination_type(place_type: &str) -> bool {
matches!(place_type, "city" | "station")
}
pub fn normalize_search_text(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut last_was_space = true;
for ch in text.chars() {
if ch == '\'' || ch == '' || ch == '`' {
continue;
}
let lower = ch.to_ascii_lowercase();
if lower.is_ascii_alphanumeric() {
result.push(lower);
last_was_space = false;
} else if !last_was_space {
result.push(' ');
last_was_space = true;
}
}
if result.ends_with(' ') {
result.pop();
}
result
}
fn replace_token(text: &str, from: &str, to: &str) -> Option<String> {
let mut changed = false;
let replaced: Vec<&str> = text
.split_whitespace()
.map(|token| {
if token == from {
changed = true;
to
} else {
token
}
})
.collect();
changed.then(|| replaced.join(" "))
}
fn push_alias(aliases: &mut Vec<String>, alias: String) {
if !alias.is_empty() && !aliases.iter().any(|existing| existing == &alias) {
aliases.push(alias);
}
}
fn build_search_text(name: &str, place_type: &str) -> String {
let primary = normalize_search_text(name);
let mut aliases = vec![primary.clone()];
if let Some(alias) = replace_token(&primary, "st", "saint") {
push_alias(&mut aliases, alias);
}
if let Some(alias) = replace_token(&primary, "saint", "st") {
push_alias(&mut aliases, alias);
}
if place_type == "station" {
let suffix_aliases: [(&str, &[&str]); 5] = [
(
" tube station",
&[" underground station", " station", " tube", " underground"],
),
(
" underground station",
&[" tube station", " station", " tube", " underground"],
),
(
" railway station",
&[" rail station", " station", " railway", " rail"],
),
(
" overground station",
&[" station", " overground", " railway station"],
),
(
" elizabeth line station",
&[" station", " elizabeth line", " crossrail station"],
),
];
for (suffix, replacements) in suffix_aliases {
if let Some(stem) = primary.strip_suffix(suffix) {
for replacement in replacements {
push_alias(&mut aliases, format!("{stem}{replacement}"));
}
}
}
}
aliases.join(" | ")
}
fn extract_str_col(df: &DataFrame, name: &str) -> anyhow::Result<Vec<String>> { fn extract_str_col(df: &DataFrame, name: &str) -> anyhow::Result<Vec<String>> {
let column = df let column = df
.column(name) .column(name)
@ -56,6 +161,23 @@ fn extract_f32_col(df: &DataFrame, name: &str) -> anyhow::Result<Vec<f32>> {
.collect()) .collect())
} }
fn extract_bool_col_or_default(
df: &DataFrame,
name: &str,
default_value: bool,
) -> anyhow::Result<Vec<bool>> {
let Ok(column) = df.column(name) else {
return Ok(vec![default_value; df.height()]);
};
let bool_column = column
.bool()
.with_context(|| format!("Column '{name}' is not a boolean column"))?;
Ok(bool_column
.into_iter()
.map(|value| value.unwrap_or(default_value))
.collect())
}
impl PlaceData { impl PlaceData {
pub fn load(parquet_path: &Path) -> anyhow::Result<Self> { pub fn load(parquet_path: &Path) -> anyhow::Result<Self> {
info!("Loading place data from {:?}...", parquet_path); info!("Loading place data from {:?}...", parquet_path);
@ -80,8 +202,21 @@ impl PlaceData {
}; };
let name_lower: Vec<String> = name.iter().map(|nm| nm.to_lowercase()).collect(); let name_lower: Vec<String> = name.iter().map(|nm| nm.to_lowercase()).collect();
let name_search: Vec<String> = name
.iter()
.zip(&place_type_raw)
.map(|(nm, pt)| build_search_text(nm, pt))
.collect();
let type_rank_vec: Vec<u8> = place_type_raw.iter().map(|pt| type_rank(pt)).collect(); let type_rank_vec: Vec<u8> = place_type_raw.iter().map(|pt| type_rank(pt)).collect();
let place_type = InternedColumn::build(&place_type_raw); let place_type = InternedColumn::build(&place_type_raw);
let travel_destination = if df.column("travel_destination").is_ok() {
extract_bool_col_or_default(&df, "travel_destination", true)?
} else {
place_type_raw
.iter()
.map(|place_type| is_travel_destination_type(place_type))
.collect()
};
// Precompute nearest city for each non-city place // Precompute nearest city for each non-city place
let city_indices: Vec<usize> = type_rank_vec let city_indices: Vec<usize> = type_rank_vec
@ -133,12 +268,14 @@ impl PlaceData {
Ok(PlaceData { Ok(PlaceData {
name, name,
name_lower, name_lower,
name_search,
place_type, place_type,
type_rank: type_rank_vec, type_rank: type_rank_vec,
population, population,
lat, lat,
lon, lon,
city, city,
travel_destination,
}) })
} }
} }
@ -149,7 +286,23 @@ mod tests {
#[test] #[test]
fn type_rank_ordering() { fn type_rank_ordering() {
assert!(type_rank("city") < type_rank("station")); assert!(type_rank("city") < type_rank("town"));
assert!(type_rank("town") < type_rank("station"));
assert!(type_rank("station") < type_rank("unknown")); assert!(type_rank("station") < type_rank("unknown"));
} }
#[test]
fn search_text_handles_common_address_variants() {
assert!(build_search_text("King's Cross tube station", "station")
.contains("kings cross underground"));
assert!(build_search_text("St Albans", "city").contains("saint albans"));
}
#[test]
fn travel_destination_types_match_legacy_places() {
assert!(is_travel_destination_type("city"));
assert!(is_travel_destination_type("station"));
assert!(!is_travel_destination_type("town"));
assert!(!is_travel_destination_type("suburb"));
}
} }

View file

@ -5,11 +5,16 @@ use rayon::prelude::*;
use serde::Serialize; use serde::Serialize;
use std::path::Path; use std::path::Path;
use rustc_hash::FxHashMap; use rustc_hash::{FxHashMap, FxHashSet};
use crate::consts::{H3_PRECOMPUTE_MAX, HISTOGRAM_BINS, NAN_U16, QUANT_SCALE}; use crate::consts::{H3_PRECOMPUTE_MAX, HISTOGRAM_BINS, NAN_U16, QUANT_SCALE};
use crate::features::{self, Bounds}; use crate::features::{self, Bounds};
const ADDRESS_SEARCH_CANDIDATE_LIMIT: usize = 50_000;
const ADDRESS_SEARCH_MAX_POSTINGS_PER_TOKEN: usize = 250_000;
const ADDRESS_SEARCH_PREFIX_MIN_LEN: usize = 4;
const ADDRESS_SEARCH_PREFIX_MAX_LEN: usize = 8;
fn is_numeric_dtype(dtype: &DataType) -> bool { fn is_numeric_dtype(dtype: &DataType) -> bool {
matches!( matches!(
dtype, dtype,
@ -32,6 +37,360 @@ fn is_datetime_dtype(dtype: &DataType) -> bool {
matches!(dtype, DataType::Datetime(_, _) | DataType::Date) matches!(dtype, DataType::Datetime(_, _) | DataType::Date)
} }
#[derive(Clone, Debug)]
struct AddressTermGroup {
alternatives: Vec<String>,
}
#[derive(Debug)]
struct AddressQuery {
full_postcode: Option<String>,
text_groups: Vec<AddressTermGroup>,
numeric_terms: Vec<String>,
candidate_terms: Vec<String>,
}
fn tokenize_address_text(text: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
for ch in text.chars() {
if ch.is_ascii_alphanumeric() {
current.push(ch.to_ascii_lowercase());
} else if matches!(ch, '\'' | '' | '`') {
continue;
} else if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
}
if !current.is_empty() {
tokens.push(current);
}
tokens
}
fn is_full_postcode_compact(compact: &str) -> bool {
let bytes = compact.as_bytes();
let len = bytes.len();
if !(5..=7).contains(&len) {
return false;
}
let inward = &bytes[len - 3..];
if !inward[0].is_ascii_digit()
|| !inward[1].is_ascii_alphabetic()
|| !inward[2].is_ascii_alphabetic()
{
return false;
}
let outward = &bytes[..len - 3];
if !(2..=4).contains(&outward.len()) {
return false;
}
outward[0].is_ascii_alphabetic()
&& outward.iter().all(u8::is_ascii_alphanumeric)
&& outward.iter().any(u8::is_ascii_digit)
}
fn canonical_postcode_from_compact(compact: &str) -> String {
let upper = compact.to_ascii_uppercase();
let split = upper.len() - 3;
format!("{} {}", &upper[..split], &upper[split..])
}
fn extract_full_postcode(tokens: &[String]) -> Option<(String, Vec<usize>)> {
for (idx, token) in tokens.iter().enumerate() {
let compact = token.to_ascii_uppercase();
if is_full_postcode_compact(&compact) {
return Some((canonical_postcode_from_compact(&compact), vec![idx]));
}
}
for idx in 0..tokens.len().saturating_sub(1) {
let compact = format!(
"{}{}",
tokens[idx].to_ascii_uppercase(),
tokens[idx + 1].to_ascii_uppercase()
);
if is_full_postcode_compact(&compact) {
return Some((
canonical_postcode_from_compact(&compact),
vec![idx, idx + 1],
));
}
}
None
}
fn looks_like_postcode_fragment(token: &str) -> bool {
(2..=4).contains(&token.len())
&& token
.chars()
.next()
.is_some_and(|ch| ch.is_ascii_alphabetic())
&& token.chars().any(|ch| ch.is_ascii_digit())
&& token.chars().all(|ch| ch.is_ascii_alphanumeric())
}
fn is_numeric_address_token(token: &str) -> bool {
token.chars().all(|ch| ch.is_ascii_digit())
}
fn address_token_aliases(token: &str) -> Vec<&'static str> {
match token {
"apt" => vec!["apt", "apartment"],
"apartment" => vec!["apartment", "apt"],
"ave" => vec!["ave", "avenue"],
"avenue" => vec!["avenue", "ave"],
"blvd" => vec!["blvd", "boulevard"],
"boulevard" => vec!["boulevard", "blvd"],
"cl" => vec!["cl", "close"],
"close" => vec!["close", "cl"],
"ct" => vec!["ct", "court"],
"court" => vec!["court", "ct"],
"cres" => vec!["cres", "crescent"],
"crescent" => vec!["crescent", "cres"],
"dr" => vec!["dr", "drive"],
"drive" => vec!["drive", "dr"],
"fl" => vec!["fl", "flat"],
"flat" => vec!["flat", "fl"],
"gdns" => vec!["gdns", "gardens", "garden"],
"garden" => vec!["garden", "gardens", "gdns"],
"gardens" => vec!["gardens", "garden", "gdns"],
"hse" => vec!["hse", "house"],
"house" => vec!["house", "hse"],
"ln" => vec!["ln", "lane"],
"lane" => vec!["lane", "ln"],
"rd" => vec!["rd", "road"],
"road" => vec!["road", "rd"],
"sq" => vec!["sq", "square"],
"square" => vec!["square", "sq"],
"st" => vec!["st", "street", "saint"],
"street" => vec!["street", "st"],
"saint" => vec!["saint", "st"],
"terr" => vec!["terr", "terrace"],
"terrace" => vec!["terrace", "terr"],
_ => Vec::new(),
}
}
fn is_address_stop_token(token: &str) -> bool {
matches!(
token,
"a" | "an"
| "and"
| "apartment"
| "apt"
| "avenue"
| "ave"
| "block"
| "building"
| "bungalow"
| "close"
| "cl"
| "court"
| "ct"
| "cres"
| "crescent"
| "drive"
| "dr"
| "estate"
| "flat"
| "fl"
| "floor"
| "garden"
| "gardens"
| "gdns"
| "grove"
| "house"
| "hse"
| "lane"
| "ln"
| "lodge"
| "mansions"
| "mews"
| "of"
| "park"
| "place"
| "road"
| "rd"
| "room"
| "row"
| "saint"
| "sq"
| "square"
| "st"
| "street"
| "terr"
| "terrace"
| "the"
| "unit"
| "view"
| "villas"
| "walk"
| "way"
| "yard"
)
}
fn address_term_group(token: &str) -> Option<AddressTermGroup> {
if token.len() < 3 || is_numeric_address_token(token) || looks_like_postcode_fragment(token) {
return None;
}
let mut alternatives = Vec::new();
alternatives.push(token.to_string());
for alias in address_token_aliases(token) {
if !alternatives.iter().any(|existing| existing == alias) {
alternatives.push(alias.to_string());
}
}
if alternatives
.iter()
.all(|alternative| is_address_stop_token(alternative))
{
return None;
}
Some(AddressTermGroup { alternatives })
}
fn address_search_tokens(text: &str) -> Vec<String> {
let mut tokens: Vec<String> = tokenize_address_text(text)
.into_iter()
.filter(|token| is_address_search_token(token))
.collect();
tokens.sort_unstable();
tokens.dedup();
tokens
}
fn is_address_search_token(token: &str) -> bool {
if looks_like_postcode_fragment(token) {
return false;
}
if is_numeric_address_token(token) {
return true;
}
if token.chars().any(|ch| ch.is_ascii_digit()) {
return token.len() >= 2;
}
token.len() >= 3
}
fn is_address_candidate_token(token: &str) -> bool {
!is_numeric_address_token(token)
&& !looks_like_postcode_fragment(token)
&& (token.chars().any(|ch| ch.is_ascii_digit())
|| (token.len() >= 3 && !is_address_stop_token(token)))
}
fn address_prefix_key(term: &str) -> &str {
if term.len() > ADDRESS_SEARCH_PREFIX_MAX_LEN {
&term[..ADDRESS_SEARCH_PREFIX_MAX_LEN]
} else {
term
}
}
fn build_address_prefix_index(
address_token_index: &FxHashMap<String, Vec<u32>>,
) -> FxHashMap<String, Vec<String>> {
let mut prefix_index: FxHashMap<String, Vec<String>> = FxHashMap::default();
for token in address_token_index.keys() {
let max_prefix_len = token.len().min(ADDRESS_SEARCH_PREFIX_MAX_LEN);
for prefix_len in ADDRESS_SEARCH_PREFIX_MIN_LEN..=max_prefix_len {
prefix_index
.entry(token[..prefix_len].to_string())
.or_default()
.push(token.clone());
}
}
for tokens in prefix_index.values_mut() {
tokens.sort_unstable();
tokens.dedup();
}
prefix_index
}
fn parse_address_query(query: &str) -> AddressQuery {
let tokens = tokenize_address_text(query);
let (full_postcode, postcode_token_indices) = extract_full_postcode(&tokens)
.map(|(postcode, indices)| (Some(postcode), indices))
.unwrap_or((None, Vec::new()));
let skip_postcode_tokens: FxHashSet<usize> = postcode_token_indices.into_iter().collect();
let mut text_groups = Vec::new();
let mut numeric_terms = Vec::new();
let mut candidate_terms = Vec::new();
for (idx, token) in tokens.iter().enumerate() {
if skip_postcode_tokens.contains(&idx) || looks_like_postcode_fragment(token) {
continue;
}
if is_numeric_address_token(token) {
numeric_terms.push(token.clone());
continue;
}
if let Some(group) = address_term_group(token) {
for alternative in &group.alternatives {
if !is_address_stop_token(alternative)
&& !candidate_terms.iter().any(|term| term == alternative)
{
candidate_terms.push(alternative.clone());
}
}
text_groups.push(group);
} else if token.chars().any(|ch| ch.is_ascii_digit()) && token.len() >= 2 {
numeric_terms.push(token.clone());
if !candidate_terms.iter().any(|term| term == token) {
candidate_terms.push(token.clone());
}
}
}
text_groups.dedup_by(|left, right| left.alternatives == right.alternatives);
numeric_terms.sort_unstable();
numeric_terms.dedup();
AddressQuery {
full_postcode,
text_groups,
numeric_terms,
candidate_terms,
}
}
fn token_matches_query_term(token: &str, query_term: &str) -> bool {
token == query_term || (query_term.len() >= 3 && token.starts_with(query_term))
}
fn token_matches_numeric_term(token: &str, query_term: &str) -> bool {
token == query_term || token.starts_with(query_term)
}
fn address_tokens_match_group(tokens: &[String], group: &AddressTermGroup) -> bool {
group.alternatives.iter().any(|alternative| {
tokens
.iter()
.any(|token| token_matches_query_term(token, alternative))
})
}
/// Histogram with outlier buckets at the edges. /// Histogram with outlier buckets at the edges.
/// - Bin 0: [min, p1) — low outliers /// - Bin 0: [min, p1) — low outliers
/// - Bins 1 to n-2: [p1, p99) — main distribution, evenly divided /// - Bins 1 to n-2: [p1, p99) — main distribution, evenly divided
@ -163,6 +522,20 @@ pub struct PropertyData {
/// Interned postcodes: reader is thread-safe, keys index into it. /// Interned postcodes: reader is thread-safe, keys index into it.
postcode_interner: lasso::RodeoReader, postcode_interner: lasso::RodeoReader,
postcode_keys: Vec<lasso::Spur>, postcode_keys: Vec<lasso::Spur>,
/// Rows for each postcode, keyed by the interned postcode key.
postcode_row_index: FxHashMap<lasso::Spur, Vec<u32>>,
/// Inverted index from address tokens to property rows.
address_token_index: FxHashMap<String, Vec<u32>>,
/// Prefix lookup from typed address-token prefix to indexed full address tokens.
address_prefix_index: FxHashMap<String, Vec<String>>,
/// Interned normalized address-search tokens used for per-row scoring.
address_search_interner: lasso::RodeoReader,
/// Flat per-row normalized address-search token keys.
address_search_token_keys: Vec<lasso::Spur>,
/// Offset into `address_search_token_keys` for each row.
address_search_token_offsets: Vec<u32>,
/// Number of normalized address-search token keys for each row.
address_search_token_lengths: Vec<u16>,
/// For enum features: maps feature index to list of possible string values. /// For enum features: maps feature index to list of possible string values.
/// Index in values list corresponds to the u16 value stored in feature_data. /// Index in values list corresponds to the u16 value stored in feature_data.
pub enum_values: rustc_hash::FxHashMap<usize, Vec<String>>, pub enum_values: rustc_hash::FxHashMap<usize, Vec<String>>,
@ -197,6 +570,164 @@ impl PropertyData {
(&self.postcode_interner, &self.postcode_keys) (&self.postcode_interner, &self.postcode_keys)
} }
fn row_address_search_tokens(&self, row: usize) -> &[lasso::Spur] {
let offset = self.address_search_token_offsets[row] as usize;
let length = self.address_search_token_lengths[row] as usize;
&self.address_search_token_keys[offset..offset + length]
}
/// Search individual property addresses. Full postcode queries use a direct row index;
/// free-text queries use a small inverted index over distinctive address tokens.
pub fn search_addresses(&self, query: &str, limit: usize) -> Vec<usize> {
if limit == 0 {
return Vec::new();
}
let parsed = parse_address_query(query);
if parsed.full_postcode.is_none()
&& parsed.text_groups.is_empty()
&& parsed.numeric_terms.is_empty()
{
return Vec::new();
}
let candidate_rows: Vec<u32> = if let Some(postcode) = parsed.full_postcode.as_deref() {
self.postcode_interner
.get(postcode)
.and_then(|key| self.postcode_row_index.get(&key))
.map(|rows| rows.to_vec())
.unwrap_or_default()
} else if let Some(rows) = self.best_address_token_rows(&parsed.candidate_terms) {
rows.iter()
.take(ADDRESS_SEARCH_CANDIDATE_LIMIT)
.copied()
.collect()
} else {
Vec::new()
};
if candidate_rows.is_empty() {
return Vec::new();
}
let mut scored: Vec<(i32, usize, usize)> = candidate_rows
.into_iter()
.filter_map(|row| {
let row = row as usize;
self.address_match_score(row, &parsed)
.map(|score| (score, self.address(row).len(), row))
})
.collect();
scored.sort_unstable_by(|left, right| {
right
.0
.cmp(&left.0)
.then(left.1.cmp(&right.1))
.then(left.2.cmp(&right.2))
});
let mut seen = FxHashSet::default();
let mut results = Vec::with_capacity(limit);
for (_, _, row) in scored {
let address = self.address(row).trim();
if address.is_empty() {
continue;
}
let key = format!("{}\n{}", address.to_ascii_lowercase(), self.postcode(row));
if !seen.insert(key) {
continue;
}
results.push(row);
if results.len() == limit {
break;
}
}
results
}
fn best_address_token_rows(&self, terms: &[String]) -> Option<&[u32]> {
let mut best: Option<&[u32]> = None;
for term in terms {
if let Some(rows) = self.address_token_index.get(term) {
if best.map_or(true, |current| rows.len() < current.len()) {
best = Some(rows.as_slice());
}
continue;
}
if term.len() < 4 {
continue;
}
if let Some(tokens) = self.address_prefix_index.get(address_prefix_key(term)) {
for token in tokens {
if !token.starts_with(term) {
continue;
}
if let Some(rows) = self.address_token_index.get(token) {
if best.map_or(true, |current| rows.len() < current.len()) {
best = Some(rows.as_slice());
}
}
}
}
}
best
}
fn address_match_score(&self, row: usize, parsed: &AddressQuery) -> Option<i32> {
if self.address(row).trim().is_empty() {
return None;
}
let tokens = self.row_address_search_tokens(row);
if parsed
.text_groups
.iter()
.any(|group| !self.address_tokens_match_group(tokens, group))
{
return None;
}
let numeric_matches = parsed
.numeric_terms
.iter()
.filter(|term| {
tokens.iter().any(|token| {
token_matches_numeric_term(self.address_search_interner.resolve(token), term)
})
})
.count();
if !parsed.numeric_terms.is_empty() && numeric_matches == 0 {
return None;
}
let mut score = 0;
if parsed.full_postcode.is_some() {
score += 1_000;
}
score += (parsed.text_groups.len() as i32) * 200;
score += (numeric_matches as i32) * 90;
if numeric_matches == parsed.numeric_terms.len() && numeric_matches > 0 {
score += 50;
}
Some(score)
}
fn address_tokens_match_group(&self, tokens: &[lasso::Spur], group: &AddressTermGroup) -> bool {
group.alternatives.iter().any(|alternative| {
tokens.iter().any(|token| {
token_matches_query_term(self.address_search_interner.resolve(token), alternative)
})
})
}
/// Get the is_approx_build_date flag for a given row (bit-packed). /// Get the is_approx_build_date flag for a given row (bit-packed).
pub fn is_approx_build_date(&self, row: usize) -> bool { pub fn is_approx_build_date(&self, row: usize) -> bool {
let byte = self.approx_build_date_bits[row / 8]; let byte = self.approx_build_date_bits[row / 8];
@ -946,27 +1477,70 @@ impl PropertyData {
.map(|&perm_index| lon[perm_index as usize]) .map(|&perm_index| lon[perm_index as usize])
.collect(); .collect();
// Build contiguous address buffer (permuted) // Build contiguous address buffer and address search index (permuted)
tracing::info!("Building interned strings"); tracing::info!("Building interned strings");
let total_addr_bytes: usize = address_raw.iter().map(|text| text.len()).sum(); let total_addr_bytes: usize = address_raw.iter().map(|text| text.len()).sum();
let mut address_buffer = String::with_capacity(total_addr_bytes); let mut address_buffer = String::with_capacity(total_addr_bytes);
let mut address_offsets = Vec::with_capacity(row_count); let mut address_offsets = Vec::with_capacity(row_count);
let mut address_lengths = Vec::with_capacity(row_count); let mut address_lengths = Vec::with_capacity(row_count);
for &perm_index in &perm { let mut address_token_index: FxHashMap<String, Vec<u32>> = FxHashMap::default();
let mut address_search_rodeo = lasso::Rodeo::default();
let mut address_search_token_keys: Vec<lasso::Spur> = Vec::new();
let mut address_search_token_offsets = Vec::with_capacity(row_count);
let mut address_search_token_lengths = Vec::with_capacity(row_count);
for (new_row, &perm_index) in perm.iter().enumerate() {
let addr = &address_raw[perm_index as usize]; let addr = &address_raw[perm_index as usize];
let offset = address_buffer.len() as u32; let offset = address_buffer.len() as u32;
let length = addr.len().min(u16::MAX as usize) as u16; let length = addr.len().min(u16::MAX as usize) as u16;
address_offsets.push(offset); address_offsets.push(offset);
address_lengths.push(length); address_lengths.push(length);
address_buffer.push_str(&addr[..length as usize]); address_buffer.push_str(&addr[..length as usize]);
let search_tokens = address_search_tokens(addr);
let token_offset = address_search_token_keys.len() as u32;
let token_length = search_tokens.len().min(u16::MAX as usize) as u16;
address_search_token_offsets.push(token_offset);
address_search_token_lengths.push(token_length);
for token in search_tokens.iter().take(token_length as usize) {
let key = address_search_rodeo.get_or_intern(token);
address_search_token_keys.push(key);
if is_address_candidate_token(token) {
address_token_index
.entry(token.clone())
.or_default()
.push(new_row as u32);
}
}
} }
let address_token_count_before_prune = address_token_index.len();
address_token_index.retain(|_, rows| rows.len() <= ADDRESS_SEARCH_MAX_POSTINGS_PER_TOKEN);
let address_prefix_index = build_address_prefix_index(&address_token_index);
let address_search_interner = address_search_rodeo.into_reader();
let address_postings_count: usize = address_token_index.values().map(Vec::len).sum();
tracing::info!(
tokens = address_token_index.len(),
prefixes = address_prefix_index.len(),
pruned_tokens =
address_token_count_before_prune.saturating_sub(address_token_index.len()),
postings = address_postings_count,
row_tokens = address_search_token_keys.len(),
"Address search index built"
);
// Intern postcodes (permuted) // Intern postcodes (permuted)
let mut postcode_rodeo = lasso::Rodeo::default(); let mut postcode_rodeo = lasso::Rodeo::default();
let postcode_keys: Vec<lasso::Spur> = perm let mut postcode_keys: Vec<lasso::Spur> = Vec::with_capacity(row_count);
.iter() let mut postcode_row_index: FxHashMap<lasso::Spur, Vec<u32>> = FxHashMap::default();
.map(|&perm_index| postcode_rodeo.get_or_intern(&postcode_raw[perm_index as usize])) for (new_row, &perm_index) in perm.iter().enumerate() {
.collect(); let key = postcode_rodeo.get_or_intern(&postcode_raw[perm_index as usize]);
postcode_keys.push(key);
postcode_row_index
.entry(key)
.or_default()
.push(new_row as u32);
}
let postcode_interner = postcode_rodeo.into_reader(); let postcode_interner = postcode_rodeo.into_reader();
// Pack is_approx_build_date into a bitvec (8 bools per byte) // Pack is_approx_build_date into a bitvec (8 bools per byte)
@ -1110,6 +1684,13 @@ impl PropertyData {
address_lengths, address_lengths,
postcode_interner, postcode_interner,
postcode_keys, postcode_keys,
postcode_row_index,
address_token_index,
address_prefix_index,
address_search_interner,
address_search_token_keys,
address_search_token_offsets,
address_search_token_lengths,
enum_values, enum_values,
enum_counts, enum_counts,
approx_build_date_bits, approx_build_date_bits,
@ -1133,6 +1714,120 @@ mod tests {
Bounds::Percentile { low, high } Bounds::Percentile { low, high }
} }
#[test]
fn full_postcode_detection_accepts_common_formats() {
assert!(is_full_postcode_compact("SW1A1AA"));
assert!(is_full_postcode_compact("E142DG"));
assert!(is_full_postcode_compact("M11AE"));
assert!(!is_full_postcode_compact("E14"));
assert!(!is_full_postcode_compact("DOWNING"));
assert!(!is_full_postcode_compact("10A"));
}
#[test]
fn address_query_parsing_skips_postcodes_and_street_suffixes() {
let parsed = parse_address_query("Flat 2, 10 Downing St, SW1A 2AA");
assert_eq!(parsed.full_postcode.as_deref(), Some("SW1A 2AA"));
assert_eq!(
parsed.numeric_terms,
vec!["10".to_string(), "2".to_string()]
);
assert_eq!(parsed.candidate_terms, vec!["downing".to_string()]);
assert_eq!(parsed.text_groups.len(), 1);
assert_eq!(
parsed.text_groups[0].alternatives,
vec!["downing".to_string()]
);
}
#[test]
fn address_query_parsing_handles_compact_postcodes() {
let parsed = parse_address_query("10 downing street sw1a1aa");
assert_eq!(parsed.full_postcode.as_deref(), Some("SW1A 1AA"));
assert_eq!(parsed.numeric_terms, vec!["10".to_string()]);
assert_eq!(parsed.candidate_terms, vec!["downing".to_string()]);
}
#[test]
fn address_query_parsing_keeps_partial_terms_for_row_matching() {
let parsed = parse_address_query("settlers cour");
assert_eq!(parsed.full_postcode, None);
assert_eq!(parsed.numeric_terms, Vec::<String>::new());
assert_eq!(
parsed.candidate_terms,
vec!["settlers".to_string(), "cour".to_string()]
);
assert_eq!(parsed.text_groups.len(), 2);
assert_eq!(
parsed.text_groups[0].alternatives,
vec!["settlers".to_string()]
);
assert_eq!(parsed.text_groups[1].alternatives, vec!["cour".to_string()]);
}
#[test]
fn address_search_tokens_keep_actual_address_terms_for_scoring() {
let tokens = address_search_tokens("Flat 2, 10 Downing Cour");
assert_eq!(
tokens,
vec![
"10".to_string(),
"2".to_string(),
"cour".to_string(),
"downing".to_string(),
"flat".to_string()
]
);
}
#[test]
fn address_prefix_index_finds_partial_address_terms() {
let mut token_index: FxHashMap<String, Vec<u32>> = FxHashMap::default();
token_index.insert("downing".to_string(), vec![1]);
token_index.insert("downton".to_string(), vec![2]);
token_index.insert("market".to_string(), vec![3]);
let prefix_index = build_address_prefix_index(&token_index);
assert_eq!(
prefix_index.get("down").cloned().unwrap_or_default(),
vec!["downing".to_string(), "downton".to_string()]
);
assert_eq!(
prefix_index.get("downi").cloned().unwrap_or_default(),
vec!["downing".to_string()]
);
assert_eq!(
prefix_index.get("downt").cloned().unwrap_or_default(),
vec!["downton".to_string()]
);
assert!(!prefix_index.contains_key("do"));
}
#[test]
fn address_term_matching_allows_prefixes_and_aliases() {
let tokens = tokenize_address_text("10 Downing Street");
let prefix_group = address_term_group("down").expect("prefix term should be searchable");
let alias_group = AddressTermGroup {
alternatives: vec!["st".to_string(), "street".to_string()],
};
assert!(address_tokens_match_group(&tokens, &prefix_group));
assert!(address_tokens_match_group(&tokens, &alias_group));
}
#[test]
fn address_term_matching_uses_actual_token_prefixes() {
let tokens = tokenize_address_text("12 Settlers Court");
let prefix_group = address_term_group("cou").expect("partial term should be searchable");
assert!(address_tokens_match_group(&tokens, &prefix_group));
}
#[test] #[test]
fn histogram_empty_data() { fn histogram_empty_data() {
let data: Vec<f32> = vec![]; let data: Vec<f32> = vec![];

View file

@ -413,7 +413,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
features: &[ features: &[
Feature::Numeric(FeatureConfig { Feature::Numeric(FeatureConfig {
name: "Income Score (rate)", name: "Income Score (rate)",
bounds: Bounds::Fixed { min: 0.0, max: 0.6 }, bounds: Bounds::Fixed { min: 0.0, max: 1.0 },
step: 0.01, step: 0.01,
description: "Income deprivation rate, inverted (higher = less deprived)", description: "Income deprivation rate, inverted (higher = less deprived)",
detail: "From the English Indices of Deprivation (inverted so higher = better). Higher values indicate less income deprivation. Based on Income Support, income-based Jobseeker's Allowance, income-based Employment and Support Allowance, Pension Credit, Working Tax Credit and Child Tax Credit, Universal Credit, and asylum seekers.", detail: "From the English Indices of Deprivation (inverted so higher = better). Higher values indicate less income deprivation. Based on Income Support, income-based Jobseeker's Allowance, income-based Employment and Support Allowance, Pension Credit, Working Tax Credit and Child Tax Credit, Universal Credit, and asylum seekers.",
@ -425,7 +425,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
}), }),
Feature::Numeric(FeatureConfig { Feature::Numeric(FeatureConfig {
name: "Employment Score (rate)", name: "Employment Score (rate)",
bounds: Bounds::Fixed { min: 0.0, max: 0.4 }, bounds: Bounds::Fixed { min: 0.0, max: 1.0 },
step: 0.01, step: 0.01,
description: "Employment deprivation rate, inverted (higher = less deprived)", description: "Employment deprivation rate, inverted (higher = less deprived)",
detail: "From the English Indices of Deprivation (inverted so higher = better). Higher values indicate less employment deprivation. Based on claimants of Jobseeker's Allowance, Employment and Support Allowance, Incapacity Benefit, Severe Disablement Allowance, Carer's Allowance, and relevant Universal Credit claimants.", detail: "From the English Indices of Deprivation (inverted so higher = better). Higher values indicate less employment deprivation. Based on claimants of Jobseeker's Allowance, Employment and Support Allowance, Incapacity Benefit, Severe Disablement Allowance, Carer's Allowance, and relevant Universal Credit claimants.",

View file

@ -1,16 +1,26 @@
use std::sync::Arc; use std::sync::Arc;
use axum::body::Body; use axum::body::{to_bytes, Body};
use axum::extract::Request; use axum::extract::Request;
use axum::http::header; use axum::http::{header, StatusCode};
use axum::middleware::Next; use axum::middleware::Next;
use axum::response::Response; use axum::response::Response;
use tracing::warn;
use crate::state::AppState; use crate::state::AppState;
const OG_PLACEHOLDER: &str = const OG_PLACEHOLDER: &str =
r#"<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__"/>"#; r#"<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__"/>"#;
const HTML_BODY_LIMIT: usize = 5 * 1024 * 1024;
struct SeoPage {
canonical_path: &'static str,
title: &'static str,
description: &'static str,
indexable: bool,
}
/// Escape a string for safe inclusion inside a double-quoted HTML attribute value. /// Escape a string for safe inclusion inside a double-quoted HTML attribute value.
fn escape_attr(s: &str) -> String { fn escape_attr(s: &str) -> String {
let mut out = String::with_capacity(s.len()); let mut out = String::with_capacity(s.len());
@ -26,6 +36,279 @@ fn escape_attr(s: &str) -> String {
out out
} }
fn trim_trailing_slash(path: &str) -> &str {
if path.len() > 1 {
path.trim_end_matches('/')
} else {
path
}
}
fn seo_page_for_path(path: &str) -> Option<SeoPage> {
let path = trim_trailing_slash(path);
match path {
"/" => Some(SeoPage {
canonical_path: "/",
title: "Perfect Postcode - Find where to buy before browsing listings",
description: "Search every postcode by budget, commute, schools, safety, noise, broadband, prices and more. Build a better home-buying shortlist before viewings.",
indexable: true,
}),
"/learn" | "/support" => Some(SeoPage {
canonical_path: "/learn",
title: "How Perfect Postcode works - Data sources, FAQ and support",
description: "Learn how Perfect Postcode combines property prices, EPC records, travel times, crime, schools, broadband, noise, amenities and open data for postcode research.",
indexable: true,
}),
"/pricing" => Some(SeoPage {
canonical_path: "/pricing",
title: "Perfect Postcode pricing - Lifetime property search map access",
description: "Get lifetime access to the postcode property search map for England, including filters, saved searches, exports, and future data updates.",
indexable: true,
}),
"/property-price-map" => Some(SeoPage {
canonical_path: "/property-price-map",
title: "Property price map for England - Compare postcodes before viewing",
description: "Compare sold prices, estimated current value, price per square metre and local context across English postcodes before searching listings.",
indexable: true,
}),
"/postcode-property-search" => Some(SeoPage {
canonical_path: "/postcode-property-search",
title: "Postcode property search - Find areas that match your criteria",
description: "Search every postcode by budget, property type, floor area, tenure, commute, schools, crime, broadband, noise, parks and local amenities.",
indexable: true,
}),
"/commute-property-search" => Some(SeoPage {
canonical_path: "/commute-property-search",
title: "Commute property search - Find places to live by travel time",
description: "Filter postcodes by commute time, then compare price, schools, safety, broadband, road noise, parks and property data on one map.",
indexable: true,
}),
"/school-property-search" => Some(SeoPage {
canonical_path: "/school-property-search",
title: "School property search - Compare postcodes for family moves",
description: "Compare nearby schools, property size, prices, parks, safety, commute and local amenities before building a viewing shortlist.",
indexable: true,
}),
"/postcode-checker" => Some(SeoPage {
canonical_path: "/postcode-checker",
title: "Postcode checker - Property, crime, broadband, noise and schools",
description: "Check postcode-level property prices, EPC data, crime, broadband, road noise, schools, council tax, amenities and travel-time context.",
indexable: true,
}),
"/property-search/birmingham" => Some(SeoPage {
canonical_path: "/property-search/birmingham",
title: "Birmingham property search - Compare postcodes by price and commute",
description: "Use postcode-level data to compare Birmingham property prices, commute trade-offs, schools, crime, broadband and local amenities before viewings.",
indexable: true,
}),
"/property-search/manchester" => Some(SeoPage {
canonical_path: "/property-search/manchester",
title: "Manchester property search - Compare postcodes before viewing",
description: "Compare Manchester-area postcodes by budget, commute, property type, schools, broadband, crime, noise and amenities before booking viewings.",
indexable: true,
}),
"/property-search/bristol" => Some(SeoPage {
canonical_path: "/property-search/bristol",
title: "Bristol property search - Compare postcodes by commute and price",
description: "Compare Bristol postcodes by price, commute, property size, schools, broadband, crime, road noise, parks and amenities before viewings.",
indexable: true,
}),
"/data-sources" => Some(SeoPage {
canonical_path: "/data-sources",
title: "Perfect Postcode data sources - Property, schools, commute and local context",
description: "Review the public and official datasets used by Perfect Postcode, including property prices, EPC, schools, crime, broadband, noise and travel-time context.",
indexable: true,
}),
"/methodology" => Some(SeoPage {
canonical_path: "/methodology",
title: "Perfect Postcode methodology - How to interpret postcode property data",
description: "Understand how to use postcode filters, property estimates, travel-time data, school context and local signals as a home-buying shortlist tool.",
indexable: true,
}),
"/privacy-security" => Some(SeoPage {
canonical_path: "/privacy-security",
title: "Perfect Postcode privacy and security - Saved searches and account data",
description: "Learn how Perfect Postcode treats saved searches, account data and property research workflows with privacy and security in mind.",
indexable: true,
}),
"/dashboard" => Some(SeoPage {
canonical_path: "/dashboard",
title: "Perfect Postcode dashboard",
description: "Explore postcode property data, travel times, prices, schools, crime, noise, broadband and amenities on the interactive map.",
indexable: false,
}),
"/saved" => Some(SeoPage {
canonical_path: "/saved",
title: "Perfect Postcode account",
description: "Manage your Perfect Postcode account, saved searches, saved properties and invitations.",
indexable: false,
}),
"/invites" => Some(SeoPage {
canonical_path: "/invites",
title: "Perfect Postcode account",
description: "Manage your Perfect Postcode account, saved searches, saved properties and invitations.",
indexable: false,
}),
"/account" => Some(SeoPage {
canonical_path: "/account",
title: "Perfect Postcode account",
description: "Manage your Perfect Postcode account, saved searches, saved properties and invitations.",
indexable: false,
}),
_ if path.starts_with("/invite/") => Some(SeoPage {
canonical_path: "/invite",
title: "You're invited to Perfect Postcode",
description: "Accept your invitation to explore property prices, energy ratings, crime stats, school ratings, and more across England.",
indexable: false,
}),
_ => None,
}
}
fn is_passthrough_path(path: &str) -> bool {
path.starts_with("/api/")
|| path.starts_with("/pb/")
|| path.starts_with("/s/")
|| path.starts_with("/assets/")
|| matches!(
path,
"/health"
| "/metrics"
| "/robots.txt"
| "/sitemap.xml"
| "/favicon.svg"
| "/bundle.js"
| "/main.css"
| "/house.png"
)
|| path
.rsplit('/')
.next()
.is_some_and(|segment| segment.contains('.'))
}
fn should_return_404(path: &str) -> bool {
!is_passthrough_path(path) && seo_page_for_path(path).is_none()
}
fn not_found_response(public_url: &str, path: &str) -> Response {
let public_url_e = escape_attr(public_url);
let path_e = escape_attr(path);
let html = format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex,follow" />
<title>Page not found - Perfect Postcode</title>
<meta name="description" content="This Perfect Postcode page could not be found." />
<link rel="canonical" href="{public_url_e}/" />
</head>
<body>
<main>
<h1>Page not found</h1>
<p>The requested path was not found: {path_e}</p>
<p><a href="{public_url_e}/">Go to Perfect Postcode</a></p>
</main>
</body>
</html>"#
);
let mut response = Response::new(Body::from(html));
*response.status_mut() = StatusCode::NOT_FOUND;
response.headers_mut().insert(
header::CONTENT_TYPE,
header::HeaderValue::from_static("text/html; charset=utf-8"),
);
response
}
fn route_seo_tags(page: &SeoPage, path: &str, query_string: &str, public_url: &str) -> String {
let path_e = escape_attr(path);
let query_e = escape_attr(query_string);
let public_url_e = escape_attr(public_url.trim_end_matches('/'));
let canonical_path_e = escape_attr(page.canonical_path);
let title_e = escape_attr(page.title);
let description_e = escape_attr(page.description);
let is_invite = path.starts_with("/invite/");
let og_image_url = if is_invite {
if query_string.is_empty() {
format!("{public_url_e}/api/screenshot?og=1&amp;path={path_e}")
} else {
format!("{public_url_e}/api/screenshot?og=1&amp;path={path_e}&amp;{query_e}")
}
} else if query_string.is_empty() {
format!("{public_url_e}/api/screenshot?og=1")
} else {
format!("{public_url_e}/api/screenshot?og=1&amp;{query_e}")
};
let canonical_url = format!("{public_url_e}{canonical_path_e}");
let og_url = if query_string.is_empty() {
format!("{public_url_e}{path_e}")
} else {
format!("{public_url_e}{path_e}?{query_e}")
};
let robots = if page.indexable {
"index,follow"
} else {
"noindex,follow"
};
format!(
r#"<meta name="robots" content="{robots}" />
<link rel="canonical" href="{canonical_url}" />
<meta property="og:title" content="{title_e}" />
<meta property="og:description" content="{description_e}" />
<meta property="og:type" content="website" />
<meta property="og:url" content="{og_url}" />
<meta property="og:site_name" content="Perfect Postcode" />
<meta property="og:logo" content="{public_url_e}/favicon.svg" />
<meta property="og:image" content="{og_image_url}" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{title_e}" />
<meta name="twitter:description" content="{description_e}" />
<meta name="twitter:image" content="{og_image_url}" />"#
)
}
fn inject_tags(mut html: String, page: &SeoPage, tags: &str) -> String {
if let Some(start) = html.find("<title>") {
if let Some(end_offset) = html[start..].find("</title>") {
let end = start + end_offset + "</title>".len();
html.replace_range(
start..end,
&format!("<title>{}</title>", escape_attr(page.title)),
);
}
}
if let Some(start) = html.find(r#"<meta name="description""#) {
if let Some(end_offset) = html[start..].find('>') {
let end = start + end_offset + 1;
html.replace_range(
start..end,
&format!(
r#"<meta name="description" content="{}" />"#,
escape_attr(page.description)
),
);
}
}
if html.contains(OG_PLACEHOLDER) {
return html.replace(OG_PLACEHOLDER, tags);
}
if let Some(index) = html.find("</head>") {
html.insert_str(index, tags);
}
html
}
pub async fn og_middleware(request: Request, next: Next) -> Response { pub async fn og_middleware(request: Request, next: Next) -> Response {
let path = request.uri().path().to_string(); let path = request.uri().path().to_string();
// Capture the query string before passing the request through // Capture the query string before passing the request through
@ -34,6 +317,12 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
// Get state from extensions // Get state from extensions
let state = request.extensions().get::<Arc<AppState>>().cloned(); let state = request.extensions().get::<Arc<AppState>>().cloned();
if let Some(st) = &state {
if !st.is_dev && should_return_404(&path) {
return not_found_response(&st.public_url, &path);
}
}
let response = next.run(request).await; let response = next.run(request).await;
// Only inject OG tags into SPA HTML responses, not proxied PocketBase responses // Only inject OG tags into SPA HTML responses, not proxied PocketBase responses
@ -56,68 +345,25 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
None => return response, None => return response,
}; };
let index_html = match &state.index_html { let page = match seo_page_for_path(&path) {
Some(html) => html, Some(page) => page,
None => return response, None => return response,
}; };
// Build OG-injected HTML (og=1 triggers heading overlay on screenshot). let (mut parts, body) = response.into_parts();
// All URL components are HTML-escaped before interpolation into attributes let bytes = match to_bytes(body, HTML_BODY_LIMIT).await {
// because path/query are attacker-controlled. Ok(bytes) => bytes,
let is_invite = path.starts_with("/invite/"); Err(err) => {
let path_e = escape_attr(&path); warn!("Failed to buffer HTML body for SEO tag injection: {err}");
let query_e = escape_attr(&query_string); let mut response = Response::from_parts(parts, Body::empty());
let public_url_e = escape_attr(&state.public_url); *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
return response;
let og_image_url = if is_invite {
// Include path= so the screenshot service navigates to /invite/CODE
if query_string.is_empty() {
format!("{public_url_e}/api/screenshot?og=1&amp;path={path_e}")
} else {
format!("{public_url_e}/api/screenshot?og=1&amp;path={path_e}&amp;{query_e}")
} }
} else if query_string.is_empty() {
format!("{public_url_e}/api/screenshot?og=1")
} else {
format!("{public_url_e}/api/screenshot?og=1&amp;{query_e}")
}; };
let og_url = if query_string.is_empty() { let html = String::from_utf8_lossy(&bytes).into_owned();
format!("{public_url_e}{path_e}") let tags = route_seo_tags(&page, &path, &query_string, &state.public_url);
} else { let html = inject_tags(html, &page, &tags);
format!("{public_url_e}{path_e}?{query_e}") parts.headers.remove(header::CONTENT_LENGTH);
};
let og_logo = format!("{public_url_e}/favicon.svg");
let (og_title, og_description) = if is_invite {
(
"You\u{2019}re invited to Perfect Postcode",
"Accept your invitation to explore property prices, energy ratings, crime stats, school ratings, and more across England.",
)
} else {
(
"Perfect Postcode \u{2014} Every neighbourhood in England",
"Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map.",
)
};
let og_tags = format!(
r#"<meta property="og:title" content="{og_title}" />
<meta property="og:description" content="{og_description}" />
<meta property="og:type" content="website" />
<meta property="og:url" content="{og_url}" />
<meta property="og:logo" content="{og_logo}" />
<meta property="og:image" content="{og_image_url}" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{og_title}" />
<meta name="twitter:description" content="{og_description}" />"#
);
let html = index_html.replace(OG_PLACEHOLDER, &og_tags);
let (parts, _body) = response.into_parts();
Response::from_parts(parts, Body::from(html)) Response::from_parts(parts, Body::from(html))
} }

View file

@ -143,6 +143,9 @@ fn execute_destination_search(state: &AppState, query: &str, mode: &str) -> Valu
.iter() .iter()
.enumerate() .enumerate()
.filter_map(|(idx, name_lower)| { .filter_map(|(idx, name_lower)| {
if !pd.travel_destination[idx] {
return None;
}
let words_match = query_words.iter().all(|word| name_lower.contains(word)); let words_match = query_words.iter().all(|word| name_lower.contains(word));
let slug = slugify(&pd.name[idx]); let slug = slugify(&pd.name[idx]);
let slug_match = slug.contains(&query_slug) || query_slug.contains(&slug); let slug_match = slug.contains(&query_slug) || query_slug.contains(&slug);
@ -169,6 +172,9 @@ fn execute_destination_search(state: &AppState, query: &str, mode: &str) -> Valu
.iter() .iter()
.enumerate() .enumerate()
.find_map(|(idx, name_lower)| { .find_map(|(idx, name_lower)| {
if !pd.travel_destination[idx] {
return None;
}
let words_match = query_words.iter().all(|word| name_lower.contains(word)); let words_match = query_words.iter().all(|word| name_lower.contains(word));
let slug = slugify(&pd.name[idx]); let slug = slugify(&pd.name[idx]);
let slug_match = slug.contains(&query_slug) || query_slug.contains(&slug); let slug_match = slug.contains(&query_slug) || query_slug.contains(&slug);
@ -186,6 +192,9 @@ fn execute_destination_search(state: &AppState, query: &str, mode: &str) -> Valu
.iter() .iter()
.enumerate() .enumerate()
.filter_map(|(idx, city_opt)| { .filter_map(|(idx, city_opt)| {
if !pd.travel_destination[idx] {
return None;
}
let city = city_opt.as_deref()?; let city = city_opt.as_deref()?;
if city.to_lowercase() != city_lower { if city.to_lowercase() != city_lower {
return None; return None;

View file

@ -6,7 +6,7 @@ use axum::response::Json;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::info; use tracing::info;
use crate::data::slugify; use crate::data::{normalize_search_text, slugify};
use crate::state::SharedState; use crate::state::SharedState;
#[derive(Serialize)] #[derive(Serialize)]
@ -20,9 +20,21 @@ pub struct PlaceResult {
city: Option<String>, city: Option<String>,
} }
#[derive(Serialize)]
pub struct AddressResult {
address: String,
postcode: String,
lat: f32,
lon: f32,
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct PlacesResponse { pub struct PlacesResponse {
places: Vec<PlaceResult>, places: Vec<PlaceResult>,
#[serde(skip_serializing_if = "Vec::is_empty")]
postcodes: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
addresses: Vec<AddressResult>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -34,6 +46,53 @@ pub struct PlacesParams {
mode: Option<String>, mode: Option<String>,
} }
fn compact_postcode_query(query: &str) -> String {
query
.chars()
.filter(|ch| !ch.is_whitespace())
.map(|ch| ch.to_ascii_uppercase())
.collect()
}
fn looks_like_postcode_prefix(query: &str) -> bool {
let compact = compact_postcode_query(query);
if compact.len() < 2 || compact.len() > 7 {
return false;
}
compact
.chars()
.next()
.is_some_and(|ch| ch.is_ascii_alphabetic())
&& compact.chars().all(|ch| ch.is_ascii_alphanumeric())
&& compact.chars().any(|ch| ch.is_ascii_digit())
}
fn postcode_starts_with_compact(postcode: &str, compact_query: &str) -> bool {
let mut query_chars = compact_query.chars();
let mut current = query_chars.next();
if current.is_none() {
return false;
}
for postcode_char in postcode.chars() {
if postcode_char.is_whitespace() {
continue;
}
match current {
Some(query_char) if postcode_char.to_ascii_uppercase() == query_char => {
current = query_chars.next();
if current.is_none() {
return true;
}
}
_ => return false,
}
}
current.is_none()
}
pub async fn get_places( pub async fn get_places(
State(shared): State<Arc<SharedState>>, State(shared): State<Arc<SharedState>>,
Query(params): Query<PlacesParams>, Query(params): Query<PlacesParams>,
@ -51,31 +110,39 @@ pub async fn get_places(
let places = tokio::task::spawn_blocking(move || { let places = tokio::task::spawn_blocking(move || {
let t0 = std::time::Instant::now(); let t0 = std::time::Instant::now();
let query_lower = query.to_lowercase(); let query_lower = query.to_lowercase();
let query_search = normalize_search_text(&query);
let pd = &state.place_data; let pd = &state.place_data;
let od = &state.outcode_data; let od = &state.outcode_data;
let postcode_data = &state.postcode_data;
let tt_store = &state.travel_time_store; let tt_store = &state.travel_time_store;
let property_data = &state.data;
// Linear scan — ~50-100k rows, <1ms // Linear scan — ~50-100k rows, <1ms
// Tuple: (row_idx, is_exact, is_prefix, type_rank, population, name_len, slug) // Tuple: (row_idx, is_exact, is_prefix, type_rank, population, name_len, slug)
let mut matches: Vec<(usize, bool, bool, u8, u32, usize, String)> = pd let mut matches: Vec<(usize, bool, bool, u8, u32, usize, String)> = pd
.name_lower .name_search
.iter() .iter()
.enumerate() .enumerate()
.filter_map(|(idx, name)| { .filter_map(|(idx, search_text)| {
if !name.contains(&query_lower) { if query_search.is_empty() || !search_text.contains(&query_search) {
return None; return None;
} }
let slug = slugify(&pd.name[idx]); let slug = slugify(&pd.name[idx]);
// If mode filter is set, only include places with travel data // If mode filter is set, keep the historical travel destination set only.
if let Some(ref mode) = mode_filter { if let Some(ref mode) = mode_filter {
if !tt_store.has_destination(mode, &slug) { if !pd.travel_destination[idx] || !tt_store.has_destination(mode, &slug) {
return None; return None;
} }
} }
let is_exact = name.len() == query_lower.len(); let is_exact = search_text
let is_prefix = name.starts_with(&query_lower); .split(" | ")
.any(|alias| alias == query_search || pd.name_lower[idx] == query_lower);
let is_prefix = search_text
.split(" | ")
.any(|alias| alias.starts_with(&query_search))
|| pd.name_lower[idx].starts_with(&query_lower);
Some(( Some((
idx, idx,
is_exact, is_exact,
@ -153,20 +220,76 @@ pub async fn get_places(
results = outcode_results; results = outcode_results;
} }
let postcodes: Vec<String> = if mode_filter.is_none() && looks_like_postcode_prefix(&query)
{
let compact_query = compact_postcode_query(&query);
postcode_data
.postcodes
.iter()
.filter(|postcode| postcode_starts_with_compact(postcode, &compact_query))
.take(limit)
.cloned()
.collect()
} else {
Vec::new()
};
let addresses: Vec<AddressResult> = if mode_filter.is_none() {
property_data
.search_addresses(&query, limit)
.into_iter()
.map(|row| AddressResult {
address: property_data.address(row).trim().to_string(),
postcode: property_data.postcode(row).to_string(),
lat: property_data.lat[row],
lon: property_data.lon[row],
})
.collect()
} else {
Vec::new()
};
let elapsed = t0.elapsed(); let elapsed = t0.elapsed();
info!( info!(
query = query.as_str(), query = query.as_str(),
results = results.len(), results = results.len(),
postcodes = postcodes.len(),
addresses = addresses.len(),
scanned = pd.name_lower.len(), scanned = pd.name_lower.len(),
mode = mode_filter.as_deref().unwrap_or("-"), mode = mode_filter.as_deref().unwrap_or("-"),
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0), ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
"GET /api/places" "GET /api/places"
); );
results (results, postcodes, addresses)
}) })
.await .await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?; .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
Ok(Json(PlacesResponse { places })) Ok(Json(PlacesResponse {
places: places.0,
postcodes: places.1,
addresses: places.2,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_postcode_prefixes() {
assert!(looks_like_postcode_prefix("EC2R"));
assert!(looks_like_postcode_prefix("sw1a 1"));
assert!(looks_like_postcode_prefix("M4"));
assert!(!looks_like_postcode_prefix("London"));
assert!(!looks_like_postcode_prefix("E"));
}
#[test]
fn postcode_prefix_match_ignores_spaces() {
assert!(postcode_starts_with_compact("EC2R 8AH", "EC2R8"));
assert!(postcode_starts_with_compact("SW1A 1AA", "SW1A1"));
assert!(!postcode_starts_with_compact("SW1A 1AA", "SW1A2"));
}
} }

View file

@ -22,6 +22,8 @@ pub struct PostcodePropertiesParams {
pub filters: Option<String>, pub filters: Option<String>,
pub limit: Option<usize>, pub limit: Option<usize>,
pub offset: Option<usize>, pub offset: Option<usize>,
/// Exact address to rank first when opening properties from address search.
pub focus_address: Option<String>,
/// Share-link code; grants bbox-scoped access for unlicensed users. /// Share-link code; grants bbox-scoped access for unlicensed users.
pub share: Option<String>, pub share: Option<String>,
} }
@ -67,6 +69,12 @@ pub async fn get_postcode_properties(
let filters_str = params.filters; let filters_str = params.filters;
let postcode_str = normalized; let postcode_str = normalized;
let focus_address = params
.focus_address
.as_deref()
.map(str::trim)
.filter(|address| !address.is_empty())
.map(str::to_ascii_lowercase);
let result = tokio::task::spawn_blocking(move || { let result = tokio::task::spawn_blocking(move || {
let t0 = std::time::Instant::now(); let t0 = std::time::Instant::now();
@ -100,7 +108,20 @@ pub async fn get_postcode_properties(
} }
}); });
matching_rows.sort_unstable_by_key(|&row| state.data.address(row).trim().is_empty()); matching_rows.sort_unstable_by(|&left, &right| {
let left_address = state.data.address(left).trim();
let right_address = state.data.address(right).trim();
let left_focused = focus_address
.as_ref()
.is_some_and(|address| left_address.eq_ignore_ascii_case(address));
let right_focused = focus_address
.as_ref()
.is_some_and(|address| right_address.eq_ignore_ascii_case(address));
right_focused
.cmp(&left_focused)
.then(left_address.is_empty().cmp(&right_address.is_empty()))
});
let total = matching_rows.len(); let total = matching_rows.len();
let limit = params let limit = params

View file

@ -54,6 +54,9 @@ pub async fn get_travel_destinations(
.iter() .iter()
.enumerate() .enumerate()
.filter_map(|(idx, name)| { .filter_map(|(idx, name)| {
if !pd.travel_destination[idx] {
return None;
}
let slug = slugify(name); let slug = slugify(name);
if slug_set.contains(&slug) { if slug_set.contains(&slug) {
Some((idx, slug, pd.type_rank[idx], pd.population[idx], name.len())) Some((idx, slug, pd.type_rank[idx], pd.population[idx], name.len()))