lassanmonster's picture
GridLoc: Bengaluru parking congestion digital twin
db3dbe1
Raw
History Blame Contribute Delete
9.39 kB
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>
)
}