lmao
This commit is contained in:
parent
03445188ea
commit
524580eb25
102 changed files with 36625 additions and 1295 deletions
342
frontend/src/components/home/ScrollStory.tsx
Normal file
342
frontend/src/components/home/ScrollStory.tsx
Normal 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're about to spend{' '}
|
||||
<strong className="text-navy-950 dark:text-warm-100">
|
||||
£300,000–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's on the market <em>right now</em> — a tiny, random
|
||||
slice of what's actually out there. You'll never see the 3-bed Victorian on a
|
||||
quiet street that sold six months ago, or the one that'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'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>…
|
||||
</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 mb-4">
|
||||
…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't opened a single listing yet — 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's just three filters.
|
||||
</p>
|
||||
<p className="text-lg leading-relaxed">
|
||||
We'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)}–
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue