ruby homepage changes
This commit is contained in:
parent
94ebd0f614
commit
7777d4046e
7 changed files with 312 additions and 223 deletions
|
|
@ -5,6 +5,7 @@ import ScrollStory from './ScrollStory';
|
||||||
import BottomIllustration from './BottomIllustration';
|
import BottomIllustration from './BottomIllustration';
|
||||||
import { TickerValue } from '../ui/TickerValue';
|
import { TickerValue } from '../ui/TickerValue';
|
||||||
import { ChevronIcon } from '../ui/icons/ChevronIcon';
|
import { ChevronIcon } from '../ui/icons/ChevronIcon';
|
||||||
|
import { LogoIcon } from '../ui/icons/LogoIcon';
|
||||||
import type { FeatureMeta } from '../../types';
|
import type { FeatureMeta } from '../../types';
|
||||||
|
|
||||||
export default function HomePage({
|
export default function HomePage({
|
||||||
|
|
@ -27,7 +28,6 @@ export default function HomePage({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const whyRef = useFadeInRef();
|
const whyRef = useFadeInRef();
|
||||||
const howRef = useFadeInRef();
|
|
||||||
const ctaRef = useFadeInRef();
|
const ctaRef = useFadeInRef();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -41,17 +41,16 @@ export default function HomePage({
|
||||||
<div className="relative z-10 max-w-4xl mx-auto px-6 md:px-10 pt-16 md:pt-24 backdrop-blur-[2px] flex-1 flex flex-col">
|
<div className="relative z-10 max-w-4xl mx-auto px-6 md:px-10 pt-16 md:pt-24 backdrop-blur-[2px] flex-1 flex flex-col">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-4 leading-[1.1] tracking-tight">
|
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-4 leading-[1.1] tracking-tight">
|
||||||
Maximum <span className="text-teal-400">Value</span>. Minimum Compromise.
|
Maximum <span className="text-teal-400">Value</span>.
|
||||||
|
<br />
|
||||||
|
Minimum Compromise.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-warm-300 mb-6 leading-relaxed max-w-xl">
|
<p className="text-lg text-warm-300 mb-6 leading-relaxed max-w-xl">
|
||||||
Buying a home may be your most important decision. Why not ensure you make your
|
House hunting? Make your biggest investment your smartest move.
|
||||||
best-ever decision?
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-lg text-warm-400 mb-8 max-w-lg">
|
<p className="text-lg text-warm-400 mb-8 max-w-xl">
|
||||||
You have so many options. Picking the best one is daunting and stressful. It
|
So many options — choosing the right one can feel overwhelming. Our interactive map makes it simple: select your must-haves and instantly see the areas that
|
||||||
won't be anymore when looking at the property landscape through our
|
fit.
|
||||||
interactive map. Simply pick your exact needs and our interactive map will show
|
|
||||||
you all areas that satisfy your requirements and more.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-4 mb-10">
|
<div className="flex items-center gap-4 mb-10">
|
||||||
<button
|
<button
|
||||||
|
|
@ -60,20 +59,32 @@ export default function HomePage({
|
||||||
>
|
>
|
||||||
Explore the map
|
Explore the map
|
||||||
</button>
|
</button>
|
||||||
{hidePricing ? (
|
<button
|
||||||
<span className="px-[26px] py-[12px] border-2 border-teal-400/50 text-teal-400 rounded-lg font-semibold text-base">
|
onClick={() => {
|
||||||
You have lifetime access!
|
const target = document.getElementById('comparison');
|
||||||
</span>
|
if (!target) return;
|
||||||
) : (
|
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
|
||||||
<button
|
if (!scroller) return;
|
||||||
onClick={onOpenPricing}
|
const start = scroller.scrollTop;
|
||||||
className="px-[26px] py-[12px] border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base"
|
const end = start + target.getBoundingClientRect().top - scroller.getBoundingClientRect().top - 48;
|
||||||
>
|
const distance = end - start;
|
||||||
Get lifetime access
|
const duration = 1200;
|
||||||
</button>
|
let startTime: number;
|
||||||
)}
|
const step = (time: number) => {
|
||||||
|
if (!startTime) startTime = time;
|
||||||
|
const t = Math.min((time - startTime) / duration, 1);
|
||||||
|
const ease = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||||
|
scroller.scrollTop = start + distance * ease;
|
||||||
|
if (t < 1) requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(step);
|
||||||
|
}}
|
||||||
|
className="px-[26px] py-[12px] border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base"
|
||||||
|
>
|
||||||
|
See the difference
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-12 pt-6 border-t border-white/10">
|
<div className="flex gap-12 pt-3 border-t border-white/10">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-2xl md:text-3xl font-bold text-white">
|
<div className="text-2xl md:text-3xl font-bold text-white">
|
||||||
<TickerValue text="13M" active={statsActive} />
|
<TickerValue text="13M" active={statsActive} />
|
||||||
|
|
@ -93,87 +104,145 @@ export default function HomePage({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<div className="flex flex-col items-center pb-8 animate-[bounce_3s_ease-in-out_infinite]">
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const target = document.getElementById('demo');
|
||||||
|
if (!target) return;
|
||||||
|
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
|
||||||
|
if (!scroller) return;
|
||||||
|
const start = scroller.scrollTop;
|
||||||
|
const end = start + target.getBoundingClientRect().top - scroller.getBoundingClientRect().top - 48;
|
||||||
|
const distance = end - start;
|
||||||
|
const duration = 1200;
|
||||||
|
let startTime: number;
|
||||||
|
const step = (time: number) => {
|
||||||
|
if (!startTime) startTime = time;
|
||||||
|
const t = Math.min((time - startTime) / duration, 1);
|
||||||
|
const ease = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||||
|
scroller.scrollTop = start + distance * ease;
|
||||||
|
if (t < 1) requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(step);
|
||||||
|
}}
|
||||||
|
className="flex flex-col items-center pb-8 mt-10 md:mt-0 animate-[bounce_3s_ease-in-out_infinite] cursor-pointer"
|
||||||
|
>
|
||||||
<p className="text-lg md:text-xl font-semibold text-warm-300 mb-2">
|
<p className="text-lg md:text-xl font-semibold text-warm-300 mb-2">
|
||||||
How does it work?
|
See It in Action
|
||||||
</p>
|
</p>
|
||||||
<ChevronIcon direction="down" className="w-6 h-6 text-warm-400" />
|
<ChevronIcon direction="down" className="w-6 h-6 text-warm-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* How to use it + Comparison table (two columns) */}
|
||||||
|
<div id="how-it-works" className="max-w-7xl mx-auto px-6 pt-20 pb-2">
|
||||||
|
<div ref={whyRef} className="fade-in-section">
|
||||||
|
<div className="grid lg:grid-cols-[2fr_3fr] gap-12 items-start">
|
||||||
|
{/* Left: How to use it */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10">
|
||||||
|
How to use it
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-8">
|
||||||
|
{HOW_STEPS.map((step, i) => (
|
||||||
|
<div key={i} className="flex gap-5">
|
||||||
|
<div className="shrink-0 w-10 h-10 rounded-full bg-teal-600 text-white flex items-center justify-center font-bold text-lg">
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-navy-950 dark:text-warm-100 mb-1">
|
||||||
|
{step.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-warm-600 dark:text-warm-400 leading-relaxed">
|
||||||
|
{step.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Right: Comparison table */}
|
||||||
|
<div id="comparison">
|
||||||
|
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10 flex items-center gap-3">
|
||||||
|
Others vs Perfect Postcode <LogoIcon className="w-8 h-8 text-teal-600 dark:text-teal-400" />
|
||||||
|
</h2>
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 shadow-sm">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800">
|
||||||
|
<th className="px-2 md:px-5 py-3 md:py-4 text-xs md:text-sm font-bold text-navy-950 dark:text-warm-100" />
|
||||||
|
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
|
||||||
|
Listing portals
|
||||||
|
</th>
|
||||||
|
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
|
||||||
|
{'\u201CCheck my postcode\u201D'}
|
||||||
|
</th>
|
||||||
|
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
|
||||||
|
Area guides
|
||||||
|
</th>
|
||||||
|
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-teal-700 dark:text-teal-400 text-center">
|
||||||
|
Perfect Postcode
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{FEATURE_ROWS.map((row, i) => (
|
||||||
|
<tr
|
||||||
|
key={row.feature}
|
||||||
|
className={
|
||||||
|
i < FEATURE_ROWS.length - 1
|
||||||
|
? 'border-b border-warm-100 dark:border-warm-800'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<td className="px-2 md:px-5 py-2.5 md:py-3.5 text-xs md:text-sm text-warm-700 dark:text-warm-300">
|
||||||
|
{row.feature}
|
||||||
|
{row.subtitle && (
|
||||||
|
<div className="italic text-warm-500 dark:text-warm-400">{row.subtitle}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{[row.listings, row.postcode, row.guides].map((has, j) => (
|
||||||
|
<td
|
||||||
|
key={j}
|
||||||
|
className={`px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base md:text-lg ${has ? 'text-green-500' : 'text-red-500'}`}
|
||||||
|
>
|
||||||
|
{has ? '\u2713' : '\u2717'}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base md:text-lg text-green-500">
|
||||||
|
✓
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollytelling: Problem + Solution + Demo map */}
|
{/* Scrollytelling: Problem + Solution + Demo map */}
|
||||||
|
<h2 id="demo" className="text-3xl font-bold text-navy-950 dark:text-warm-100 text-center pt-6 mb-4">
|
||||||
|
See It in Action
|
||||||
|
</h2>
|
||||||
|
<p className="text-warm-600 dark:text-warm-400 text-center max-w-2xl mx-auto mb-8 leading-relaxed">
|
||||||
|
Listings only show what's on the market right now — a tiny, random slice.
|
||||||
|
They tell you nothing about the area, or potential opportunities. We flip the search:
|
||||||
|
start with what matters to you, and the right places reveal themselves.
|
||||||
|
</p>
|
||||||
<ScrollStory features={features} theme={theme} />
|
<ScrollStory features={features} theme={theme} />
|
||||||
|
|
||||||
{/* Why existing tools don't cut it */}
|
|
||||||
<div className="max-w-4xl mx-auto px-6 py-20">
|
|
||||||
<div ref={whyRef} className="fade-in-section">
|
|
||||||
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10 text-center">
|
|
||||||
Why existing tools don't cut it
|
|
||||||
</h2>
|
|
||||||
<div className="grid md:grid-cols-3 gap-6">
|
|
||||||
{WHY_CARDS.map((card) => (
|
|
||||||
<div
|
|
||||||
key={card.title}
|
|
||||||
className="rounded-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 p-6 shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="text-2xl mb-3">{card.icon}</div>
|
|
||||||
<h3 className="font-bold text-navy-950 dark:text-warm-100 mb-2">{card.title}</h3>
|
|
||||||
<p className="text-warm-600 dark:text-warm-400 text-sm leading-relaxed">
|
|
||||||
{card.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-center mt-10 text-lg text-warm-700 dark:text-warm-300 max-w-2xl mx-auto leading-relaxed">
|
|
||||||
We do. 13 million historical transactions. 56 filters. Real travel-time routing to
|
|
||||||
any destination. Every postcode in England, scored and filterable, on a single map.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* How to use it */}
|
|
||||||
<div className="max-w-3xl mx-auto px-6 pb-20">
|
|
||||||
<div ref={howRef} className="fade-in-section">
|
|
||||||
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10 text-center">
|
|
||||||
How to use it
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-8">
|
|
||||||
{HOW_STEPS.map((step, i) => (
|
|
||||||
<div key={i} className="flex gap-5">
|
|
||||||
<div className="shrink-0 w-10 h-10 rounded-full bg-teal-600 text-white flex items-center justify-center font-bold text-lg">
|
|
||||||
{i + 1}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-bold text-navy-950 dark:text-warm-100 mb-1">
|
|
||||||
{step.title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-warm-600 dark:text-warm-400 leading-relaxed">
|
|
||||||
{step.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* The real cost CTA */}
|
{/* The real cost CTA */}
|
||||||
<div className="max-w-3xl mx-auto px-6 pb-12">
|
<div className="max-w-3xl mx-auto px-6 pt-20 pb-12">
|
||||||
<div ref={ctaRef} className="fade-in-section text-center">
|
<div ref={ctaRef} className="fade-in-section text-center">
|
||||||
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-4 leading-snug">
|
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-4 leading-snug">
|
||||||
The biggest financial decision of your life
|
The biggest financial decision of your life
|
||||||
<br />
|
<br />
|
||||||
deserves proper tools behind it.
|
deserves proper tools behind it.
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-warm-600 dark:text-warm-400 mb-3 max-w-xl mx-auto leading-relaxed">
|
<p className="text-warm-600 dark:text-warm-400 mb-8 max-w-xl mx-auto leading-relaxed">
|
||||||
Stamp duty on a £400k house: £10,000. Solicitor fees: £1,500.
|
Don't leave it to chance.
|
||||||
Survey: £500. Moving costs: £1,000. And that's just the money. Get the
|
|
||||||
wrong area and you're stuck — with a long commute, bad schools, or a street
|
|
||||||
that looked fine on the listing photos but turns out to be on a motorway.
|
|
||||||
</p>
|
|
||||||
<p className="text-warm-700 dark:text-warm-300 mb-8 font-semibold">
|
|
||||||
One payment. Lifetime access. Less than your survey costs and vastly more useful.
|
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={onOpenDashboard}
|
onClick={onOpenDashboard}
|
||||||
|
|
@ -191,46 +260,33 @@ export default function HomePage({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const WHY_CARDS = [
|
const FEATURE_ROWS = [
|
||||||
{
|
// listings postcode guides
|
||||||
icon: '\u{1F3D8}\uFE0F',
|
{ feature: 'Search without choosing an area first', subtitle: '(start with needs, not a location)', listings: false, postcode: false, guides: false },
|
||||||
title: 'Listing portals',
|
{ feature: 'Area data', subtitle: '(crime, schools, noise, broadband)', listings: false, postcode: true, guides: true },
|
||||||
description:
|
{ feature: 'Property-specific data', subtitle: '(price, EPC, floor area)', listings: true, postcode: false, guides: false },
|
||||||
"Show you what's for sale today. That's a snapshot, not a strategy. You can filter by price and bedrooms \u2014 that's about it. They tell you nothing about the area.",
|
{ feature: '56 combinable filters in one place', subtitle: '(all insights, one interactive map)', listings: false, postcode: false, guides: false },
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '\u{1F4CD}',
|
|
||||||
title: '\u201CCheck my postcode\u201D sites',
|
|
||||||
description:
|
|
||||||
"Give you stats for one postcode at a time. Useful if you already know where to look. Useless if you don't \u2014 and there are 1.5 million postcodes in England.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '\u{1F5FA}\uFE0F',
|
|
||||||
title: 'Area guides',
|
|
||||||
description:
|
|
||||||
"Show one statistic on a map \u2014 crime, or school ratings, or prices. But you care about the intersection: affordable AND safe AND good schools AND short commute. Nobody else shows you that.",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const HOW_STEPS = [
|
const HOW_STEPS = [
|
||||||
{
|
{
|
||||||
title: 'Set your non-negotiables',
|
title: 'Set your must-haves',
|
||||||
description:
|
description:
|
||||||
'Budget, commute, bedrooms, whatever matters most. The map narrows to only the areas that qualify.',
|
'Budget, commute, schools \u2014 the map shows only what qualifies.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Explore what\u2019s left",
|
title: 'Explore areas and discover hidden gems',
|
||||||
description:
|
description:
|
||||||
"Zoom in. Toggle layers. See crime, schools, noise, amenities. Discover areas you didn\u2019t know existed.",
|
'Zoom in, dig into details and nice to haves.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Drill into postcodes',
|
title: 'Drill into postcodes',
|
||||||
description:
|
description:
|
||||||
'At street level, see individual properties, what they sold for, floor area, energy rating, estimated current value.',
|
'See individual properties, sale prices, floor area, and compare.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Go to viewings with a shortlist, not a prayer',
|
title: 'Shortlist with confidence',
|
||||||
description:
|
description:
|
||||||
"You\u2019ve already done the hard part. Every area on your list meets your actual criteria, not just what happened to be listed that week.",
|
'Every area on your list meets your actual criteria \u2014 not just what was listed that week.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,13 @@ import MapComponent from '../map/Map';
|
||||||
import { apiUrl, assertOk, authHeaders, logNonAbortError } from '../../lib/api';
|
import { apiUrl, assertOk, authHeaders, logNonAbortError } from '../../lib/api';
|
||||||
import { formatValue } from '../../lib/format';
|
import { formatValue } from '../../lib/format';
|
||||||
import { zoomToResolution } from '../../lib/map-utils';
|
import { zoomToResolution } from '../../lib/map-utils';
|
||||||
|
import { FEATURE_GRADIENT } from '../../lib/consts';
|
||||||
|
import { gradientToCss } from '../../lib/utils';
|
||||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||||
import type { FeatureMeta, HexagonData } from '../../types';
|
import type { FeatureMeta, HexagonData } from '../../types';
|
||||||
|
|
||||||
const DEMO_VIEW_START = { longitude: -1.9, latitude: 52.2, zoom: 5.5, pitch: 0 };
|
const DEMO_VIEW_START = { longitude: -0.12, latitude: 51.51, zoom: 5.5, pitch: 0 };
|
||||||
const DEMO_VIEW_END = { longitude: -1.9, latitude: 52.2, zoom: 12, pitch: 0 };
|
const DEMO_VIEW_END = { longitude: -0.12, latitude: 51.51, zoom: 7, pitch: 0 };
|
||||||
|
|
||||||
function easeOutCubic(t: number): number {
|
function easeOutCubic(t: number): number {
|
||||||
return 1 - Math.pow(1 - t, 3);
|
return 1 - Math.pow(1 - t, 3);
|
||||||
|
|
@ -28,7 +30,7 @@ interface StageDef {
|
||||||
|
|
||||||
const STAGES: StageDef[] = [
|
const STAGES: StageDef[] = [
|
||||||
// 0: No filters — the problem
|
// 0: No filters — the problem
|
||||||
{ filters: {} },
|
{ filters: {}, colorFeature: 'Estimated current price' },
|
||||||
// 1: Price filter — "affordable price"
|
// 1: Price filter — "affordable price"
|
||||||
{
|
{
|
||||||
filters: { 'Estimated current price': [0, 0.25] },
|
filters: { 'Estimated current price': [0, 0.25] },
|
||||||
|
|
@ -58,6 +60,7 @@ const STAGES: StageDef[] = [
|
||||||
'Good+ primary schools within 5km': [0.3, 1],
|
'Good+ primary schools within 5km': [0.3, 1],
|
||||||
'Number of restaurants within 2km': [0.15, 1],
|
'Number of restaurants within 2km': [0.15, 1],
|
||||||
},
|
},
|
||||||
|
colorFeature: 'Number of restaurants within 2km',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -74,27 +77,26 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
|
||||||
<strong className="text-navy-950 dark:text-warm-100">
|
<strong className="text-navy-950 dark:text-warm-100">
|
||||||
£300k–£400k
|
£300k–£400k
|
||||||
</strong>{' '}
|
</strong>{' '}
|
||||||
on a home. Your research method? Scrolling through listings and hoping for the best.
|
on a home, somewhere commutable to work in, say, London. Your research method? Picking some areas you think are good based on word of mouth... then hope for the best.
|
||||||
</p>
|
|
||||||
<p className="text-lg leading-relaxed mb-4">
|
|
||||||
Listings only show what's on the market <em>right now</em> — a tiny, random
|
|
||||||
slice of what's actually out there. You'll never see the 3-bed Victorian on a
|
|
||||||
quiet street that sold six months ago, or the one that'll list next month.
|
|
||||||
</p>
|
|
||||||
<p className="text-base italic text-warm-500 dark:text-warm-400">
|
|
||||||
Your home is not a box of cereal. Don't let a discount on the wrong property distract
|
|
||||||
you from finding the right one.
|
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: 'Set your requirements. The map shows you where they intersect.',
|
heading: null,
|
||||||
body: (
|
body: (
|
||||||
<p className="text-lg leading-relaxed">
|
<>
|
||||||
Say you want a home at an{' '}
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<strong className="text-navy-950 dark:text-warm-100">affordable price</strong>…
|
<div className="shrink-0 w-8 h-8 rounded-full bg-teal-600 text-white flex items-center justify-center font-bold text-sm">
|
||||||
</p>
|
1
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-navy-950 dark:text-warm-100">Set your must-haves</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg leading-relaxed">
|
||||||
|
Say you want a home at an{' '}
|
||||||
|
<strong className="text-navy-950 dark:text-warm-100">affordable price</strong>…
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -110,33 +112,25 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
|
||||||
{
|
{
|
||||||
heading: null,
|
heading: null,
|
||||||
body: (
|
body: (
|
||||||
<>
|
<p className="text-lg leading-relaxed">
|
||||||
<p className="text-lg leading-relaxed mb-4">
|
…and{' '}
|
||||||
…and{' '}
|
<strong className="text-navy-950 dark:text-warm-100">
|
||||||
<strong className="text-navy-950 dark:text-warm-100">
|
restaurants within walking distance
|
||||||
restaurants within walking distance
|
</strong>
|
||||||
</strong>
|
.
|
||||||
.
|
</p>
|
||||||
</p>
|
|
||||||
<p className="text-lg leading-relaxed font-semibold text-navy-950 dark:text-warm-100">
|
|
||||||
You haven't opened a single listing yet — and you already know exactly where to
|
|
||||||
focus.
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: null,
|
heading: null,
|
||||||
body: (
|
body: (
|
||||||
<>
|
<>
|
||||||
<p className="text-xl font-bold text-navy-950 dark:text-warm-100 mb-2">
|
<p className="text-lg leading-relaxed mb-4 font-semibold text-navy-950 dark:text-warm-100">
|
||||||
That's just three filters.
|
No area chosen. No listings browsed. Yet you already know exactly where your needs are met.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-lg leading-relaxed">
|
<p className="text-lg leading-relaxed">
|
||||||
We've built <strong className="text-navy-950 dark:text-warm-100">43</strong>.
|
That's just 3 filters. We've built <strong className="text-navy-950 dark:text-warm-100">56</strong> —
|
||||||
Spanning property prices, commute times, school ratings, crime rates, broadband speeds,
|
covering commute times, crime, broadband, noise, schools, amenities, and more.
|
||||||
road noise, energy efficiency, amenities, deprivation scores, demographics, and more. All
|
|
||||||
layered on top of each other, all filterable at once.
|
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|
@ -318,7 +312,7 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
||||||
const deferredHexData = useDeferredValue(hexData);
|
const deferredHexData = useDeferredValue(hexData);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section ref={sectionRef} className="relative">
|
<section ref={sectionRef} className="snap-start relative">
|
||||||
{/* Sticky map background */}
|
{/* Sticky map background */}
|
||||||
<div className="sticky top-0 h-[60vh] md:h-[calc(100dvh-3rem)] z-0">
|
<div className="sticky top-0 h-[60vh] md:h-[calc(100dvh-3rem)] z-0">
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
|
|
@ -396,6 +390,23 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Color legend */}
|
||||||
|
{viewFeatureName && colorRange && (
|
||||||
|
<div className="pt-4 border-t border-warm-200 dark:border-warm-700 transition-opacity duration-700">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wider text-warm-500 dark:text-warm-400 mb-2">
|
||||||
|
Colour
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-navy-950 dark:text-warm-100 mb-1.5">
|
||||||
|
{viewFeatureName}
|
||||||
|
</div>
|
||||||
|
<div className="h-2.5 rounded-full overflow-hidden" style={{ background: gradientToCss(FEATURE_GRADIENT) }} />
|
||||||
|
<div className="flex justify-between mt-1 text-xs text-warm-500 dark:text-warm-400">
|
||||||
|
<span>{formatValue(colorRange[0], viewMeta!)}</span>
|
||||||
|
<span>{formatValue(colorRange[1], viewMeta!)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import {
|
||||||
travelFieldKey,
|
travelFieldKey,
|
||||||
type TravelTimeInitial,
|
type TravelTimeInitial,
|
||||||
} from '../../hooks/useTravelTime';
|
} from '../../hooks/useTravelTime';
|
||||||
import { apiUrl, assertOk, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api';
|
import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api';
|
||||||
import { INITIAL_VIEW_STATE } from '../../lib/consts';
|
import { INITIAL_VIEW_STATE } from '../../lib/consts';
|
||||||
import { useLicense } from '../../hooks/useLicense';
|
import { useLicense } from '../../hooks/useLicense';
|
||||||
import UpgradeModal from '../ui/UpgradeModal';
|
import UpgradeModal from '../ui/UpgradeModal';
|
||||||
|
|
@ -94,7 +94,6 @@ export default function MapPage({
|
||||||
filters,
|
filters,
|
||||||
activeFeature,
|
activeFeature,
|
||||||
dragValue,
|
dragValue,
|
||||||
dragData,
|
|
||||||
pinnedFeature,
|
pinnedFeature,
|
||||||
enabledFeatures,
|
enabledFeatures,
|
||||||
viewFeature,
|
viewFeature,
|
||||||
|
|
@ -110,7 +109,6 @@ export default function MapPage({
|
||||||
handleTogglePin,
|
handleTogglePin,
|
||||||
handleSetPin,
|
handleSetPin,
|
||||||
handleCancelPin,
|
handleCancelPin,
|
||||||
updateBoundsInfo,
|
|
||||||
} = useFilters({
|
} = useFilters({
|
||||||
initialFilters,
|
initialFilters,
|
||||||
features,
|
features,
|
||||||
|
|
@ -159,14 +157,9 @@ export default function MapPage({
|
||||||
viewFeature,
|
viewFeature,
|
||||||
activeFeature,
|
activeFeature,
|
||||||
dragValue,
|
dragValue,
|
||||||
dragData,
|
|
||||||
travelTimeEntries: travelTime.entries,
|
travelTimeEntries: travelTime.entries,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
updateBoundsInfo(mapData.bounds, mapData.resolution);
|
|
||||||
}, [mapData.bounds, mapData.resolution, updateBoundsInfo]);
|
|
||||||
|
|
||||||
const selection = useHexagonSelection({
|
const selection = useHexagonSelection({
|
||||||
filters,
|
filters,
|
||||||
features,
|
features,
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,18 @@ export default function PricingPage({
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 max-w-2xl mx-auto px-6 pb-16 text-center">
|
||||||
|
<p className="text-warm-400 leading-relaxed mb-3">
|
||||||
|
Stamp duty on a £400k house: £10,000. Solicitor fees: £1,500.
|
||||||
|
Survey: £500. Moving costs: £1,000. And that's just the money. Get the
|
||||||
|
wrong area and you're stuck — with a long commute, bad schools, or a street
|
||||||
|
that looked fine on the listing photos but turns out to be on a motorway.
|
||||||
|
</p>
|
||||||
|
<p className="text-warm-200 font-semibold">
|
||||||
|
One payment. Lifetime access. Less than your survey costs and vastly more useful.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { useState, useCallback, useRef, useMemo } from 'react';
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
import type { FeatureMeta, FeatureFilters, Bounds, HexagonData, ApiResponse } from '../types';
|
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||||
import { apiUrl, logNonAbortError } from '../lib/api';
|
|
||||||
|
|
||||||
interface UseFiltersOptions {
|
interface UseFiltersOptions {
|
||||||
initialFilters: FeatureFilters;
|
initialFilters: FeatureFilters;
|
||||||
|
|
@ -8,15 +7,10 @@ interface UseFiltersOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||||
// Use refs for bounds/resolution so handleDragStart always has latest values
|
|
||||||
const boundsRef = useRef<Bounds | null>(null);
|
|
||||||
const resolutionRef = useRef<number>(8);
|
|
||||||
const [filters, setFilters] = useState<FeatureFilters>(initialFilters);
|
const [filters, setFilters] = useState<FeatureFilters>(initialFilters);
|
||||||
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
||||||
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
|
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
|
||||||
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
|
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
|
||||||
const [dragData, setDragData] = useState<HexagonData[] | null>(null);
|
|
||||||
const dragAbortRef = useRef<AbortController | null>(null);
|
|
||||||
|
|
||||||
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
|
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
|
||||||
|
|
||||||
|
|
@ -64,40 +58,6 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||||
setActiveFeature(name);
|
setActiveFeature(name);
|
||||||
const fval = filters[name];
|
const fval = filters[name];
|
||||||
setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null);
|
setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null);
|
||||||
|
|
||||||
const currentBounds = boundsRef.current;
|
|
||||||
if (!currentBounds) return;
|
|
||||||
if (dragAbortRef.current) dragAbortRef.current.abort();
|
|
||||||
dragAbortRef.current = new AbortController();
|
|
||||||
|
|
||||||
const otherFilters = Object.entries(filters).filter(([k]) => k !== name);
|
|
||||||
let filtersStr = '';
|
|
||||||
if (otherFilters.length > 0) {
|
|
||||||
filtersStr = otherFilters
|
|
||||||
.map(([n, value]) => {
|
|
||||||
const m = features.find((f) => f.name === n);
|
|
||||||
if (m?.type === 'enum') return `${n}:${(value as string[]).join('|')}`;
|
|
||||||
const [min, max] = value as [number, number];
|
|
||||||
const maxStr = m?.absolute && max === m.max ? 'inf' : String(max);
|
|
||||||
return `${n}:${min}:${maxStr}`;
|
|
||||||
})
|
|
||||||
.join(',');
|
|
||||||
}
|
|
||||||
|
|
||||||
const boundsStr = `${currentBounds.south},${currentBounds.west},${currentBounds.north},${currentBounds.east}`;
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
resolution: resolutionRef.current.toString(),
|
|
||||||
bounds: boundsStr,
|
|
||||||
});
|
|
||||||
if (filtersStr) params.set('filters', filtersStr);
|
|
||||||
params.set('fields', name);
|
|
||||||
|
|
||||||
fetch(apiUrl('hexagons', params), {
|
|
||||||
signal: dragAbortRef.current.signal,
|
|
||||||
})
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((json: ApiResponse) => setDragData(json.features))
|
|
||||||
.catch((err) => logNonAbortError('Failed to fetch drag data', err));
|
|
||||||
},
|
},
|
||||||
[filters, features]
|
[filters, features]
|
||||||
);
|
);
|
||||||
|
|
@ -112,18 +72,12 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||||
}
|
}
|
||||||
setActiveFeature(null);
|
setActiveFeature(null);
|
||||||
setDragValue(null);
|
setDragValue(null);
|
||||||
setDragData(null);
|
|
||||||
if (dragAbortRef.current) {
|
|
||||||
dragAbortRef.current.abort();
|
|
||||||
dragAbortRef.current = null;
|
|
||||||
}
|
|
||||||
}, [activeFeature, dragValue]);
|
}, [activeFeature, dragValue]);
|
||||||
|
|
||||||
const handleSetFilters = useCallback((newFilters: FeatureFilters) => {
|
const handleSetFilters = useCallback((newFilters: FeatureFilters) => {
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
setActiveFeature(null);
|
setActiveFeature(null);
|
||||||
setDragValue(null);
|
setDragValue(null);
|
||||||
setDragData(null);
|
|
||||||
setPinnedFeature(null);
|
setPinnedFeature(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -139,16 +93,10 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||||
setPinnedFeature(null);
|
setPinnedFeature(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateBoundsInfo = useCallback((newBounds: Bounds | null, newResolution: number) => {
|
|
||||||
boundsRef.current = newBounds;
|
|
||||||
resolutionRef.current = newResolution;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filters,
|
filters,
|
||||||
activeFeature,
|
activeFeature,
|
||||||
dragValue,
|
dragValue,
|
||||||
dragData,
|
|
||||||
pinnedFeature,
|
pinnedFeature,
|
||||||
enabledFeatures,
|
enabledFeatures,
|
||||||
viewFeature,
|
viewFeature,
|
||||||
|
|
@ -164,6 +112,5 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||||
handleTogglePin,
|
handleTogglePin,
|
||||||
handleSetPin,
|
handleSetPin,
|
||||||
handleCancelPin,
|
handleCancelPin,
|
||||||
updateBoundsInfo,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ interface UseMapDataOptions {
|
||||||
viewFeature: string | null;
|
viewFeature: string | null;
|
||||||
activeFeature: string | null;
|
activeFeature: string | null;
|
||||||
dragValue: [number, number] | null;
|
dragValue: [number, number] | null;
|
||||||
dragData: HexagonData[] | null;
|
|
||||||
travelTimeEntries: TravelTimeEntry[];
|
travelTimeEntries: TravelTimeEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,7 +41,6 @@ export function useMapData({
|
||||||
viewFeature,
|
viewFeature,
|
||||||
activeFeature,
|
activeFeature,
|
||||||
dragValue,
|
dragValue,
|
||||||
dragData,
|
|
||||||
travelTimeEntries,
|
travelTimeEntries,
|
||||||
}: UseMapDataOptions) {
|
}: UseMapDataOptions) {
|
||||||
const [rawData, setRawData] = useState<HexagonData[]>([]);
|
const [rawData, setRawData] = useState<HexagonData[]>([]);
|
||||||
|
|
@ -59,6 +57,13 @@ export function useMapData({
|
||||||
const [licenseRequired, setLicenseRequired] = useState(false);
|
const [licenseRequired, setLicenseRequired] = useState(false);
|
||||||
const [freeZone, setFreeZone] = useState<Bounds | null>(null);
|
const [freeZone, setFreeZone] = useState<Bounds | null>(null);
|
||||||
|
|
||||||
|
// Drag preview state
|
||||||
|
const [dragHexData, setDragHexData] = useState<HexagonData[] | null>(null);
|
||||||
|
const [dragPostcodeData, setDragPostcodeData] = useState<PostcodeFeature[] | null>(null);
|
||||||
|
const dragFeatureRef = useRef<string | null>(null);
|
||||||
|
const dragAbortRef = useRef<AbortController | null>(null);
|
||||||
|
const activeFeatureRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
const prevBoundsRef = useRef<string>('');
|
const prevBoundsRef = useRef<string>('');
|
||||||
|
|
@ -85,6 +90,61 @@ export function useMapData({
|
||||||
return segments.join('|');
|
return segments.join('|');
|
||||||
}, [travelTimeEntries]);
|
}, [travelTimeEntries]);
|
||||||
|
|
||||||
|
// Keep activeFeatureRef in sync
|
||||||
|
useEffect(() => {
|
||||||
|
activeFeatureRef.current = activeFeature;
|
||||||
|
}, [activeFeature]);
|
||||||
|
|
||||||
|
// Drag prefetch: when activeFeature starts, fetch data excluding that filter
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeFeature || !bounds) return;
|
||||||
|
|
||||||
|
if (dragAbortRef.current) dragAbortRef.current.abort();
|
||||||
|
dragAbortRef.current = new AbortController();
|
||||||
|
|
||||||
|
const filtersStr = buildFilterString(filters, features, activeFeature);
|
||||||
|
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||||||
|
|
||||||
|
if (usePostcodeView) {
|
||||||
|
const params = new URLSearchParams({ bounds: boundsStr });
|
||||||
|
if (filtersStr) params.set('filters', filtersStr);
|
||||||
|
params.set('fields', activeFeature);
|
||||||
|
|
||||||
|
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((json: { features: PostcodeFeature[] }) => {
|
||||||
|
setDragPostcodeData(json.features);
|
||||||
|
setDragHexData(null);
|
||||||
|
dragFeatureRef.current = activeFeature;
|
||||||
|
})
|
||||||
|
.catch((err) => logNonAbortError('Failed to fetch drag postcode data', err));
|
||||||
|
} else {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
resolution: resolution.toString(),
|
||||||
|
bounds: boundsStr,
|
||||||
|
});
|
||||||
|
if (filtersStr) params.set('filters', filtersStr);
|
||||||
|
params.set('fields', activeFeature);
|
||||||
|
if (travelParam) params.set('travel', travelParam);
|
||||||
|
|
||||||
|
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((json: ApiResponse) => {
|
||||||
|
setDragHexData(json.features);
|
||||||
|
setDragPostcodeData(null);
|
||||||
|
dragFeatureRef.current = activeFeature;
|
||||||
|
})
|
||||||
|
.catch((err) => logNonAbortError('Failed to fetch drag hex data', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (dragAbortRef.current) {
|
||||||
|
dragAbortRef.current.abort();
|
||||||
|
dragAbortRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [activeFeature, bounds, resolution, filters, features, usePostcodeView, travelParam]);
|
||||||
|
|
||||||
// Fetch hexagons or postcodes when bounds/filters change
|
// Fetch hexagons or postcodes when bounds/filters change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!bounds) return;
|
if (!bounds) return;
|
||||||
|
|
@ -157,6 +217,13 @@ export function useMapData({
|
||||||
setRawData(json.features);
|
setRawData(json.features);
|
||||||
setPostcodeData([]);
|
setPostcodeData([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear drag data when committed fetch completes and we're not mid-drag
|
||||||
|
if (!activeFeatureRef.current) {
|
||||||
|
setDragHexData(null);
|
||||||
|
setDragPostcodeData(null);
|
||||||
|
dragFeatureRef.current = null;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isAbortError(err)) logNonAbortError('Failed to fetch data', err);
|
if (!isAbortError(err)) logNonAbortError('Failed to fetch data', err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -171,7 +238,9 @@ export function useMapData({
|
||||||
};
|
};
|
||||||
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView, travelParam]);
|
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView, travelParam]);
|
||||||
|
|
||||||
const data = dragData ?? rawData;
|
// Use drag data when it matches the current view feature, otherwise fall back to rawData
|
||||||
|
const data = (viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ?? rawData;
|
||||||
|
const effectivePostcodeData = (viewFeature && dragFeatureRef.current === viewFeature ? dragPostcodeData : null) ?? postcodeData;
|
||||||
|
|
||||||
// Compute p5/p95 from visible data for the viewed feature
|
// Compute p5/p95 from visible data for the viewed feature
|
||||||
const dataRange = useMemo((): [number, number] | null => {
|
const dataRange = useMemo((): [number, number] | null => {
|
||||||
|
|
@ -182,14 +251,14 @@ export function useMapData({
|
||||||
if (!isTravelTime) {
|
if (!isTravelTime) {
|
||||||
const meta = features.find((f) => f.name === viewFeature);
|
const meta = features.find((f) => f.name === viewFeature);
|
||||||
if (!meta || meta.type === 'enum') return null;
|
if (!meta || meta.type === 'enum') return null;
|
||||||
if (activeFeature && !dragData) return null;
|
if (activeFeature && !dragHexData && !dragPostcodeData) return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const vals: number[] = [];
|
const vals: number[] = [];
|
||||||
|
|
||||||
if (usePostcodeView && !isTravelTime) {
|
if (usePostcodeView && !isTravelTime) {
|
||||||
if (postcodeData.length === 0) return null;
|
if (effectivePostcodeData.length === 0) return null;
|
||||||
for (const feat of postcodeData) {
|
for (const feat of effectivePostcodeData) {
|
||||||
if (bounds) {
|
if (bounds) {
|
||||||
const [lng, lat] = feat.properties.centroid as [number, number];
|
const [lng, lat] = feat.properties.centroid as [number, number];
|
||||||
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east)
|
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east)
|
||||||
|
|
@ -217,7 +286,7 @@ export function useMapData({
|
||||||
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
|
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
|
||||||
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
|
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
|
||||||
];
|
];
|
||||||
}, [viewFeature, data, dragData, postcodeData, usePostcodeView, features, activeFeature, bounds]);
|
}, [viewFeature, data, dragHexData, dragPostcodeData, effectivePostcodeData, usePostcodeView, features, activeFeature, bounds]);
|
||||||
|
|
||||||
// Color range for the legend and hex coloring
|
// Color range for the legend and hex coloring
|
||||||
const colorRange = useMemo((): [number, number] | null => {
|
const colorRange = useMemo((): [number, number] | null => {
|
||||||
|
|
@ -270,7 +339,7 @@ export function useMapData({
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
rawData,
|
rawData,
|
||||||
postcodeData,
|
postcodeData: effectivePostcodeData,
|
||||||
resolution,
|
resolution,
|
||||||
bounds,
|
bounds,
|
||||||
loading,
|
loading,
|
||||||
|
|
|
||||||
|
|
@ -70,10 +70,11 @@ export async function shortenUrl(params: string): Promise<string> {
|
||||||
return `${window.location.origin}${data.url}`;
|
return `${window.location.origin}${data.url}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildFilterString(filters: FeatureFilters, features: FeatureMeta[]): string {
|
export function buildFilterString(filters: FeatureFilters, features: FeatureMeta[], exclude?: string): string {
|
||||||
const entries = Object.entries(filters);
|
const entries = Object.entries(filters);
|
||||||
if (entries.length === 0) return '';
|
if (entries.length === 0) return '';
|
||||||
return entries
|
return entries
|
||||||
|
.filter(([name]) => name !== exclude)
|
||||||
.map(([name, value]) => {
|
.map(([name, value]) => {
|
||||||
const meta = features.find((f) => f.name === name);
|
const meta = features.find((f) => f.name === name);
|
||||||
if (meta?.type === 'enum') {
|
if (meta?.type === 'enum') {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue