This commit is contained in:
Andras Schmelczer 2026-05-09 09:26:40 +01:00
parent 701c17a703
commit f114ada255
44 changed files with 5264 additions and 1674 deletions

View file

@ -33,7 +33,7 @@ import { DualHistogram } from '../map/DualHistogram';
import { FeatureLabel } from '../ui/FeatureLabel';
import { Slider } from '../ui/Slider';
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
import { PARTY_FEATURE_COLORS } from '../../lib/consts';
import { PARTY_FEATURE_COLORS, STACKED_SEGMENT_COLORS } from '../../lib/consts';
import { formatValue } from '../../lib/format';
import type {
FeatureMeta,
@ -47,6 +47,13 @@ import type {
const SHOWCASE_STEP_COUNT = 4;
const SHOWCASE_INTERVAL_MS = 5200;
const SHOWCASE_SCOUT_INTERVAL_MS = 9000;
const SHOWCASE_STEP_INTERVALS_MS = [
SHOWCASE_INTERVAL_MS,
SHOWCASE_INTERVAL_MS,
SHOWCASE_INTERVAL_MS,
SHOWCASE_SCOUT_INTERVAL_MS,
];
const FILTER_ANIMATION_MS = 5000;
const INSPECT_SCROLL_ANIMATION_MS = 4600;
const SCOUT_TABLE_REVEAL_MS = 2400;
@ -781,18 +788,6 @@ function RightPaneOnlyScreen({
return (
<div className="h-full overflow-hidden bg-white dark:bg-navy-900/60 dark:backdrop-blur-sm">
<div className="flex h-full flex-col overflow-hidden">
<div className="bg-white px-3 py-3 shadow-sm dark:bg-navy-900/65 sm:px-5 sm:py-4">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-teal-50 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300 sm:h-9 sm:w-9">
<MapPinIcon className="h-4 w-4 sm:h-5 sm:w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="text-base font-black leading-tight text-navy-950 dark:text-warm-100 sm:text-lg">
{t('home.showcaseStep3HeaderArea')}
</div>
</div>
</div>
</div>
<div
ref={scrollerRef}
className="min-h-0 flex-1 space-y-2 overflow-y-auto p-2.5 scrollbar-hide sm:space-y-3 sm:p-4"
@ -844,7 +839,11 @@ function RightPaneOnlyScreen({
<ChartBarIcon className="h-4 w-4 text-teal-600 dark:text-teal-400" />
<span>{t('home.showcaseStep3Stat2Label')}</span>
</div>
<StackedBarChart segments={CRIME_SEGMENTS} total={82} />
<StackedBarChart
segments={CRIME_SEGMENTS}
total={82}
colorMap={STACKED_SEGMENT_COLORS}
/>
</div>
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950/50 sm:p-4">
<div className="mb-2 flex items-center justify-between gap-3">
@ -1015,7 +1014,7 @@ function ScoutScreen({ isActive }: { isActive: boolean }) {
</div>
</div>
<div className="relative z-10 mt-3 shrink-0 rounded-lg bg-navy-950/55 p-3 text-center text-white shadow-2xl shadow-navy-950/20 backdrop-blur-sm sm:mt-4 sm:p-4">
<div className="relative z-10 mt-3 shrink-0 rounded-lg bg-navy-950/55 p-3 text-left text-white shadow-2xl shadow-navy-950/20 backdrop-blur-sm sm:mt-4 sm:p-4">
<div className="text-base font-black leading-tight">
{t('home.showcaseStep4Conclusion')}
</div>
@ -1025,7 +1024,7 @@ function ScoutScreen({ isActive }: { isActive: boolean }) {
'Test the commute from a real front door, not a borough name.',
'Compare viewings with evidence already in hand.',
].map((item) => (
<div key={item} className="flex justify-center gap-2">
<div key={item} className="flex gap-2">
<CheckIcon className="mt-0.5 h-4 w-4 shrink-0 text-teal-300" />
<span>{item}</span>
</div>
@ -1040,21 +1039,23 @@ function ScoutScreen({ isActive }: { isActive: boolean }) {
function DashboardShowcase({
activeStep,
active,
hasStarted,
inspectUserScrolledRef,
}: {
activeStep: number;
active: ShowcaseStep;
hasStarted: boolean;
inspectUserScrolledRef: MutableRefObject<boolean>;
}) {
const screens = [
<FilterOnlyScreen key="filter" isActive={activeStep === 0} />,
<EnglandHexMapScreen key="match" isActive={activeStep === 1} />,
<FilterOnlyScreen key="filter" isActive={hasStarted && activeStep === 0} />,
<EnglandHexMapScreen key="match" isActive={hasStarted && activeStep === 1} />,
<RightPaneOnlyScreen
key="inspect"
isActive={activeStep === 2}
isActive={hasStarted && activeStep === 2}
userScrolledRef={inspectUserScrolledRef}
/>,
<ScoutScreen key="scout" isActive={activeStep === 3} />,
<ScoutScreen key="scout" isActive={hasStarted && activeStep === 3} />,
];
const ActiveIcon = active.Icon;
const showStageHeader = activeStep !== 3;
@ -1102,6 +1103,9 @@ function HeroProductShowcase() {
const { t } = useTranslation();
const [activeStep, setActiveStep] = useState(0);
const [isStagePaused, setIsStagePaused] = useState(false);
const [hasStarted, setHasStarted] = useState(false);
const [canPauseOnHover, setCanPauseOnHover] = useState(false);
const showcaseRef = useRef<HTMLDivElement | null>(null);
const inspectUserScrolledRef = useRef(false);
const steps: ShowcaseStep[] = [
@ -1132,14 +1136,61 @@ function HeroProductShowcase() {
];
const active = steps[activeStep];
const activeStepIntervalMs = SHOWCASE_STEP_INTERVALS_MS[activeStep] ?? SHOWCASE_INTERVAL_MS;
const isProgressRunning = hasStarted && !isStagePaused;
useEffect(() => {
const showcase = showcaseRef.current;
if (!showcase || hasStarted) return;
if (!('IntersectionObserver' in window)) {
setHasStarted(true);
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
setHasStarted(true);
observer.disconnect();
},
{ threshold: 0.2 }
);
observer.observe(showcase);
return () => observer.disconnect();
}, [hasStarted]);
useEffect(() => {
if (typeof window.matchMedia !== 'function') return;
const mediaQuery = window.matchMedia('(hover: hover) and (pointer: fine)');
const updateCanPause = () => {
setCanPauseOnHover(mediaQuery.matches);
if (!mediaQuery.matches) setIsStagePaused(false);
};
updateCanPause();
mediaQuery.addEventListener('change', updateCanPause);
return () => mediaQuery.removeEventListener('change', updateCanPause);
}, []);
const pauseForHover = () => {
if (canPauseOnHover) setIsStagePaused(true);
};
const resumeAfterHover = () => {
if (canPauseOnHover) setIsStagePaused(false);
};
return (
<div
ref={showcaseRef}
className="home-hero-showcase dark relative w-full min-w-0 max-w-[58rem] justify-self-center"
onMouseEnter={() => setIsStagePaused(true)}
onMouseLeave={() => setIsStagePaused(false)}
onFocus={() => setIsStagePaused(true)}
onBlur={() => setIsStagePaused(false)}
onMouseEnter={pauseForHover}
onMouseLeave={resumeAfterHover}
onFocus={pauseForHover}
onBlur={resumeAfterHover}
aria-label={t('home.showcaseContext')}
>
<div className="home-hero-showcase-frame flex h-[36rem] flex-col overflow-hidden rounded-lg bg-navy-950/50 shadow-2xl shadow-black/40 backdrop-blur-sm ring-1 ring-white/10 sm:h-[38rem] md:h-[40rem]">
@ -1171,13 +1222,13 @@ function HeroProductShowcase() {
</span>
</div>
<span className="mt-1.5 block h-1 overflow-hidden rounded-full bg-white/10 sm:mt-2">
{activeStep === index && (
{hasStarted && activeStep === index && (
<span
key={activeStep}
className="showcase-progress block h-full origin-left bg-teal-400"
style={{
animationDuration: `${SHOWCASE_INTERVAL_MS}ms`,
animationPlayState: isStagePaused ? 'paused' : 'running',
animationDuration: `${activeStepIntervalMs}ms`,
animationPlayState: isProgressRunning ? 'running' : 'paused',
}}
onAnimationEnd={() =>
setActiveStep((step) => (step + 1) % SHOWCASE_STEP_COUNT)
@ -1194,6 +1245,7 @@ function HeroProductShowcase() {
<DashboardShowcase
activeStep={activeStep}
active={active}
hasStarted={hasStarted}
inspectUserScrolledRef={inspectUserScrolledRef}
/>
</div>
@ -1215,6 +1267,8 @@ export default function HomePage({
}) {
const { t } = useTranslation();
const [statsActive, setStatsActive] = useState(false);
const homeScrollerRef = useRef<HTMLDivElement | null>(null);
const homeSurfaceRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const timer = setTimeout(() => setStatsActive(true), 300);
return () => clearTimeout(timer);
@ -1223,6 +1277,68 @@ export default function HomePage({
const whyRef = useFadeInRef();
const ctaRef = useFadeInRef();
useEffect(() => {
const scroller = homeScrollerRef.current;
if (!scroller) return;
let frame = 0;
const syncParallax = () => {
frame = 0;
scroller.style.setProperty('--home-scroll-y', `${scroller.scrollTop}px`);
};
const onScroll = () => {
if (frame) return;
frame = requestAnimationFrame(syncParallax);
};
syncParallax();
scroller.addEventListener('scroll', onScroll, { passive: true });
return () => {
scroller.removeEventListener('scroll', onScroll);
if (frame) cancelAnimationFrame(frame);
};
}, []);
useEffect(() => {
const surface = homeSurfaceRef.current;
if (!surface) return;
let frame = 0;
let pointerX = 0;
let pointerY = 0;
const syncPointer = () => {
frame = 0;
surface.style.setProperty('--home-pointer-x', `${pointerX}px`);
surface.style.setProperty('--home-pointer-y', `${pointerY}px`);
};
const queuePointerSync = (event: PointerEvent) => {
const rect = surface.getBoundingClientRect();
pointerX = event.clientX - rect.left;
pointerY = event.clientY - rect.top;
if (frame) return;
frame = requestAnimationFrame(syncPointer);
};
const onPointerEnter = (event: PointerEvent) => {
queuePointerSync(event);
surface.style.setProperty('--home-pointer-active', '1');
};
const onPointerLeave = () => {
surface.style.setProperty('--home-pointer-active', '0');
};
surface.style.setProperty('--home-pointer-active', '0');
surface.addEventListener('pointerenter', onPointerEnter);
surface.addEventListener('pointermove', queuePointerSync, { passive: true });
surface.addEventListener('pointerleave', onPointerLeave);
return () => {
surface.removeEventListener('pointerenter', onPointerEnter);
surface.removeEventListener('pointermove', queuePointerSync);
surface.removeEventListener('pointerleave', onPointerLeave);
if (frame) cancelAnimationFrame(frame);
};
}, []);
// Scroll depth tracking
const scrolledSections = useRef(new Set<string>());
useEffect(() => {
@ -1274,11 +1390,18 @@ export default function HomePage({
};
return (
<div className="flex-1 overflow-y-auto overflow-x-hidden bg-warm-50 dark:bg-navy-950 relative">
<div
ref={homeScrollerRef}
className="home-page-scroll flex-1 overflow-y-auto overflow-x-hidden bg-warm-50 dark:bg-navy-950 relative"
>
<div className="relative" style={{ zIndex: 1 }}>
{/* Hero */}
<div className="relative overflow-hidden bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 dark:from-navy-950 dark:via-navy-900 dark:to-navy-950 min-h-[calc(100dvh-3rem)] flex flex-col">
<HexCanvas isDark={theme === 'dark'} animated={false} />
<HexCanvas
isDark={theme === 'dark'}
animated={false}
className="home-hero-hex-parallax"
/>
<div className="home-hero-container relative z-10 mx-auto flex w-full max-w-[104rem] flex-1 flex-col px-6 pb-8 pt-6 backdrop-blur-[2px] md:px-10 md:py-10">
<div className="home-hero-layout hero-roomy-lift grid flex-1 items-center gap-x-8 gap-y-6">
<div className="home-hero-copy min-w-0 max-w-4xl">
@ -1352,7 +1475,7 @@ export default function HomePage({
</button>
</div>
<div className="home-content-surface relative overflow-hidden">
<div ref={homeSurfaceRef} className="home-content-surface relative overflow-hidden">
<div className="relative z-10">
<ProductDemoVideo />