import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react'; 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: -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); } const DEMO_FEATURE_NAMES = [ 'Estimated current price', 'Good+ primary schools within 5km', 'Number of restaurants within 2km', ]; const noop = () => { }; // Filter fractions per stage: featureName -> [minFrac, maxFrac] // 0 = feature.min, 1 = feature.max interface StageDef { filters: Record; colorFeature?: string; } const STAGES: StageDef[] = [ // 0: No filters — the problem { filters: {}, colorFeature: 'Estimated current price' }, // 1: Price filter — "affordable price" { filters: { 'Estimated current price': [0, 0.25] }, colorFeature: 'Estimated current price', }, // 2: Price + schools { filters: { 'Estimated current price': [0, 0.25], 'Good+ primary schools within 5km': [0.3, 1], }, colorFeature: 'Good+ primary schools within 5km', }, // 3: All three { filters: { 'Estimated current price': [0, 0.25], 'Good+ primary schools within 5km': [0.3, 1], 'Number of restaurants within 2km': [0.15, 1], }, colorFeature: 'Number of restaurants within 2km', }, // 4: Same filters — "that's just three" { filters: { 'Estimated current price': [0, 0.25], 'Good+ primary schools within 5km': [0.3, 1], 'Number of restaurants within 2km': [0.15, 1], }, colorFeature: 'Number of restaurants within 2km', }, ]; const STEPS: { heading: string | null; body: React.ReactNode }[] = [ { heading: null, body: ( <>

Let's look at an example:

You're about to spend{' '} £300k–£400k {' '} 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: null, body: ( <>
1

Set your must-haves

Say you want a home at an{' '} affordable price

), }, { heading: null, body: (

…with{' '} good primary schools{' '} nearby…

), }, { heading: null, body: (

…and{' '} restaurants within walking distance .

), }, { 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 — covering commute times, crime, broadband, noise, schools, amenities, and more.

), }, ]; interface ScrollStoryProps { features: FeatureMeta[]; theme: 'light' | 'dark'; } 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( () => DEMO_FEATURE_NAMES.map((name) => features.find((f) => f.name === name)).filter( Boolean ) as FeatureMeta[], [features] ); // Compute actual filter values from stage fractions + feature metadata const stageFilters = useMemo(() => { const stageDef = STAGES[stage]; const result: Record = {}; for (const [name, [minFrac, maxFrac]] of Object.entries(stageDef.filters)) { const meta = demoFeatures.find((f) => f.name === name); if (meta?.min != null && meta?.max != null) { const range = meta.max - meta.min; result[name] = [meta.min + range * minFrac, meta.min + range * maxFrac]; } } 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]); // 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 const demoBounds = useMemo(() => { const { longitude, latitude, zoom } = demoView; const scale = Math.pow(2, zoom); const degreesPerPixelLng = 360 / (512 * scale); const halfW = (1200 / 2) * degreesPerPixelLng * 1.3; const latRad = (latitude * Math.PI) / 180; const mercY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2; const worldSize = 512 * scale; const halfH = (800 / 2) * 1.3; const topY = mercY * worldSize - halfH; const botY = mercY * worldSize + halfH; const toLat = (py: number) => { const my = Math.max(0.001, Math.min(0.999, py / worldSize)); return (Math.atan(Math.sinh(Math.PI * (1 - 2 * my))) * 180) / Math.PI; }; const snap = (v: number) => Math.round(v * 2) / 2; const south = snap(Math.max(-85, toLat(botY))); const west = snap(Math.max(-180, longitude - halfW)); const north = snap(Math.min(85, toLat(topY))); const east = snap(Math.min(180, longitude + halfW)); return `${south},${west},${north},${east}`; }, [demoView]); // Fetch hex data when resolution, filters, or bounds change useEffect(() => { if (features.length === 0) return; const params = new URLSearchParams({ resolution: String(resolution), bounds: demoBounds, }); const filterParts: string[] = []; for (const [name, [min, max]] of Object.entries(stageFilters)) { 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); setLoading(false); }) .catch((err) => logNonAbortError('Failed to fetch story hexagons', err)); }, 300); return () => clearTimeout(fetchTimeoutRef.current); }, [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); return (
{/* Sticky map background */}
{/* Interaction blocker */}
{/* Loading */} {loading && (
)} {/* 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} {isActive && filterVal && ( {formatValue(filterVal[0], feature)}– {formatValue(filterVal[1], feature)} )}
); })} {/* Color legend */} {viewFeatureName && colorRange && (
Colour
{viewFeatureName}
{formatValue(colorRange[0], viewMeta!)} {formatValue(colorRange[1], viewMeta!)}
)}
{/* Scrolling text overlay */}
{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" > {step.heading && (

{step.heading}

)}
{step.body}
))}
); }