import { useState, useEffect, useRef, useMemo } from 'react'; import MapComponent from '../map/Map'; import { apiUrl, assertOk, authHeaders, isAbortError, logNonAbortError } from '../../lib/api'; import { formatValue } from '../../lib/format'; import { zoomToResolution } from '../../lib/map-utils'; import { DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts'; import { gradientToCss } from '../../lib/utils'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; import type { FeatureMeta, HexagonData } from '../../types'; const DEMO_VIEW = { longitude: -0.12, latitude: 51.51, zoom: 5.5, pitch: 0 }; 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; travel?: { mode: string; slug: string; min: number; max: number }; } const STAGES: StageDef[] = [ // 0: No filters — the problem { filters: {} }, // 1: Price filter { filters: { 'Estimated current price': [0, 0.4] }, }, // 2: Price + schools { filters: { 'Estimated current price': [0, 0.4], 'Good+ primary schools within 5km': [0.3, 1], }, }, // 3: Price + schools + restaurants { filters: { 'Estimated current price': [0, 0.4], 'Good+ primary schools within 5km': [0.3, 1], 'Number of restaurants within 2km': [0.15, 1], }, }, // 4: Price + schools + restaurants + commute to Manchester { filters: { 'Estimated current price': [0, 0.4], 'Good+ primary schools within 5km': [0.3, 1], 'Number of restaurants within 2km': [0.15, 1], }, travel: { mode: 'transit', slug: 'manchester', min: 0, max: 45 }, }, // 5: Summary — same filters { filters: { 'Estimated current price': [0, 0.4], 'Good+ primary schools within 5km': [0.3, 1], 'Number of restaurants within 2km': [0.15, 1], }, travel: { mode: 'transit', slug: 'manchester', min: 0, max: 45 }, }, ]; const STEPS: { heading: string | null; body: React.ReactNode }[] = [ { heading: null, body: ( <>

Let's look at an example:

You're about to spend{' '} up to £500k on a home.

), }, { heading: null, body: ( <>
1

Set your must-haves

Say you want a home{' '} under £500k

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

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

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

…and{' '} restaurants within walking distance

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

…all within{' '} 45 minutes of Manchester by public transport.

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

No area chosen. No listings browsed. Yet you already know exactly where your needs are met.

That's just 4 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 abortRef = useRef(); 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]); 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° for stability 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 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, }); 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.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)) { logNonAbortError('Failed to fetch story hexagons', err); setLoading(false); } }); return () => controller.abort(); }, [features, stageFilters, stage, resolution, demoBounds]); const isLastStage = stage === STEPS.length - 1; return (
{/* Map background */}
{/* Interaction blocker */}
{/* Loading */} {loading && (
)} {/* Filter indicators — top left */}
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)} )}
); })} {/* Travel time indicator */}
Commute to Manchester {STAGES[stage].travel && ( 0–45 min )}
{/* Density legend — top right */}
Colour
Number of properties
Fewer More
{/* Card stack overlay — bottom on mobile, right-center on desktop */}
{STEPS.map((step, i) => (
{step.heading && (

{step.heading}

)}
{step.body}
{/* Navigation */}
{/* Step dots */}
{STEPS.map((_, dotIdx) => (
{/* Prev / Next / CTA buttons */}
{stage > 0 && ( )} {isLastStage ? ( Start exploring → ) : ( )}
))}
); }