+
+
1
-
Set your must-haves
+
Set your must-haves
-
- Say you want a home at an{' '}
- affordable price …
+
+ Say you want a home{' '}
+ under £500k …
>
),
@@ -102,7 +103,7 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
{
heading: null,
body: (
-
+
…with{' '}
good primary schools {' '}
nearby…
@@ -112,12 +113,22 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
{
heading: null,
body: (
-
+
…and{' '}
restaurants within walking distance
- .
+ …
+
+ ),
+ },
+ {
+ heading: null,
+ body: (
+
+ …all within{' '}
+ 45 minutes of Manchester {' '}
+ by public transport.
),
},
@@ -125,11 +136,11 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
heading: null,
body: (
<>
-
+
No area chosen. No listings browsed. Yet you already know exactly where your needs are met.
-
- That's just 3 filters. We've built 56 —
+
+ That's just 4 filters. We've built 56 —
covering commute times, crime, broadband, noise, schools, amenities, and more.
>
@@ -146,12 +157,7 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
const [stage, setStage] = useState(0);
const [hexData, setHexData] = useState
([]);
const [loading, setLoading] = useState(true);
- const stepRefs = useRef<(HTMLDivElement | null)[]>([]);
const abortRef = useRef();
- const fetchTimeoutRef = useRef>();
- const sectionRef = useRef(null);
- const [scrollProgress, setScrollProgress] = useState(0);
- const rafRef = useRef(0);
const demoFeatures = useMemo(
() =>
@@ -175,70 +181,12 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
return result;
}, [stage, demoFeatures]);
- // IntersectionObserver for scroll stage detection
- useEffect(() => {
- const observers: IntersectionObserver[] = [];
- stepRefs.current.forEach((el, i) => {
- if (!el) return;
- const observer = new IntersectionObserver(
- ([entry]) => {
- if (entry.isIntersecting) setStage(i);
- },
- { rootMargin: '-35% 0px -35% 0px', threshold: 0 }
- );
- observer.observe(el);
- observers.push(observer);
- });
- return () => observers.forEach((o) => o.disconnect());
- }, [demoFeatures.length]);
-
- // Track scroll progress through the section for zoom interpolation
- useEffect(() => {
- const section = sectionRef.current;
- if (!section) return;
- let scrollParent: HTMLElement | null = section.parentElement;
- while (scrollParent) {
- const { overflow, overflowY } = getComputedStyle(scrollParent);
- if (['auto', 'scroll'].includes(overflow) || ['auto', 'scroll'].includes(overflowY)) break;
- scrollParent = scrollParent.parentElement;
- }
- if (!scrollParent) return;
-
- const handleScroll = () => {
- cancelAnimationFrame(rafRef.current);
- rafRef.current = requestAnimationFrame(() => {
- const rect = section.getBoundingClientRect();
- const viewportHeight = window.innerHeight;
- const totalTravel = rect.height - viewportHeight;
- if (totalTravel <= 0) return;
- const scrolled = -rect.top;
- const progress = Math.max(0, Math.min(1, scrolled / totalTravel));
- setScrollProgress(progress);
- });
- };
-
- scrollParent.addEventListener('scroll', handleScroll, { passive: true });
- handleScroll();
- return () => {
- scrollParent.removeEventListener('scroll', handleScroll);
- cancelAnimationFrame(rafRef.current);
- };
- }, []);
-
- const demoView = useMemo(() => {
- const t = easeOutCubic(scrollProgress);
- return {
- longitude: DEMO_VIEW_START.longitude + (DEMO_VIEW_END.longitude - DEMO_VIEW_START.longitude) * t,
- latitude: DEMO_VIEW_START.latitude + (DEMO_VIEW_END.latitude - DEMO_VIEW_START.latitude) * t,
- zoom: DEMO_VIEW_START.zoom + (DEMO_VIEW_END.zoom - DEMO_VIEW_START.zoom) * t,
- pitch: 0,
- };
- }, [scrollProgress]);
+ const demoView = useMemo(() => DEMO_VIEW, []);
// Derive H3 resolution from current zoom (discrete — only changes at thresholds)
const resolution = zoomToResolution(demoView.zoom);
- // Compute bounds string from current view, rounded to 0.5° to avoid refetching on every scroll tick
+ // Compute bounds string from current view, rounded to 0.5° for stability
const demoBounds = useMemo(() => {
const { longitude, latitude, zoom } = demoView;
const scale = Math.pow(2, zoom);
@@ -262,9 +210,14 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
return `${south},${west},${north},${east}`;
}, [demoView]);
- // Fetch hex data when resolution, filters, or bounds change
+ // Fetch hex data when stage filters change
useEffect(() => {
if (features.length === 0) return;
+
+ // Clear stale data and show loading spinner immediately
+ setLoading(true);
+ setHexData([]);
+
const params = new URLSearchParams({
resolution: String(resolution),
bounds: demoBounds,
@@ -274,164 +227,229 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
filterParts.push(`${name}:${min}:${max}`);
}
if (filterParts.length > 0) params.set('filters', filterParts.join(','));
- const stageDef = STAGES[stage];
- if (stageDef.colorFeature) params.set('fields', stageDef.colorFeature);
- clearTimeout(fetchTimeoutRef.current);
- fetchTimeoutRef.current = setTimeout(() => {
- abortRef.current?.abort();
- abortRef.current = new AbortController();
- fetch(apiUrl('hexagons', params), authHeaders({ signal: abortRef.current.signal }))
- .then((res) => {
- assertOk(res, 'hexagons');
- return res.json();
- })
- .then((data: { features: HexagonData[] }) => {
- setHexData(data.features);
+ const stageDef = STAGES[stage];
+ if (stageDef.travel) {
+ const { mode, slug, min, max } = stageDef.travel;
+ params.set('travel', `${mode}:${slug}:${min}:${max}`);
+ }
+
+ const controller = new AbortController();
+ abortRef.current?.abort();
+ abortRef.current = controller;
+ fetch(apiUrl('hexagons', params), authHeaders({ signal: controller.signal }))
+ .then((res) => {
+ assertOk(res, 'hexagons');
+ return res.json();
+ })
+ .then((data: { features: HexagonData[] }) => {
+ setHexData(data.features);
+ setLoading(false);
+ })
+ .catch((err) => {
+ if (!isAbortError(err)) {
+ console.error('Failed to fetch story hexagons:', err);
setLoading(false);
- })
- .catch((err) => logNonAbortError('Failed to fetch story hexagons', err));
- }, 300);
- return () => clearTimeout(fetchTimeoutRef.current);
+ }
+ });
+
+ return () => controller.abort();
}, [features, stageFilters, stage, resolution, demoBounds]);
- useEffect(() => {
- return () => {
- abortRef.current?.abort();
- clearTimeout(fetchTimeoutRef.current);
- };
- }, []);
-
- const stageDef = STAGES[stage];
- const viewFeatureName = stageDef.colorFeature || null;
- const viewMeta = viewFeatureName ? features.find((f) => f.name === viewFeatureName) : null;
- const colorRange: [number, number] | null =
- viewMeta?.min != null && viewMeta?.max != null ? [viewMeta.min, viewMeta.max] : null;
-
- // Defer hex data so scroll zoom stays smooth while layer rebuilds happen in the background
- const deferredHexData = useDeferredValue(hexData);
+ const isLastStage = stage === STEPS.length - 1;
return (
-
- {/* Sticky map background */}
-
-
-
+
+ {/* Map background */}
+
+
+
+
+ {/* Interaction blocker */}
+
+
+ {/* Loading */}
+ {loading && (
+
+
+ )}
- {/* Interaction blocker */}
-
-
- {/* Loading */}
- {loading && (
-
-
+ {/* Filter indicators — top left */}
+
+
+
+ Filters
- )}
-
- {/* Filter indicators — left sidebar */}
-
-
-
- Filters
-
- {demoFeatures.map((feature) => {
- const filterVal = stageFilters[feature.name];
- const isActive = !!filterVal;
- const min = feature.min ?? 0;
- const max = feature.max ?? 1;
- const range = max - min || 1;
- const leftPct = filterVal ? ((filterVal[0] - min) / range) * 100 : 0;
- const widthPct = filterVal ? ((filterVal[1] - filterVal[0]) / range) * 100 : 100;
- return (
-
-
-
- {feature.name}
+ {demoFeatures.map((feature) => {
+ const filterVal = stageFilters[feature.name];
+ const isActive = !!filterVal;
+ const min = feature.min ?? 0;
+ const max = feature.max ?? 1;
+ const range = max - min || 1;
+ const leftPct = filterVal ? ((filterVal[0] - min) / range) * 100 : 0;
+ const widthPct = filterVal ? ((filterVal[1] - filterVal[0]) / range) * 100 : 100;
+ return (
+
+
+
+ {feature.name}
+
+ {isActive && filterVal && (
+
+ {formatValue(filterVal[0], feature)}–
+ {formatValue(filterVal[1], feature)}
- {isActive && filterVal && (
-
- {formatValue(filterVal[0], feature)}–
- {formatValue(filterVal[1], feature)}
-
- )}
-
-
+ )}
- );
- })}
-
- {/* Color legend */}
- {viewFeatureName && colorRange && (
-
-
- Colour
-
-
- {viewFeatureName}
-
-
-
-
{formatValue(colorRange[0], viewMeta!)}
-
{formatValue(colorRange[1], viewMeta!)}
+
- )}
+ );
+ })}
+ {/* Travel time indicator */}
+
+
+
+ Commute to Manchester
+
+ {STAGES[stage].travel && (
+
+ 0–45 min
+
+ )}
+
+
- {/* Scrolling text overlay */}
-
-
-
+ {/* Density legend — top right */}
+
+
+
+ Colour
+
+
+ Number of properties
+
+
+
+ Fewer
+ More
+
+
+
+
+ {/* Card stack overlay — bottom on mobile, right-center on desktop */}
+
+
{STEPS.map((step, i) => (
{
- stepRefs.current[i] = el;
- }}
- className="pointer-events-auto mb-[30vh] md:mb-[40vh] bg-white/90 dark:bg-warm-800/90 rounded-xl p-5 md:p-6 backdrop-blur-sm shadow-lg border border-warm-200/40 dark:border-warm-700/40"
+ className={`col-start-1 row-start-1 transition-all duration-500 ease-out ${
+ i === stage
+ ? 'opacity-100 translate-y-0'
+ : i < stage
+ ? 'opacity-0 -translate-y-4 pointer-events-none'
+ : 'opacity-0 translate-y-4 pointer-events-none'
+ }`}
>
- {step.heading && (
-
- {step.heading}
-
- )}
-
{step.body}
+
+ {step.heading && (
+
+ {step.heading}
+
+ )}
+
{step.body}
+
+ {/* Navigation */}
+
+ {/* Step dots */}
+
+ {STEPS.map((_, dotIdx) => (
+ setStage(dotIdx)}
+ className={`w-2 h-2 rounded-full transition-all duration-300 ${
+ dotIdx === stage
+ ? 'bg-teal-600 dark:bg-teal-400 w-4'
+ : dotIdx < stage
+ ? 'bg-teal-600/40 dark:bg-teal-400/40'
+ : 'bg-warm-300 dark:bg-warm-600'
+ }`}
+ aria-label={`Go to step ${dotIdx + 1}`}
+ />
+ ))}
+
+
+ {/* Prev / Next / CTA buttons */}
+
+ {stage > 0 && (
+
setStage(stage - 1)}
+ className="inline-flex items-center gap-1 px-4 py-2 rounded-lg border border-warm-200 dark:border-warm-700 text-warm-600 dark:text-warm-300 font-semibold text-sm hover:bg-warm-50 dark:hover:bg-warm-800 transition-colors"
+ >
+ ← Back
+
+ )}
+ {isLastStage ? (
+
+ Start exploring →
+
+ ) : (
+
setStage(stage + 1)}
+ className="inline-flex items-center gap-1 px-4 py-2 rounded-lg bg-teal-600 text-white font-semibold text-sm hover:bg-teal-700 transition-colors"
+ >
+ Next →
+
+ )}
+
+
+
))}
-