sus
Some checks failed
CI / Check (push) Failing after 7m12s
Build and publish Docker image / build-and-push (push) Successful in 7m25s

This commit is contained in:
Andras Schmelczer 2026-07-12 15:11:21 +01:00
parent 920119ff48
commit badab57438
3 changed files with 189 additions and 33 deletions

View file

@ -6,7 +6,7 @@ import HexCanvas from './HexCanvas';
import HomeFinalCta from './HomeFinalCta'; import HomeFinalCta from './HomeFinalCta';
import BottomIllustration from './BottomIllustration'; import BottomIllustration from './BottomIllustration';
import { TickerValue } from '../ui/TickerValue'; import { TickerValue } from '../ui/TickerValue';
import { ChevronIcon, LogoIcon, PlayIcon } from '../ui/icons'; import { CheckIcon, ChevronIcon, LogoIcon, PlayIcon } from '../ui/icons';
import { trackEvent } from '../../lib/analytics'; import { trackEvent } from '../../lib/analytics';
import { apiUrl } from '../../lib/api'; import { apiUrl } from '../../lib/api';
@ -17,7 +17,7 @@ const HOME_SECTION_HEADING_CLASS =
'text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100'; 'text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100';
const HOME_BODY_CLASS = 'text-base leading-relaxed text-warm-600 dark:text-warm-400'; const HOME_BODY_CLASS = 'text-base leading-relaxed text-warm-600 dark:text-warm-400';
const HOME_PRIMARY_BUTTON_CLASS = const HOME_PRIMARY_BUTTON_CLASS =
'border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-base shadow-lg shadow-[#7a3905]/25 text-center'; 'border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-base shadow-lg shadow-[#7a3905]/25 text-center focus:outline-none focus-visible:ring-4 focus-visible:ring-teal-300/80';
const PRODUCT_DEMO_VIDEO_BY_LANGUAGE: Record<string, string> = { const PRODUCT_DEMO_VIDEO_BY_LANGUAGE: Record<string, string> = {
en: 'recording', en: 'recording',
de: 'recording-de', de: 'recording-de',
@ -40,7 +40,7 @@ function getProductDemoSlug(language: string | undefined, isMobile: boolean): st
const code = language?.toLowerCase().split('-')[0] ?? 'en'; const code = language?.toLowerCase().split('-')[0] ?? 'en';
const base = PRODUCT_DEMO_VIDEO_BY_LANGUAGE[code] ?? PRODUCT_DEMO_VIDEO_BY_LANGUAGE.en; const base = PRODUCT_DEMO_VIDEO_BY_LANGUAGE[code] ?? PRODUCT_DEMO_VIDEO_BY_LANGUAGE.en;
// Mobile cuts (9:16, 540x960) are published as `<base>-mobile` alongside // Mobile cuts (9:16, 540x960) are published as `<base>-mobile` alongside
// the 16:9 desktop cuts. The recorder pipeline writes both every render // the 16:9 desktop cuts. The recorder pipeline writes both every render:
// see video/src/storyboard.ts. // see video/src/storyboard.ts.
return isMobile ? `${base}-mobile` : base; return isMobile ? `${base}-mobile` : base;
} }
@ -238,6 +238,93 @@ function PriceStrip({
); );
} }
const TWIN_SECTION_ID = 'twin-proof';
/**
* "Cheaper twin" proof block: real, verified figures (not illustrative).
* Median £/sqm for Land Registry "Flats/Maisonettes" sold since 2021, derived from
* epc_pp.parquet; school/commute parity checked against postcode.parquet + modelled
* transit times to central London. Sector grain (N1 1 / N7 9) is LOAD-BEARING. Do
* not relabel as bare N1/N7: the gap collapses to ~14% at outward-district level.
* Re-run the query periodically as new sales land; figures drift slightly over time.
*/
function TwinProof({ onOpenDashboard }: { onOpenDashboard: () => void }) {
const { t } = useTranslation();
const pricey = { name: 'Angel', sector: 'N1 1', perSqm: 11356 };
const value = { name: 'Holloway', sector: 'N7 9', perSqm: 7943 };
const deltaPct = Math.round((1 - value.perSqm / pricey.perSqm) * 100);
const fmt = (n: number) => `£${n.toLocaleString('en-GB')}`;
return (
<div id={TWIN_SECTION_ID} className={`${HOME_SECTION_CONTAINER_CLASS} pt-12 md:pt-20 pb-2`}>
<div className="mx-auto max-w-4xl rounded-2xl border border-warm-200 bg-white/90 p-6 shadow-sm dark:border-warm-700 dark:bg-warm-800/90 md:p-8">
<div className="mb-3 flex flex-wrap items-center gap-x-3 gap-y-2">
<span className="rounded-full bg-teal-50 px-3 py-1 text-xs font-bold uppercase tracking-wide text-teal-700 dark:bg-teal-900/30 dark:text-teal-300">
{t('home.twinBadge')}
</span>
<h2 className={HOME_SECTION_HEADING_CLASS}>{t('home.twinTitle')}</h2>
</div>
<p className={`${HOME_BODY_CLASS} mb-6 max-w-2xl`}>{t('home.twinIntro')}</p>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-xl border border-warm-200 bg-warm-50 p-5 dark:border-warm-700 dark:bg-navy-950/40">
<div className="text-xs font-semibold uppercase tracking-wide text-warm-500 dark:text-warm-400">
{t('home.twinPriceyLabel')}
</div>
<div className="mt-1 text-2xl font-extrabold text-navy-950 dark:text-warm-100">
{pricey.name}{' '}
<span className="text-base font-semibold text-warm-500 dark:text-warm-400">
{pricey.sector}
</span>
</div>
<div className="mt-2 text-lg font-bold tabular-nums text-warm-700 dark:text-warm-300">
{t('home.twinPerSqm', { value: fmt(pricey.perSqm) })}
</div>
</div>
<div className="rounded-xl border-2 border-teal-400 bg-teal-50/60 p-5 dark:border-teal-500 dark:bg-teal-900/20">
<div className="text-xs font-semibold uppercase tracking-wide text-teal-700 dark:text-teal-300">
{t('home.twinValueLabel')}
</div>
<div className="mt-1 text-2xl font-extrabold text-navy-950 dark:text-warm-100">
{value.name}{' '}
<span className="text-base font-semibold text-teal-700/70 dark:text-teal-300/70">
{value.sector}
</span>
</div>
<div className="mt-2 text-lg font-bold tabular-nums text-teal-700 dark:text-teal-300">
{t('home.twinPerSqm', { value: fmt(value.perSqm) })}
</div>
<div className="mt-3 text-sm font-bold tabular-nums text-teal-700 dark:text-teal-300">
{t('home.twinDelta', { percent: `${deltaPct}%` })}
</div>
</div>
</div>
<div className="mt-5 flex flex-col gap-2 text-sm font-medium text-warm-600 dark:text-warm-400 sm:flex-row sm:gap-6">
<span className="flex items-center gap-2">
<CheckIcon className="h-4 w-4 shrink-0 text-teal-600 dark:text-teal-400" />
{t('home.twinSameSchool')}
</span>
<span className="flex items-center gap-2">
<CheckIcon className="h-4 w-4 shrink-0 text-teal-600 dark:text-teal-400" />
{t('home.twinSameCommute')}
</span>
</div>
<p className="mt-4 text-xs italic text-warm-500 dark:text-warm-400">
{t('home.twinFootnote')}
</p>
<button
onClick={() => {
trackEvent('CTA Click', { location: 'twin', label: 'explore_map' });
onOpenDashboard();
}}
className={`mt-5 w-full sm:w-auto px-6 py-3 ${HOME_PRIMARY_BUTTON_CLASS}`}
>
{t('home.twinCta')}
</button>
</div>
</div>
);
}
export default function HomePage({ export default function HomePage({
onOpenDashboard, onOpenDashboard,
onOpenPricing, onOpenPricing,
@ -326,7 +413,7 @@ export default function HomePage({
// Scroll depth tracking // Scroll depth tracking
const scrolledSections = useRef(new Set<string>()); const scrolledSections = useRef(new Set<string>());
useEffect(() => { useEffect(() => {
const ids = ['how-it-works']; const ids = ['comparison'];
const observers: IntersectionObserver[] = []; const observers: IntersectionObserver[] = [];
ids.forEach((id) => { ids.forEach((id) => {
const el = document.getElementById(id); const el = document.getElementById(id);
@ -352,8 +439,8 @@ export default function HomePage({
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, []); }, []);
const scrollToProductDemoVideo = () => { const scrollToSection = (sectionId: string) => {
const target = document.getElementById(PRODUCT_DEMO_SECTION_ID); const target = document.getElementById(sectionId);
if (!target) return; if (!target) return;
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null; const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
if (!scroller) return; if (!scroller) return;
@ -402,37 +489,38 @@ export default function HomePage({
<p className="text-base md:text-lg text-warm-200 mb-8 max-w-xl"> <p className="text-base md:text-lg text-warm-200 mb-8 max-w-xl">
{highlightBrandText(t('home.heroDescription'), 'font-semibold text-teal-300')} {highlightBrandText(t('home.heroDescription'), 'font-semibold text-teal-300')}
</p> </p>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-5"> <div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-3">
<button <button
onClick={() => { onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' }); trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
onOpenDashboard(); onOpenDashboard();
}} }}
className={`px-7 py-3.5 ${HOME_PRIMARY_BUTTON_CLASS}`} className={`w-full sm:w-auto px-7 py-3.5 ${HOME_PRIMARY_BUTTON_CLASS}`}
> >
{t('home.exploreTheMap')} {t('home.exploreTheMap')}
</button> </button>
<button <button
onClick={() => { onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'see_difference' }); trackEvent('CTA Click', { location: 'hero', label: 'see_difference' });
scrollToProductDemoVideo(); scrollToSection(TWIN_SECTION_ID);
}} }}
className="px-7 py-3 border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base text-center" className="w-full sm:w-auto px-7 py-3 border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base text-center focus:outline-none focus-visible:ring-4 focus-visible:ring-teal-300/60"
> >
{t('home.seeTheDifference')} {t('home.seeTheDifference')}
</button> </button>
</div> </div>
<p className="text-sm font-medium text-teal-200 mb-4">{t('home.freeToExplore')}</p>
<PriceStrip onOpenPricing={onOpenPricing} hidePricing={hidePricing} /> <PriceStrip onOpenPricing={onOpenPricing} hidePricing={hidePricing} />
<p className="text-sm text-warm-400 mb-8">{t('home.coverageNote')}</p> <p className="text-sm text-warm-400 mb-6">{t('home.coverageNote')}</p>
<div className="home-hero-stats flex flex-wrap pt-3 border-t border-white/10"> <div className="home-hero-stats flex flex-wrap pt-3 border-t border-white/10">
<div className="home-hero-stat"> <div className="home-hero-stat">
<div className="home-hero-stat-value"> <div className="home-hero-stat-value tabular-nums">
<TickerValue text="13M" active={statsActive} /> <TickerValue text="13M" active={statsActive} />
</div> </div>
<div className="home-hero-stat-label">{t('home.statProperties')}</div> <div className="home-hero-stat-label">{t('home.statProperties')}</div>
</div> </div>
<div className="home-hero-stat"> <div className="home-hero-stat">
<div className="home-hero-stat-value"> <div className="home-hero-stat-value tabular-nums">
<TickerValue text="40+" active={statsActive} /> <TickerValue text="40+" active={statsActive} />
</div> </div>
<div className="home-hero-stat-label">{t('home.statFilters')}</div> <div className="home-hero-stat-label">{t('home.statFilters')}</div>
@ -442,6 +530,10 @@ export default function HomePage({
<div className="home-hero-stat-label">{t('home.statPostcodeInEngland')}</div> <div className="home-hero-stat-label">{t('home.statPostcodeInEngland')}</div>
</div> </div>
</div> </div>
<p className="mt-5 text-xs font-medium uppercase tracking-wide text-warm-400">
<span className="text-warm-300">{t('home.dataSourcesLabel')}:</span>{' '}
{t('home.dataSources')}
</p>
</div> </div>
<Suspense fallback={<ProductShowcaseFallback />}> <Suspense fallback={<ProductShowcaseFallback />}>
<ProductShowcase /> <ProductShowcase />
@ -454,7 +546,7 @@ export default function HomePage({
aria-label={t('home.scrollToProductDemo')} aria-label={t('home.scrollToProductDemo')}
onClick={() => { onClick={() => {
trackEvent('CTA Click', { location: 'hero_chevron', label: 'scroll_down' }); trackEvent('CTA Click', { location: 'hero_chevron', label: 'scroll_down' });
scrollToProductDemoVideo(); scrollToSection(TWIN_SECTION_ID);
}} }}
> >
<ChevronIcon direction="down" className="h-14 w-14" /> <ChevronIcon direction="down" className="h-14 w-14" />
@ -463,7 +555,8 @@ export default function HomePage({
<div ref={homeSurfaceRef} className="home-content-surface relative overflow-hidden"> <div ref={homeSurfaceRef} className="home-content-surface relative overflow-hidden">
<div className="relative z-10"> <div className="relative z-10">
<ProductDemoVideo /> {/* Concrete proof of the "cheaper twin" claim, high on the page */}
<TwinProof onOpenDashboard={onOpenDashboard} />
{/* Our philosophy */} {/* Our philosophy */}
<div className={`${HOME_SECTION_CONTAINER_CLASS} pt-12 md:pt-20 pb-4`}> <div className={`${HOME_SECTION_CONTAINER_CLASS} pt-12 md:pt-20 pb-4`}>
@ -509,12 +602,9 @@ export default function HomePage({
</div> </div>
{/* Comparison table */} {/* Comparison table */}
<div <div id="comparison" className={`${HOME_SECTION_CONTAINER_CLASS} pt-10 md:pt-16 pb-2`}>
id="how-it-works"
className={`${HOME_SECTION_CONTAINER_CLASS} pt-10 md:pt-16 pb-2`}
>
<div ref={whyRef} className="fade-in-section"> <div ref={whyRef} className="fade-in-section">
<div id="comparison"> <div>
<h2 <h2
className={`${HOME_SECTION_HEADING_CLASS} mb-6 flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-center md:mb-10 md:gap-x-3`} className={`${HOME_SECTION_HEADING_CLASS} mb-6 flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-center md:mb-10 md:gap-x-3`}
> >
@ -547,24 +637,35 @@ export default function HomePage({
subtitle: t('home.compSearchWithoutSub'), subtitle: t('home.compSearchWithoutSub'),
postcode: false, postcode: false,
guides: false, guides: false,
perfect: true,
}, },
{ {
feature: t('home.compAreaData'), feature: t('home.compAreaData'),
subtitle: t('home.compAreaDataSub'), subtitle: t('home.compAreaDataSub'),
postcode: true, postcode: true,
guides: true, guides: true,
perfect: true,
}, },
{ {
feature: t('home.compPropertyData'), feature: t('home.compPropertyData'),
subtitle: t('home.compPropertyDataSub'), subtitle: t('home.compPropertyDataSub'),
postcode: false, postcode: false,
guides: true, guides: true,
perfect: true,
}, },
{ {
feature: t('home.compFilters'), feature: t('home.compFilters'),
subtitle: t('home.compFiltersSub'), subtitle: t('home.compFiltersSub'),
postcode: false, postcode: false,
guides: false, guides: false,
perfect: true,
},
{
feature: t('home.compListings'),
subtitle: t('home.compListingsSub'),
postcode: true,
guides: false,
perfect: false,
}, },
].map((row, i, arr) => ( ].map((row, i, arr) => (
<tr <tr
@ -597,11 +698,15 @@ export default function HomePage({
); );
})} })}
<td <td
aria-label={t('common.yes')} aria-label={row.perfect ? t('common.yes') : t('common.no')}
className="px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base text-green-500 bg-teal-50 dark:bg-teal-900/30" className={`px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base bg-teal-50 dark:bg-teal-900/30 ${
row.perfect ? 'text-green-500' : 'text-red-500'
}`}
> >
<span aria-hidden="true">&#x2713;</span> <span aria-hidden="true">{row.perfect ? '✓' : '✗'}</span>
<span className="sr-only">{t('common.yes')}</span> <span className="sr-only">
{row.perfect ? t('common.yes') : t('common.no')}
</span>
</td> </td>
</tr> </tr>
))} ))}
@ -612,6 +717,10 @@ export default function HomePage({
</div> </div>
</div> </div>
{/* Full product walkthrough, kept as a later proof beat, after the
differentiation, instead of competing with the showcase in the hero */}
<ProductDemoVideo />
{/* The real cost CTA */} {/* The real cost CTA */}
<div className={`${HOME_SECTION_CONTAINER_CLASS} pt-8 md:pt-14 pb-12`}> <div className={`${HOME_SECTION_CONTAINER_CLASS} pt-8 md:pt-14 pb-12`}>
<div ref={ctaRef} className="fade-in-section"> <div ref={ctaRef} className="fade-in-section">

View file

@ -16,7 +16,7 @@ export const BUFFER_MULTIPLIER = 1;
/** Default initial map view (Canary Wharf), used when no explicit view is in the URL. */ /** Default initial map view (Canary Wharf), used when no explicit view is in the URL. */
export const CANARY_WHARF = { latitude: 51.5054, longitude: -0.0235 }; export const CANARY_WHARF = { latitude: 51.5054, longitude: -0.0235 };
/** 10 Downing Street geolocation fallback in dev so the "use my location" flow /** 10 Downing Street: geolocation fallback in dev so the "use my location" flow
* stays testable without a real fix (e.g. over http, where browsers deny geolocation). */ * stays testable without a real fix (e.g. over http, where browsers deny geolocation). */
export const DEV_LOCATION = { latitude: 51.5033635, longitude: -0.1276248 }; export const DEV_LOCATION = { latitude: 51.5033635, longitude: -0.1276248 };
@ -28,12 +28,12 @@ export const INITIAL_VIEW_STATE: ViewState = {
}; };
/** Anonymous (logged-out) users can apply at most this many filters at a time. The /** Anonymous (logged-out) users can apply at most this many filters at a time. The
* full dashboard is otherwise available everywhere there is no region lock. * full dashboard is otherwise available everywhere. There is no region lock.
* Must match the server-side `DEMO_MAX_FILTERS`. */ * Must match the server-side `DEMO_MAX_FILTERS`. */
export const DEMO_MAX_FILTERS = 3; export const DEMO_MAX_FILTERS = 3;
/** Registered but non-paying users get a higher filter allowance than anonymous /** Registered but non-paying users get a higher filter allowance than anonymous
* visitors the carrot for creating a free account. Paying/licensed users are * visitors: the carrot for creating a free account. Paying/licensed users are
* unlimited. Must match the server-side `REGISTERED_MAX_FILTERS`. */ * unlimited. Must match the server-side `REGISTERED_MAX_FILTERS`. */
export const REGISTERED_MAX_FILTERS = 5; export const REGISTERED_MAX_FILTERS = 5;
@ -44,6 +44,17 @@ export function filterCapFor(isLoggedIn: boolean, filtersUnlimited: boolean): nu
return isLoggedIn ? REGISTERED_MAX_FILTERS : DEMO_MAX_FILTERS; return isLoggedIn ? REGISTERED_MAX_FILTERS : DEMO_MAX_FILTERS;
} }
/** Funnel fix (growth): a cold map open lands empty, so first-time visitors never feel the value
* or the 3-filter cap. These two high-intent filters (value for money + good secondary schools)
* are pre-seeded when the map opens with no filters in the URL, so the map is immediately useful
* and one more filter hits the cap. Deep links (OG screenshots, the SEO landing-page CTAs) carry
* their own filters and are left untouched. Unknown feature names are dropped safely by useFilters.
* Tune or empty this object to change/disable the behaviour. */
export const DEFAULT_DEMO_FILTERS: Record<string, [number, number]> = {
// 'Est. price per sqm': [0, 7000],
// 'Good+ secondary school catchments': [1, 11],
};
/** /**
* Zoom to H3 resolution mapping thresholds. * Zoom to H3 resolution mapping thresholds.
* Returns the H3 resolution to use for a given zoom level. * Returns the H3 resolution to use for a given zoom level.
@ -60,8 +71,8 @@ export const SMALLEST_VISIBLE_HEXAGON_RESOLUTION = Math.max(
...ZOOM_TO_RESOLUTION_THRESHOLDS.map(({ resolution }) => resolution) ...ZOOM_TO_RESOLUTION_THRESHOLDS.map(({ resolution }) => resolution)
); );
// Shared limit at which the zoomed-in detail layers postcodes AND the map // Shared limit at which the zoomed-in detail layers, postcodes AND the map
// overlays (noise, crime, trees, property borders) start showing. Set just // overlays (noise, crime, trees, property borders), start showing. Set just
// past the finest hexagon level so detail appears while still relatively // past the finest hexagon level so detail appears while still relatively
// zoomed out. (Each overlay additionally can't render below its own tile-data // zoomed out. (Each overlay additionally can't render below its own tile-data
// floor, OVERLAY_MIN_ZOOM, regardless of this limit.) // floor, OVERLAY_MIN_ZOOM, regardless of this limit.)
@ -115,7 +126,7 @@ export function getFeatureGradient(featureName: string | null | undefined): Grad
: FEATURE_GRADIENT; : FEATURE_GRADIENT;
} }
/** Number of properties gradient light mode (cream → orange) */ /** Number of properties gradient: light mode (cream → orange) */
export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [ export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [255, 255, 255] }, { t: 0, color: [255, 255, 255] },
{ t: 0.1, color: [248, 233, 211] }, { t: 0.1, color: [248, 233, 211] },
@ -124,7 +135,7 @@ export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[]
{ t: 1, color: [255, 162, 31] }, { t: 1, color: [255, 162, 31] },
]; ];
/** Number of properties gradient dark mode (dark warm → bright amber) */ /** Number of properties gradient: dark mode (dark warm → bright amber) */
export const DENSITY_GRADIENT_DARK: { t: number; color: [number, number, number] }[] = [ export const DENSITY_GRADIENT_DARK: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [55, 45, 35] }, { t: 0, color: [55, 45, 35] },
{ t: 0.1, color: [85, 65, 40] }, { t: 0.1, color: [85, 65, 40] },
@ -144,7 +155,7 @@ export const FILTERED_OUT_FILL: Record<'light' | 'dark', [number, number, number
dark: [120, 114, 108, 50], dark: [120, 114, 108, 50],
}; };
/** Border for a filtered-out postcode a touch stronger than its fill so the /** Border for a filtered-out postcode: a touch stronger than its fill so the
* polygon outline stays legible. */ * polygon outline stays legible. */
export const FILTERED_OUT_LINE: Record<'light' | 'dark', [number, number, number, number]> = { export const FILTERED_OUT_LINE: Record<'light' | 'dark', [number, number, number, number]> = {
light: [110, 110, 110, 80], light: [110, 110, 110, 80],
@ -256,7 +267,7 @@ export const POI_CLUSTER_MAX_ZOOM = 15;
export const POI_AUTO_CARD_ZOOM_THRESHOLD = POI_CLUSTER_MAX_ZOOM + 1; export const POI_AUTO_CARD_ZOOM_THRESHOLD = POI_CLUSTER_MAX_ZOOM + 1;
/** Hard cap on auto POI cards rendered at once. With every category enabled a /** Hard cap on auto POI cards rendered at once. With every category enabled a
* dense area can yield hundreds of overlapping cards an unreadable wall so we * dense area can yield hundreds of overlapping cards (an unreadable wall), so we
* show a spaced subset and rely on the map markers (+ hover) for the rest. */ * show a spaced subset and rely on the map markers (+ hover) for the rest. */
export const MAX_AUTO_POI_CARDS = 40; export const MAX_AUTO_POI_CARDS = 40;
/** Minimum screen-space gap (px) between two auto POI cards before one is culled. */ /** Minimum screen-space gap (px) between two auto POI cards before one is culled. */

View file

@ -0,0 +1,36 @@
// @generated by analysis/build_pages.py. Do not edit by hand.
// Registers the data-driven growth pages so og_middleware serves them (not 404) with the
// right title/description and an OG card pointed at the finding's map view.
pub struct DataPage {
pub path: &'static str,
pub title: &'static str,
pub description: &'static str,
/// Map query string the OG screenshot should frame (empty = default map).
pub screenshot_query: &'static str,
}
pub static DATA_PAGES: &[DataPage] = &[
DataPage { path: "/cheaper-twin/br3-3-vs-cr0-7", title: "Beckenham vs Croydon: the same terraced house, about 31% cheaper per m²", description: "£201,870 less for an equivalent terraced house: same station, similar schools, ~2.02km apart", screenshot_query: "lat=51.38969&lon=-0.04244&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5200&filter=Good%2B%20secondary%20school%20catchments:1:11" },
DataPage { path: "/cheaper-twin/ha7-2-vs-ha3-0", title: "Stanmore vs Kenton: the same semi-detached house, about 17% cheaper per m²", description: "£106,920 less for an equivalent semi-detached house: same station, similar schools, ~2.57km apart", screenshot_query: "lat=51.59199&lon=-0.3079&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11" },
DataPage { path: "/cheaper-twin/ig8-7-vs-ig6-2", title: "Woodford Green vs Barkingside: the same terraced house, about 26% cheaper per m²", description: "£164,070 less for an equivalent terraced house: same station, similar schools, ~2.98km apart", screenshot_query: "lat=51.60238&lon=0.06063&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5600&filter=Good%2B%20secondary%20school%20catchments:1:11" },
DataPage { path: "/cheaper-twin/l16-7-vs-l14-6", title: "Childwall vs Broadgreen: the same semi-detached house, about 30% cheaper per m²", description: "£106,740 less for an equivalent semi-detached house: same station, similar schools, ~1.88km apart", screenshot_query: "lat=53.40344&lon=-2.88529&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3000&filter=Good%2B%20secondary%20school%20catchments:1:11" },
DataPage { path: "/cheaper-twin/m40-5-vs-m9-4", title: "Newton Heath vs Harpurhey: the same terraced house, about 42% cheaper per m²", description: "£106,740 less for an equivalent terraced house: same station, similar schools, ~1.18km apart", screenshot_query: "lat=53.51293&lon=-2.19574&zoom=12.5&filter=Est.%20price%20per%20sqm:0:1700&filter=Good%2B%20secondary%20school%20catchments:1:11" },
DataPage { path: "/cheaper-twin/rm14-2-vs-rm12-5", title: "Upminster vs Hornchurch: the same semi-detached house, about 20% cheaper per m²", description: "£115,290 less for an equivalent semi-detached house: same station, similar schools, ~2.99km apart", screenshot_query: "lat=51.54892&lon=0.22193&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5300&filter=Good%2B%20secondary%20school%20catchments:1:11" },
DataPage { path: "/cheaper-twin/se28-8-vs-da18-4", title: "SE28 8 vs DA18 4: the same terraced house, about 30% cheaper per m²", description: "£129,690 less for an equivalent terraced house: same station, similar schools, ~1.72km apart", screenshot_query: "lat=51.50039&lon=0.12568&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3600&filter=Good%2B%20secondary%20school%20catchments:1:11" },
DataPage { path: "/cheaper-twin/sw1x-8-vs-sw7-2", title: "SW1X 8 vs SW7 2: the same flat, about 42% cheaper per m²", description: "£1,001,160 less for an equivalent flat: same station, similar schools, ~1.31km apart", screenshot_query: "lat=51.49758&lon=-0.16439&zoom=12.5&filter=Est.%20price%20per%20sqm:0:16400&filter=Good%2B%20secondary%20school%20catchments:1:11" },
DataPage { path: "/cheaper-twin/tw12-3-vs-kt8-1", title: "Hampton vs East Molesey: the same terraced house, about 19% cheaper per m²", description: "£120,060 less for an equivalent terraced house: same station, similar schools, ~2.23km apart", screenshot_query: "lat=51.41616&lon=-0.37365&zoom=12.5&filter=Est.%20price%20per%20sqm:0:6000&filter=Good%2B%20secondary%20school%20catchments:1:11" },
DataPage { path: "/cheaper-twin/tw2-7-vs-tw3-2", title: "Twickenham vs Hounslow: the same semi-detached house, about 19% cheaper per m²", description: "£121,590 less for an equivalent semi-detached house: same station, similar schools, ~1.0km apart", screenshot_query: "lat=51.45678&lon=-0.35702&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11" },
DataPage { path: "/cheaper-twin/w1j-7-vs-sw7-3", title: "W1J 7 vs SW7 3: the same flat, about 41% cheaper per m²", description: "£1,223,460 less for an equivalent flat: same station, similar schools, ~2.6km apart", screenshot_query: "lat=51.49856&lon=-0.16253&zoom=12.5&filter=Est.%20price%20per%20sqm:0:20400&filter=Good%2B%20secondary%20school%20catchments:1:11" },
DataPage { path: "/cheaper-twin/w1j-8-vs-sw1a-2", title: "W1J 8 vs SW1A 2: the same flat, about 37% cheaper per m²", description: "£916,380 less for an equivalent flat: same station, similar schools, ~1.31km apart", screenshot_query: "lat=51.50663&lon=-0.13494&zoom=12.5&filter=Est.%20price%20per%20sqm:0:17900&filter=Good%2B%20secondary%20school%20catchments:1:11" },
DataPage { path: "/cheaper-twin/w1k-2-vs-sw1x-0", title: "W1K 2 vs SW1X 0: the same flat, about 32% cheaper per m²", description: "£978,570 less for an equivalent flat: same station, similar schools, ~1.62km apart", screenshot_query: "lat=51.50311&lon=-0.15673&zoom=12.5&filter=Est.%20price%20per%20sqm:0:24700&filter=Good%2B%20secondary%20school%20catchments:1:11" },
DataPage { path: "/cheaper-twin/w1u-4-vs-nw1-4", title: "Marylebone vs Camden: the same flat, about 43% cheaper per m²", description: "£942,480 less for an equivalent flat: same station, similar schools, ~0.97km apart", screenshot_query: "lat=51.5238&lon=-0.15091&zoom=12.5&filter=Est.%20price%20per%20sqm:0:14500&filter=Good%2B%20secondary%20school%20catchments:1:11" },
DataPage { path: "/cheaper-twin/wc2a-2-vs-ec2a-2", title: "WC2A 2 vs EC2A 2: the same flat, about 43% cheaper per m²", description: "£981,540 less for an equivalent flat: same station, similar schools, ~2.3km apart", screenshot_query: "lat=51.51807&lon=-0.09837&zoom=12.5&filter=Est.%20price%20per%20sqm:0:15300&filter=Good%2B%20secondary%20school%20catchments:1:11" },
DataPage { path: "/square-metres-per-100k", title: "How many square metres £100,000 buys across England", description: "£100k buys ~152 m² of floor space in BD21 3 but only ~3 m² in Mayfair (W1K 2)", screenshot_query: "zoom=6&filter=Est.%20price%20per%20sqm:0:4000" },
DataPage { path: "/cheaper-twins", title: "Cheaper twin postcodes in England", description: "Neighbouring England postcodes priced apart for the name, not the home.", screenshot_query: "" },
];
/// Look up a generated data page by request path (already trailing-slash-trimmed).
pub fn data_page(path: &str) -> Option<&'static DataPage> {
DATA_PAGES.iter().find(|p| p.path == path)
}