openvisengine/src/components/ava/beat-dot.tsx

122 lines
4.3 KiB
TypeScript
Executable file

// AVA — the green beat indicator.
//
// A circular component: emerald center dot + thin SVG phase ring that fills
// with beat.phase over each beat period. On every beat pulse the dot scales up
// and glows (~180 ms). When the pulse came from the *predictor* the flash is
// rendered as an expanding outline ring + "PRED" badge instead of a filled
// dot, so prediction is visible at a glance.
'use client'
import { useEffect, useState } from 'react'
import { motion } from 'framer-motion'
import { useAvaStore } from '@/lib/store'
import { cn } from '@/lib/utils'
interface BeatDotProps {
size?: number
className?: string
}
export function BeatDot({ size = 44, className }: BeatDotProps) {
const phase = useAvaStore((s) => s.stats?.beat.phase ?? 0)
const beatFlash = useAvaStore((s) => s.beatFlash)
const bpm = useAvaStore((s) => s.stats?.beat.bpm ?? 0)
const idle = useAvaStore((s) => s.source) === 'idle'
// flash state is derived: a flash is "on" until its seq is cleared by the
// hold timeout (setState only ever happens inside the timeout callback)
const [clearedSeq, setClearedSeq] = useState(-1)
useEffect(() => {
if (!beatFlash) return
const hold = beatFlash.source === 'predicted' ? 450 : 180
const t = setTimeout(() => setClearedSeq(beatFlash.seq), hold)
return () => clearTimeout(t)
}, [beatFlash])
const flashing = beatFlash !== null && beatFlash.seq > clearedSeq
const flashSource = flashing ? beatFlash.source : 'none'
const r = size / 2 - 3
const c = 2 * Math.PI * r
const predicted = flashing && flashSource === 'predicted'
const dotBase = Math.max(8, size * 0.3)
return (
<div
className={cn('relative shrink-0 select-none', className)}
style={{ width: size, height: size }}
role="img"
aria-label={
idle
? 'Beat indicator — paused, no audio playing'
: `Beat indicator — ${bpm > 0 ? `${bpm.toFixed(1)} BPM` : 'no tempo'}${flashSource === 'predicted' ? ', predicted' : ''}`
}
>
{/* phase progress ring */}
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="absolute inset-0 -rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="currentColor"
strokeWidth={Math.max(2, size / 22)}
className="text-zinc-800"
/>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="currentColor"
strokeWidth={Math.max(2, size / 22)}
strokeLinecap="round"
strokeDasharray={c}
strokeDashoffset={c * (1 - Math.min(1, Math.max(0, phase)))}
className="text-emerald-400 transition-[stroke-dashoffset] duration-150 ease-linear"
/>
</svg>
{/* filled pulse dot (audio / tap beats) — dimmed while no source is
playing/loaded, since beat detection is suspended (v6.4) */}
<div
className={cn(
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-emerald-400 transition-all duration-150 ease-out',
flashing && !predicted
? 'glow-beat-flash opacity-100'
: idle
? 'opacity-35'
: 'glow-beat-idle opacity-80',
)}
style={{
width: dotBase,
height: dotBase,
transform: `translate(-50%, -50%) scale(${flashing && !predicted ? 1.9 : 1})`,
}}
/>
{/* predicted beats: expanding outline ring instead of the filled dot */}
{predicted && (
<motion.span
initial={{ scale: 0.45, opacity: 0.9 }}
animate={{ scale: 1.3, opacity: 0 }}
transition={{ duration: 0.45, ease: 'easeOut' }}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-emerald-300"
style={{ width: size * 0.62, height: size * 0.62 }}
/>
)}
{/* PRED badge — visible while a predicted pulse is showing */}
{predicted && (
<span
className="absolute left-1/2 -translate-x-1/2 rounded border border-emerald-400/40 bg-zinc-950/90 px-1 py-px font-mono text-emerald-300"
style={{ bottom: size >= 64 ? -18 : -13, fontSize: size >= 64 ? 9 : 7, letterSpacing: '0.08em' }}
>
PRED
</span>
)}
</div>
)
}