- Maximum Value . Minimum Compromise.
+ Maximum Value .
+
+ Minimum Compromise.
-
- Buying a home may be your most important decision. Why not ensure you make your
- best-ever decision?
+
+ House hunting? Make your biggest investment your smartest move.
-
- You have so many options. Picking the best one is daunting and stressful. It
- won'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.
+
+ 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.
Explore the map
- {hidePricing ? (
-
- You have lifetime access!
-
- ) : (
-
- Get lifetime access
-
- )}
+ {
+ 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
+
-
+
@@ -93,87 +104,145 @@ export default function HomePage({
-
+
{
+ 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"
+ >
- How does it work?
+ See It in Action
+
+
+
+
+ {/* How to use it + Comparison table (two columns) */}
+
+
+
+ {/* Left: How to use it */}
+
+
+ How to use it
+
+
+ {HOW_STEPS.map((step, i) => (
+
+
+ {i + 1}
+
+
+
+ {step.title}
+
+
+ {step.description}
+
+
+
+ ))}
+
+
+ {/* Right: Comparison table */}
+
+
+ Others vs Perfect Postcode
+
+
+
+
+
+
+
+ Listing portals
+
+
+ {'\u201CCheck my postcode\u201D'}
+
+
+ Area guides
+
+
+ Perfect Postcode
+
+
+
+
+ {FEATURE_ROWS.map((row, i) => (
+
+
+ {row.feature}
+ {row.subtitle && (
+ {row.subtitle}
+ )}
+
+ {[row.listings, row.postcode, row.guides].map((has, j) => (
+
+ {has ? '\u2713' : '\u2717'}
+
+ ))}
+
+ ✓
+
+
+ ))}
+
+
+
+
{/* Scrollytelling: Problem + Solution + Demo map */}
+
+ See It in Action
+
+
+ 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.
+
- {/* Why existing tools don't cut it */}
-
-
-
- Why existing tools don't cut it
-
-
- {WHY_CARDS.map((card) => (
-
-
{card.icon}
-
{card.title}
-
- {card.description}
-
-
- ))}
-
-
- 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.
-
-
-
-
- {/* How to use it */}
-
-
-
- How to use it
-
-
- {HOW_STEPS.map((step, i) => (
-
-
- {i + 1}
-
-
-
- {step.title}
-
-
- {step.description}
-
-
-
- ))}
-
-
-
-
{/* The real cost CTA */}
-
+
The biggest financial decision of your life
deserves proper tools behind it.
-
- 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.
-
-
- One payment. Lifetime access. Less than your survey costs and vastly more useful.
+
+ Don't leave it to chance.
£300k–£400k
{' '}
- on a home. Your research method? Scrolling through listings and hoping for the best.
-
-
- Listings only show what's on the market right now — 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.
-
-
- Your home is not a box of cereal. Don'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.
>
),
},
{
- heading: 'Set your requirements. The map shows you where they intersect.',
+ heading: null,
body: (
-
- Say you want a home at an{' '}
- affordable price …
-
+ <>
+
+
+ 1
+
+
Set your must-haves
+
+
+ Say you want a home at an{' '}
+ affordable price …
+
+ >
),
},
{
@@ -110,33 +112,25 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
{
heading: null,
body: (
- <>
-
- …and{' '}
-
- restaurants within walking distance
-
- .
-
-
- You haven't opened a single listing yet — and you already know exactly where to
- focus.
-
- >
+
+ …and{' '}
+
+ restaurants within walking distance
+
+ .
+
),
},
{
heading: null,
body: (
<>
-
- That's just three filters.
+
+ No area chosen. No listings browsed. Yet you already know exactly where your needs are met.
- We've built 43 .
- 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's just 3 filters. We've built 56 —
+ covering commute times, crime, broadband, noise, schools, amenities, and more.
>
),
@@ -318,7 +312,7 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
const deferredHexData = useDeferredValue(hexData);
return (
-
+
{/* Sticky map background */}
@@ -396,6 +390,23 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
);
})}
+
+ {/* Color legend */}
+ {viewFeatureName && colorRange && (
+
+
+ Colour
+
+
+ {viewFeatureName}
+
+
+
+ {formatValue(colorRange[0], viewMeta!)}
+ {formatValue(colorRange[1], viewMeta!)}
+
+
+ )}
diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx
index 7e3cddc..66aaba7 100644
--- a/frontend/src/components/map/MapPage.tsx
+++ b/frontend/src/components/map/MapPage.tsx
@@ -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,
diff --git a/frontend/src/components/pricing/PricingPage.tsx b/frontend/src/components/pricing/PricingPage.tsx
index f2b5687..0e6fcc9 100644
--- a/frontend/src/components/pricing/PricingPage.tsx
+++ b/frontend/src/components/pricing/PricingPage.tsx
@@ -347,6 +347,18 @@ export default function PricingPage({
)}
+
+
+
+ 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.
+
+
+ One payment. Lifetime access. Less than your survey costs and vastly more useful.
+
+
);
}
diff --git a/frontend/src/hooks/useFilters.ts b/frontend/src/hooks/useFilters.ts
index a41fe23..c23ede1 100644
--- a/frontend/src/hooks/useFilters.ts
+++ b/frontend/src/hooks/useFilters.ts
@@ -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
(null);
- const resolutionRef = useRef(8);
const [filters, setFilters] = useState(initialFilters);
const [activeFeature, setActiveFeature] = useState(null);
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
const [pinnedFeature, setPinnedFeature] = useState(null);
- const [dragData, setDragData] = useState(null);
- const dragAbortRef = useRef(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,
};
}
diff --git a/frontend/src/hooks/useMapData.ts b/frontend/src/hooks/useMapData.ts
index 817be69..27f2b50 100644
--- a/frontend/src/hooks/useMapData.ts
+++ b/frontend/src/hooks/useMapData.ts
@@ -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([]);
@@ -59,6 +57,13 @@ export function useMapData({
const [licenseRequired, setLicenseRequired] = useState(false);
const [freeZone, setFreeZone] = useState(null);
+ // Drag preview state
+ const [dragHexData, setDragHexData] = useState(null);
+ const [dragPostcodeData, setDragPostcodeData] = useState(null);
+ const dragFeatureRef = useRef(null);
+ const dragAbortRef = useRef(null);
+ const activeFeatureRef = useRef(null);
+
const debounceRef = useRef | null>(null);
const abortControllerRef = useRef(null);
const prevBoundsRef = useRef('');
@@ -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,
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index b8f2560..20956da 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -70,10 +70,11 @@ export async function shortenUrl(params: string): Promise {
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') {