439 lines
17 KiB
TypeScript
439 lines
17 KiB
TypeScript
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<string, [number, number]>;
|
|
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: (
|
|
<>
|
|
<p className="text-lg leading-relaxed mb-4">
|
|
Let's look at an example:
|
|
</p>
|
|
<p className="text-lg leading-relaxed mb-4">
|
|
You're about to spend{' '}
|
|
<strong className="text-navy-950 dark:text-warm-100">
|
|
£300k–£400k
|
|
</strong>{' '}
|
|
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: null,
|
|
body: (
|
|
<>
|
|
<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>…
|
|
</p>
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
heading: null,
|
|
body: (
|
|
<p className="text-lg leading-relaxed">
|
|
…with{' '}
|
|
<strong className="text-navy-950 dark:text-warm-100">good primary schools</strong>{' '}
|
|
nearby…
|
|
</p>
|
|
),
|
|
},
|
|
{
|
|
heading: null,
|
|
body: (
|
|
<p className="text-lg leading-relaxed">
|
|
…and{' '}
|
|
<strong className="text-navy-950 dark:text-warm-100">
|
|
restaurants within walking distance
|
|
</strong>
|
|
.
|
|
</p>
|
|
),
|
|
},
|
|
{
|
|
heading: null,
|
|
body: (
|
|
<>
|
|
<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">
|
|
That's just 3 filters. We've built <strong className="text-navy-950 dark:text-warm-100">56</strong> —
|
|
covering commute times, crime, broadband, noise, schools, amenities, and more.
|
|
</p>
|
|
</>
|
|
),
|
|
},
|
|
];
|
|
|
|
interface ScrollStoryProps {
|
|
features: FeatureMeta[];
|
|
theme: 'light' | 'dark';
|
|
}
|
|
|
|
export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|
const [stage, setStage] = useState(0);
|
|
const [hexData, setHexData] = useState<HexagonData[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const stepRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
const abortRef = useRef<AbortController>();
|
|
const fetchTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
|
const sectionRef = useRef<HTMLElement>(null);
|
|
const [scrollProgress, setScrollProgress] = useState(0);
|
|
const rafRef = useRef<number>(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<string, [number, number]> = {};
|
|
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 (
|
|
<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">
|
|
<MapComponent
|
|
data={deferredHexData}
|
|
postcodeData={[]}
|
|
usePostcodeView={false}
|
|
pois={[]}
|
|
onViewChange={noop}
|
|
viewFeature={viewFeatureName}
|
|
colorRange={colorRange}
|
|
filterRange={null}
|
|
viewSource={viewFeatureName ? 'drag' : null}
|
|
onCancelPin={noop}
|
|
features={features}
|
|
selectedHexagonId={null}
|
|
hoveredHexagonId={null}
|
|
onHexagonClick={noop}
|
|
onHexagonHover={noop}
|
|
initialViewState={demoView}
|
|
theme={theme}
|
|
screenshotMode={true}
|
|
hideLegend={true}
|
|
/>
|
|
</div>
|
|
|
|
{/* Interaction blocker */}
|
|
<div className="absolute inset-0 z-30" />
|
|
|
|
{/* Loading */}
|
|
{loading && (
|
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
|
|
<SpinnerIcon className="w-10 h-10 text-teal-600 dark:text-teal-400 animate-spin" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Filter indicators — left sidebar */}
|
|
<div className="absolute top-0 left-0 bottom-0 z-40 pointer-events-none w-[280px] md:w-[340px] flex items-center">
|
|
<div className="bg-white/85 dark:bg-warm-800/85 rounded-r-xl p-5 md:p-6 backdrop-blur-sm shadow-lg space-y-5 w-full">
|
|
<div className="text-xs font-semibold uppercase tracking-wider text-warm-500 dark:text-warm-400 mb-1">
|
|
Filters
|
|
</div>
|
|
{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 (
|
|
<div
|
|
key={feature.name}
|
|
className={`transition-opacity duration-700 ${isActive ? 'opacity-100' : 'opacity-30'}`}
|
|
>
|
|
<div className="flex justify-between items-baseline text-sm mb-1.5 gap-2">
|
|
<span
|
|
className={`font-medium truncate ${isActive ? 'text-navy-950 dark:text-warm-100' : 'text-warm-400 dark:text-warm-500'}`}
|
|
>
|
|
{feature.name}
|
|
</span>
|
|
{isActive && filterVal && (
|
|
<span className="text-teal-600 dark:text-teal-400 font-semibold whitespace-nowrap">
|
|
{formatValue(filterVal[0], feature)}–
|
|
{formatValue(filterVal[1], feature)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="relative h-2.5 bg-warm-200 dark:bg-warm-700 rounded-full overflow-hidden">
|
|
<div
|
|
className="absolute h-full bg-teal-500 dark:bg-teal-400 rounded-full transition-all duration-700 ease-out"
|
|
style={{ left: `${leftPct}%`, width: `${widthPct}%` }}
|
|
/>
|
|
</div>
|
|
</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>
|
|
|
|
{/* Scrolling text overlay */}
|
|
<div className="relative z-10 -mt-[60vh] md:-mt-[calc(100dvh-3rem)] pointer-events-none">
|
|
<div className="mx-4 md:ml-auto md:mr-[4%] md:max-w-md">
|
|
<div className="h-[35vh] md:h-[45vh]" />
|
|
{STEPS.map((step, i) => (
|
|
<div
|
|
key={i}
|
|
ref={(el) => {
|
|
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 && (
|
|
<h3 className="text-xl font-bold text-navy-950 dark:text-warm-100 mb-3 leading-snug">
|
|
{step.heading}
|
|
</h3>
|
|
)}
|
|
<div className="text-warm-700 dark:text-warm-300">{step.body}</div>
|
|
</div>
|
|
))}
|
|
<div className="h-[30vh] md:h-[40vh]" />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|