LGTM
This commit is contained in:
parent
701c17a703
commit
f114ada255
44 changed files with 5264 additions and 1674 deletions
|
|
@ -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 />
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue