ruby homepage changes

This commit is contained in:
Andras Schmelczer 2026-02-21 20:49:56 +00:00
parent 94ebd0f614
commit 7777d4046e
7 changed files with 312 additions and 223 deletions

View file

@ -5,6 +5,7 @@ import ScrollStory from './ScrollStory';
import BottomIllustration from './BottomIllustration';
import { TickerValue } from '../ui/TickerValue';
import { ChevronIcon } from '../ui/icons/ChevronIcon';
import { LogoIcon } from '../ui/icons/LogoIcon';
import type { FeatureMeta } from '../../types';
export default function HomePage({
@ -27,7 +28,6 @@ export default function HomePage({
}, []);
const whyRef = useFadeInRef();
const howRef = useFadeInRef();
const ctaRef = useFadeInRef();
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>
<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>
<p className="text-xl 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
best-ever decision?
<p className="text-lg text-warm-300 mb-6 leading-relaxed max-w-xl">
House hunting? Make your biggest investment your smartest move.
</p>
<p className="text-lg text-warm-400 mb-8 max-w-lg">
You have so many options. Picking the best one is daunting and stressful. It
won&apos;t be anymore when looking at the property landscape through our
interactive map. Simply pick your exact needs and our interactive map will show
you all areas that satisfy your requirements and more.
<p className="text-lg text-warm-400 mb-8 max-w-xl">
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
fit.
</p>
<div className="flex items-center gap-4 mb-10">
<button
@ -60,20 +59,32 @@ export default function HomePage({
>
Explore the map
</button>
{hidePricing ? (
<span className="px-[26px] py-[12px] border-2 border-teal-400/50 text-teal-400 rounded-lg font-semibold text-base">
You have lifetime access!
</span>
) : (
<button
onClick={onOpenPricing}
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"
>
Get lifetime access
</button>
)}
<button
onClick={() => {
const target = document.getElementById('comparison');
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="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 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 className="text-2xl md:text-3xl font-bold text-white">
<TickerValue text="13M" active={statsActive} />
@ -93,87 +104,145 @@ export default function HomePage({
</div>
</div>
<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">
How does it work?
See It in Action
</p>
<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">
&#x2713;
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{/* 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&apos;s on the market right now &mdash; 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} />
{/* 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&apos;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 */}
<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">
<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
<br />
deserves proper tools behind&nbsp;it.
</h2>
<p className="text-warm-600 dark:text-warm-400 mb-3 max-w-xl mx-auto leading-relaxed">
Stamp duty on a &pound;400k house: &pound;10,000. Solicitor fees: &pound;1,500.
Survey: &pound;500. Moving costs: &pound;1,000. And that&apos;s just the money. Get the
wrong area and you&apos;re stuck &mdash; 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 className="text-warm-600 dark:text-warm-400 mb-8 max-w-xl mx-auto leading-relaxed">
Don&apos;t leave it to chance.
</p>
<button
onClick={onOpenDashboard}
@ -191,46 +260,33 @@ export default function HomePage({
);
}
const WHY_CARDS = [
{
icon: '\u{1F3D8}\uFE0F',
title: 'Listing portals',
description:
"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.",
},
{
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 FEATURE_ROWS = [
// listings postcode guides
{ feature: 'Search without choosing an area first', subtitle: '(start with needs, not a location)', listings: false, postcode: false, guides: false },
{ feature: 'Area data', subtitle: '(crime, schools, noise, broadband)', listings: false, postcode: true, guides: true },
{ feature: 'Property-specific data', subtitle: '(price, EPC, floor area)', listings: true, postcode: false, guides: false },
{ feature: '56 combinable filters in one place', subtitle: '(all insights, one interactive map)', listings: false, postcode: false, guides: false },
];
const HOW_STEPS = [
{
title: 'Set your non-negotiables',
title: 'Set your must-haves',
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:
"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',
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:
"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.',
},
];

View file

@ -3,11 +3,13 @@ import MapComponent from '../map/Map';
import { apiUrl, assertOk, authHeaders, logNonAbortError } from '../../lib/api';
import { formatValue } from '../../lib/format';
import { zoomToResolution } from '../../lib/map-utils';
import { FEATURE_GRADIENT } from '../../lib/consts';
import { gradientToCss } from '../../lib/utils';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import type { FeatureMeta, HexagonData } from '../../types';
const DEMO_VIEW_START = { longitude: -1.9, latitude: 52.2, zoom: 5.5, pitch: 0 };
const DEMO_VIEW_END = { longitude: -1.9, latitude: 52.2, zoom: 12, pitch: 0 };
const DEMO_VIEW_START = { longitude: -0.12, latitude: 51.51, zoom: 5.5, pitch: 0 };
const DEMO_VIEW_END = { longitude: -0.12, latitude: 51.51, zoom: 7, pitch: 0 };
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
@ -28,7 +30,7 @@ interface StageDef {
const STAGES: StageDef[] = [
// 0: No filters — the problem
{ filters: {} },
{ filters: {}, colorFeature: 'Estimated current price' },
// 1: Price filter — "affordable price"
{
filters: { 'Estimated current price': [0, 0.25] },
@ -58,6 +60,7 @@ const STAGES: StageDef[] = [
'Good+ primary schools within 5km': [0.3, 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">
&pound;300k&ndash;&pound;400k
</strong>{' '}
on a home. Your research method? Scrolling through listings and hoping for the best.
</p>
<p className="text-lg leading-relaxed mb-4">
Listings only show what&apos;s on the market <em>right now</em> &mdash; a tiny, random
slice of what&apos;s actually out there. You&apos;ll never see the 3-bed Victorian on a
quiet street that sold six months ago, or the one that&apos;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&apos;t let a discount on the wrong property distract
you from finding the right one.
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>
</>
),
},
{
heading: 'Set your requirements. The map shows you where they intersect.',
heading: null,
body: (
<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>&hellip;
</p>
<>
<div className="flex items-center gap-3 mb-3">
<div className="shrink-0 w-8 h-8 rounded-full bg-teal-600 text-white flex items-center justify-center font-bold text-sm">
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>&hellip;
</p>
</>
),
},
{
@ -110,33 +112,25 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
{
heading: null,
body: (
<>
<p className="text-lg leading-relaxed mb-4">
&hellip;and{' '}
<strong className="text-navy-950 dark:text-warm-100">
restaurants within walking distance
</strong>
.
</p>
<p className="text-lg leading-relaxed font-semibold text-navy-950 dark:text-warm-100">
You haven&apos;t opened a single listing yet &mdash; and you already know exactly where to
focus.
</p>
</>
<p className="text-lg leading-relaxed">
&hellip;and{' '}
<strong className="text-navy-950 dark:text-warm-100">
restaurants within walking distance
</strong>
.
</p>
),
},
{
heading: null,
body: (
<>
<p className="text-xl font-bold text-navy-950 dark:text-warm-100 mb-2">
That&apos;s just three filters.
<p className="text-lg leading-relaxed mb-4 font-semibold text-navy-950 dark:text-warm-100">
No area chosen. No listings browsed. Yet you already know exactly where your needs are met.
</p>
<p className="text-lg leading-relaxed">
We&apos;ve built <strong className="text-navy-950 dark:text-warm-100">43</strong>.
Spanning property prices, commute times, school ratings, crime rates, broadband speeds,
road noise, energy efficiency, amenities, deprivation scores, demographics, and more. All
layered on top of each other, all filterable at once.
That&apos;s just 3 filters. We&apos;ve built <strong className="text-navy-950 dark:text-warm-100">56</strong> &mdash;
covering commute times, crime, broadband, noise, schools, amenities, and more.
</p>
</>
),
@ -318,7 +312,7 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
const deferredHexData = useDeferredValue(hexData);
return (
<section ref={sectionRef} className="relative">
<section ref={sectionRef} className="snap-start relative">
{/* Sticky map background */}
<div className="sticky top-0 h-[60vh] md:h-[calc(100dvh-3rem)] z-0">
<div className="absolute inset-0">
@ -396,6 +390,23 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
</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>

View file

@ -26,7 +26,7 @@ import {
travelFieldKey,
type TravelTimeInitial,
} 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 { useLicense } from '../../hooks/useLicense';
import UpgradeModal from '../ui/UpgradeModal';
@ -94,7 +94,6 @@ export default function MapPage({
filters,
activeFeature,
dragValue,
dragData,
pinnedFeature,
enabledFeatures,
viewFeature,
@ -110,7 +109,6 @@ export default function MapPage({
handleTogglePin,
handleSetPin,
handleCancelPin,
updateBoundsInfo,
} = useFilters({
initialFilters,
features,
@ -159,14 +157,9 @@ export default function MapPage({
viewFeature,
activeFeature,
dragValue,
dragData,
travelTimeEntries: travelTime.entries,
});
useEffect(() => {
updateBoundsInfo(mapData.bounds, mapData.resolution);
}, [mapData.bounds, mapData.resolution, updateBoundsInfo]);
const selection = useHexagonSelection({
filters,
features,

View file

@ -347,6 +347,18 @@ export default function PricingPage({
</p>
)}
</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 &pound;400k house: &pound;10,000. Solicitor fees: &pound;1,500.
Survey: &pound;500. Moving costs: &pound;1,000. And that&apos;s just the money. Get the
wrong area and you&apos;re stuck &mdash; 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>
);
}

View file

@ -1,6 +1,5 @@
import { useState, useCallback, useRef, useMemo } from 'react';
import type { FeatureMeta, FeatureFilters, Bounds, HexagonData, ApiResponse } from '../types';
import { apiUrl, logNonAbortError } from '../lib/api';
import { useState, useCallback, useMemo } from 'react';
import type { FeatureMeta, FeatureFilters } from '../types';
interface UseFiltersOptions {
initialFilters: FeatureFilters;
@ -8,15 +7,10 @@ interface 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 [activeFeature, setActiveFeature] = useState<string | null>(null);
const [dragValue, setDragValue] = useState<[number, number] | 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]);
@ -64,40 +58,6 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
setActiveFeature(name);
const fval = filters[name];
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]
);
@ -112,18 +72,12 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
}
setActiveFeature(null);
setDragValue(null);
setDragData(null);
if (dragAbortRef.current) {
dragAbortRef.current.abort();
dragAbortRef.current = null;
}
}, [activeFeature, dragValue]);
const handleSetFilters = useCallback((newFilters: FeatureFilters) => {
setFilters(newFilters);
setActiveFeature(null);
setDragValue(null);
setDragData(null);
setPinnedFeature(null);
}, []);
@ -139,16 +93,10 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
setPinnedFeature(null);
}, []);
const updateBoundsInfo = useCallback((newBounds: Bounds | null, newResolution: number) => {
boundsRef.current = newBounds;
resolutionRef.current = newResolution;
}, []);
return {
filters,
activeFeature,
dragValue,
dragData,
pinnedFeature,
enabledFeatures,
viewFeature,
@ -164,6 +112,5 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
handleTogglePin,
handleSetPin,
handleCancelPin,
updateBoundsInfo,
};
}

View file

@ -32,7 +32,6 @@ interface UseMapDataOptions {
viewFeature: string | null;
activeFeature: string | null;
dragValue: [number, number] | null;
dragData: HexagonData[] | null;
travelTimeEntries: TravelTimeEntry[];
}
@ -42,7 +41,6 @@ export function useMapData({
viewFeature,
activeFeature,
dragValue,
dragData,
travelTimeEntries,
}: UseMapDataOptions) {
const [rawData, setRawData] = useState<HexagonData[]>([]);
@ -59,6 +57,13 @@ export function useMapData({
const [licenseRequired, setLicenseRequired] = useState(false);
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 abortControllerRef = useRef<AbortController | null>(null);
const prevBoundsRef = useRef<string>('');
@ -85,6 +90,61 @@ export function useMapData({
return segments.join('|');
}, [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
useEffect(() => {
if (!bounds) return;
@ -157,6 +217,13 @@ export function useMapData({
setRawData(json.features);
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) {
if (!isAbortError(err)) logNonAbortError('Failed to fetch data', err);
} finally {
@ -171,7 +238,9 @@ export function useMapData({
};
}, [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
const dataRange = useMemo((): [number, number] | null => {
@ -182,14 +251,14 @@ export function useMapData({
if (!isTravelTime) {
const meta = features.find((f) => f.name === viewFeature);
if (!meta || meta.type === 'enum') return null;
if (activeFeature && !dragData) return null;
if (activeFeature && !dragHexData && !dragPostcodeData) return null;
}
const vals: number[] = [];
if (usePostcodeView && !isTravelTime) {
if (postcodeData.length === 0) return null;
for (const feat of postcodeData) {
if (effectivePostcodeData.length === 0) return null;
for (const feat of effectivePostcodeData) {
if (bounds) {
const [lng, lat] = feat.properties.centroid as [number, number];
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_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
const colorRange = useMemo((): [number, number] | null => {
@ -270,7 +339,7 @@ export function useMapData({
return {
data,
rawData,
postcodeData,
postcodeData: effectivePostcodeData,
resolution,
bounds,
loading,

View file

@ -70,10 +70,11 @@ export async function shortenUrl(params: string): Promise<string> {
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);
if (entries.length === 0) return '';
return entries
.filter(([name]) => name !== exclude)
.map(([name, value]) => {
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') {