This commit is contained in:
Andras Schmelczer 2026-02-15 22:39:49 +00:00
parent 03445188ea
commit 524580eb25
102 changed files with 36625 additions and 1295 deletions

View file

@ -0,0 +1,342 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import MapComponent from '../map/Map';
import { apiUrl, assertOk, authHeaders, logNonAbortError } from '../../lib/api';
import { formatValue } from '../../lib/format';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import type { FeatureMeta, HexagonData } from '../../types';
const DEMO_VIEW = { longitude: -1.9, latitude: 52.2, zoom: 5.5, pitch: 0 };
const DEMO_FEATURE_NAMES = [
'Estimated current price',
'Good+ primary schools within 5km',
'Number of restaurants within 2km',
];
const DEMO_BOUNDS = '49,-9.5,57,5';
const DEMO_RESOLUTION = 5;
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: {} },
// 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],
},
},
];
const STEPS: { heading: string | null; body: React.ReactNode }[] = [
{
heading: null,
body: (
<>
<p className="text-lg leading-relaxed mb-4">
You&apos;re about to spend{' '}
<strong className="text-navy-950 dark:text-warm-100">
&pound;300,000&ndash;600,000
</strong>{' '}
on a home. Your research method? Scrolling through listings and hoping for the best.
</p>
<p className="text-lg leading-relaxed mb-4">
Listings only show what&apos;s on the market <em>right now</em> &mdash; a tiny, random
slice of what&apos;s actually out there. You&apos;ll never see the 3-bed Victorian on a
quiet street that sold six months ago, or the one that&apos;ll list next month.
</p>
<p className="text-base italic text-warm-500 dark:text-warm-400">
Your home is not a box of cereal. Don&apos;t let a discount on the wrong property distract
you from finding the right one.
</p>
</>
),
},
{
heading: 'Set your requirements. The map shows you where they intersect.',
body: (
<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>&hellip;
</p>
),
},
{
heading: null,
body: (
<p className="text-lg leading-relaxed">
&hellip;with{' '}
<strong className="text-navy-950 dark:text-warm-100">good primary schools</strong>{' '}
nearby&hellip;
</p>
),
},
{
heading: null,
body: (
<>
<p className="text-lg leading-relaxed mb-4">
&hellip;and{' '}
<strong className="text-navy-950 dark:text-warm-100">
restaurants within walking distance
</strong>
.
</p>
<p className="text-lg leading-relaxed font-semibold text-navy-950 dark:text-warm-100">
You haven&apos;t opened a single listing yet &mdash; and you already know exactly where to
focus.
</p>
</>
),
},
{
heading: null,
body: (
<>
<p className="text-xl font-bold text-navy-950 dark:text-warm-100 mb-2">
That&apos;s just three filters.
</p>
<p className="text-lg leading-relaxed">
We&apos;ve built <strong className="text-navy-950 dark:text-warm-100">43</strong>.
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.
</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 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]);
// Fetch hex data when filters change
useEffect(() => {
if (features.length === 0) return;
const params = new URLSearchParams({
resolution: String(DEMO_RESOLUTION),
bounds: DEMO_BOUNDS,
});
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]);
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;
return (
<section className="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={hexData}
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={DEMO_VIEW}
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 */}
<div className="absolute bottom-4 left-4 z-40 pointer-events-none w-[200px] md:w-[240px]">
<div className="bg-white/85 dark:bg-warm-800/85 rounded-lg p-3 backdrop-blur-sm shadow-lg space-y-2.5">
{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-[11px] mb-1 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-medium whitespace-nowrap">
{formatValue(filterVal[0], feature)}&ndash;
{formatValue(filterVal[1], feature)}
</span>
)}
</div>
<div className="relative h-1.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>
);
})}
</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>
);
}