Spaces:
Sleeping
Sleeping
| import { useEffect, useRef, useState } from 'react' | |
| import { Home, Cpu, BarChart3, Flag, Sun, Moon, Navigation } from 'lucide-react' | |
| import { Button } from '../ui/neon-button' | |
| import { LimelightNav, type NavItem } from '../ui/limelight-nav' | |
| import { getScroll } from '../../lib/landing/scroll' | |
| import { useTwinStore } from '../../lib/twin/store' | |
| import { getLenisRef } from '../../lib/landing/lenis-ref' | |
| // Section ids in nav order - index maps 1:1 to the limelight items below. | |
| // MUST stay in the same top-to-bottom order the sections actually render in | |
| // LandingPage (problem → matrix → how → blind-spots → trust). The scroll-spy | |
| // interpolates between consecutive section tops, so a mismatch here makes the | |
| // limelight indicator track the wrong link. | |
| const SECTION_IDS = ['problem', 'matrix', 'how', 'blind-spots', 'trust'] | |
| /** | |
| * Sticky top nav. Condenses with a frosted background after the hero. | |
| */ | |
| export function Nav({ visible }: { visible: boolean }) { | |
| const [scrolled, setScrolled] = useState(false) | |
| // Active section index (0=Hero … 3=Findings). Derived from a continuous | |
| // scroll position but ROUNDED to the nearest section, so the light always | |
| // sits centered on the highlighted word (the label uses the same rounding). | |
| const [navActive, setNavActive] = useState(0) | |
| // When a nav item is clicked we jump the light straight to the target and | |
| // ignore the scroll-spy until the smooth-scroll actually lands there - so the | |
| // light glides directly instead of pausing on every section it passes over. | |
| const jumpTargetRef = useRef(-1) | |
| const jumpTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) | |
| // Continuous progress state for the limelight | |
| const [navProgress, setNavProgress] = useState(0) | |
| const lastProgressRef = useRef(0) | |
| useEffect(() => { | |
| const onScroll = () => setScrolled(window.scrollY > window.innerHeight * 0.6) | |
| window.addEventListener('scroll', onScroll, { passive: true }) | |
| return () => window.removeEventListener('scroll', onScroll) | |
| }, []) | |
| // Scroll-spy driven by a rAF loop reading the SAME Lenis-synced scroll value | |
| // the 3D scene uses (getScroll). This removes the two lag sources: the | |
| // IntersectionObserver hysteresis AND the smooth-scroll/CSS-transition delay. | |
| // We compute a continuous position, then ROUND to the active section so the | |
| // light stays centered on the highlighted word (it uses the same rounding). | |
| useEffect(() => { | |
| let raf = 0 | |
| let last = -1 | |
| const measure = () => { | |
| // Offset by 100px so sections activate when they sit nicely below the nav. | |
| const navOffset = 100 | |
| return SECTION_IDS.map((id) => { | |
| const el = document.getElementById(id) | |
| return el ? el.getBoundingClientRect().top + window.scrollY - navOffset : null | |
| }) | |
| } | |
| const remeasure = () => {} | |
| // We no longer cache tops on mount, we'll measure continuously to ensure | |
| // pixel-perfect alignment even if the layout shifts slightly (fonts loading etc). | |
| const loop = () => { | |
| raf = requestAnimationFrame(loop) | |
| const tops = measure() | |
| const { scrollY } = getScroll() | |
| // Find the bracketing sections and interpolate between their tops. | |
| let p = 0 | |
| const maxScroll = document.documentElement.scrollHeight - window.innerHeight | |
| if (scrollY >= maxScroll - 10) { | |
| // Force active state to the last section when at the absolute bottom | |
| p = tops.length - 1 | |
| } else { | |
| for (let i = 0; i < tops.length; i++) { | |
| const top = tops[i] | |
| if (top == null) continue | |
| if (scrollY >= top) { | |
| const next = tops[i + 1] | |
| if (next != null && next > top) { | |
| const t = Math.min(1, (scrollY - top) / (next - top)) | |
| p = i + t | |
| } else { | |
| p = i | |
| } | |
| } | |
| } | |
| } | |
| const active = Math.round(p) | |
| // While a click-jump is in flight, suppress intermediate sections - only | |
| // release the lock once the scroll has reached the clicked target. | |
| if (jumpTargetRef.current !== -1) { | |
| if (active === jumpTargetRef.current) { | |
| jumpTargetRef.current = -1 | |
| last = active | |
| } | |
| return | |
| } | |
| // We round p to 3 decimal places to prevent micro-jitter react updates | |
| const smoothP = Math.round(p * 1000) / 1000 | |
| if (smoothP !== lastProgressRef.current) { | |
| lastProgressRef.current = smoothP | |
| setNavProgress(smoothP) | |
| } | |
| if (active !== last) { | |
| last = active | |
| setNavActive(active) | |
| } | |
| } | |
| raf = requestAnimationFrame(loop) | |
| return () => { | |
| cancelAnimationFrame(raf) | |
| } | |
| }, []) | |
| const go = (id: string) => { | |
| const index = SECTION_IDS.indexOf(id) | |
| if (index !== -1) { | |
| // Send the light straight to the target and lock out intermediate spy | |
| // updates until the scroll arrives. | |
| jumpTargetRef.current = index | |
| setNavActive(index) | |
| // Failsafe: release the lock even if the scroll never lands exactly on the | |
| // target (e.g. the user scrolls manually mid-jump). | |
| if (jumpTimerRef.current) clearTimeout(jumpTimerRef.current) | |
| jumpTimerRef.current = setTimeout(() => (jumpTargetRef.current = -1), 1500) | |
| } | |
| const el = document.getElementById(id) | |
| if (el) { | |
| const navOffset = 100 | |
| const targetY = el.getBoundingClientRect().top + window.scrollY - navOffset | |
| const lenis = getLenisRef() | |
| if (lenis) { | |
| lenis.scrollTo(targetY) | |
| } else { | |
| window.scrollTo({ top: targetY, behavior: 'smooth' }) | |
| } | |
| } | |
| } | |
| // Center limelight nav. Order matches SECTION_IDS so scroll-spy maps 1:1. | |
| const navItems: NavItem[] = [ | |
| { id: 'problem', icon: <Flag />, label: 'PROBLEM', onClick: () => go('problem') }, | |
| { id: 'matrix', icon: <BarChart3 />, label: 'THE MATRIX', onClick: () => go('matrix') }, | |
| { id: 'how', icon: <Cpu />, label: 'HOW IT WORKS', onClick: () => go('how') }, | |
| { id: 'blind-spots', icon: <Navigation />, label: 'BLIND SPOTS', onClick: () => go('blind-spots') }, | |
| { id: 'trust', icon: <Flag />, label: 'TRUST', onClick: () => go('trust') }, | |
| ] | |
| return ( | |
| <header | |
| className="fixed inset-x-0 top-0 z-50 flex items-center justify-between px-8 py-5" | |
| style={{ | |
| opacity: visible ? 1 : 0, | |
| transition: 'opacity 0.6s ease, background 0.3s ease, border-color 0.3s ease', | |
| background: scrolled ? 'var(--lp-nav)' : 'transparent', | |
| backdropFilter: scrolled ? 'blur(12px)' : 'none', | |
| WebkitBackdropFilter: scrolled ? 'blur(12px)' : 'none', | |
| borderBottom: `1px solid ${scrolled ? 'var(--lp-line)' : 'transparent'}`, | |
| }} | |
| > | |
| {/* Wordmark */} | |
| <button | |
| onClick={() => go('hero')} | |
| className="flex items-center gap-2.5" | |
| style={{ cursor: 'pointer' }} | |
| > | |
| <span | |
| className="police-light inline-block h-2 w-2 rounded-full" | |
| /> | |
| <span | |
| className="text-[12px] font-semibold uppercase" | |
| style={{ fontFamily: 'var(--font-mono)', letterSpacing: '0.2em', color: 'var(--lp-text)' }} | |
| > | |
| TrafficOps Twin | |
| </span> | |
| </button> | |
| {/* Center links - limelight text nav, absolutely centered so it never | |
| clashes with the wordmark or CTA regardless of their widths. */} | |
| <div className="pointer-events-none absolute left-1/2 top-1/2 hidden -translate-x-1/2 -translate-y-1/2 md:block"> | |
| <div className="pointer-events-auto"> | |
| <LimelightNav | |
| items={navItems} | |
| activeIndex={navActive} | |
| progress={jumpTargetRef.current !== -1 ? undefined : navProgress} | |
| showLabels | |
| className="h-12 rounded-xl !border-transparent !bg-transparent text-[var(--lp-text)]" | |
| limelightClassName="!bg-[var(--lp-text)] !shadow-[0_50px_15px_var(--lp-text)]" | |
| iconClassName="hidden" | |
| iconContainerClassName="!gap-0" | |
| /> | |
| </div> | |
| </div> | |
| {/* Right cluster: theme toggle + CTA */} | |
| <div className="flex items-center gap-3"> | |
| <ThemeToggle /> | |
| <Button | |
| onClick={() => go('action')} | |
| size="default" | |
| className="!mx-0 text-[12px] font-medium uppercase text-[var(--lp-text)]" | |
| style={{ fontFamily: 'var(--font-mono)', letterSpacing: '0.12em' }} | |
| > | |
| Enter the Twin | |
| </Button> | |
| </div> | |
| </header> | |
| ) | |
| } | |
| /** | |
| * Dark/Light theme toggle. Sets the app-wide theme, which the dashboard map + | |
| * panels follow (light theme → light basemap, dark → dark basemap). | |
| */ | |
| function ThemeToggle() { | |
| const theme = useTwinStore((s) => s.theme) | |
| const toggleTheme = useTwinStore((s) => s.toggleTheme) | |
| const light = theme === 'light' | |
| return ( | |
| <button | |
| onClick={toggleTheme} | |
| aria-label={light ? 'Switch to dark theme' : 'Switch to light theme'} | |
| title={light ? 'Switch to dark theme' : 'Switch to light theme'} | |
| className="flex h-9 w-9 items-center justify-center rounded-lg transition-colors" | |
| style={{ | |
| border: '1px solid var(--lp-line)', | |
| background: 'var(--lp-chip)', | |
| color: 'var(--lp-text)', | |
| cursor: 'pointer', | |
| }} | |
| > | |
| {light ? <Moon className="h-[18px] w-[18px]" /> : <Sun className="h-[18px] w-[18px]" />} | |
| </button> | |
| ) | |
| } | |