import { memo, useCallback, useEffect, useMemo, useRef } from 'react' import { Codicon } from '@/components/ui/codicon' import { useI18n } from '@/i18n' import type { TimeAxis } from './time-axis' interface TimelineProps { axis: TimeAxis // Colour for memory stars — matches the map's memory glyph. memoryColor?: string onScrub: (reveal: number) => void onTogglePlay: () => void playing: boolean revealStore: RevealSignal // Reveal positions (0–1) where rings spawn — drawn as anchor ticks. ringStops?: number[] } interface RevealSignal { get: () => number subscribe: (listener: (value: number) => void) => () => void } interface Star { delay: number duration: number kind: 'memory' | 'skill' leftPct: number opacity: number size: number topPct: number } const ACTIVE_MARKER_CLASS = 'opacity-100' const INACTIVE_MARKER_CLASS = 'opacity-30' // Busiest bucket gets this many stars; quieter ones scale down proportionally. const MAX_STARS_PER_BUCKET = 7 // Deterministic PRNG (mulberry32) so a bucket's stars stay put across renders. function rng(seed: number): () => number { let a = seed >>> 0 return () => { a += 0x6d2b79f5 let t = a t = Math.imul(t ^ (t >>> 15), t | 1) t ^= t + Math.imul(t ^ (t >>> 7), t | 61) return ((t ^ (t >>> 14)) >>> 0) / 4294967296 } } // Scatter each time bucket's activity into stars: count ∝ events, split between // skill- and memory-coloured stars, jittered within the bucket's horizontal slot // and across the track height. Vertical placement is biased toward the midline // (a triangular distribution) so stars cluster near the centre more often than // the edges. A starmap timeline for a starmap. function buildStars(axis: TimeAxis): Star[] { const n = Math.max(1, axis.buckets.length) const stars: Star[] = [] axis.buckets.forEach((b, i) => { if (b.total === 0) { return } const intensity = axis.maxTotal > 0 ? b.total / axis.maxTotal : 0 // sqrt curve so a single co-timed burst (a packed core ring) doesn't crush // every quieter bucket down to the 1-star floor and read as blank. const count = Math.max(1, Math.round(Math.sqrt(intensity) * MAX_STARS_PER_BUCKET)) const skillCount = Math.round((b.skill / b.total) * count) const r = rng(i * 9973 + 7) const slot = 1 / n const center = (i + 0.5) / n for (let s = 0; s < count; s++) { const jitter = (r() - 0.5) * slot * 0.9 // Average of two uniforms → triangular peak at 0.5, pulling stars toward // the midline more often while still reaching the edges occasionally. const vertical = (r() + r()) / 2 stars.push({ delay: r() * 3, duration: 2.4 + r() * 2.6, kind: s < skillCount ? 'skill' : 'memory', leftPct: Math.max(0, Math.min(1, center + jitter)) * 100, // Brighter, slightly larger stars are rarer. opacity: 0.5 + r() * 0.5, // Floor at 2px so a lone star in a quiet bucket still reads on black. size: 2 + Math.round(r() * r() * 2.2), topPct: 12 + vertical * 76 }) } }) return stars } // Playback scrubber as a constellation: dim stars are the unrevealed future; a // scanner sweep ignites them (bright + twinkling) left→right as the reveal // advances. The bright layer is clipped by the reveal CSS var, so the rAF sweep // in StarMap drives it with zero per-frame JS. export const Timeline = memo(function Timeline({ axis, memoryColor = 'var(--theme-secondary)', onScrub, onTogglePlay, playing, revealStore, ringStops = [] }: TimelineProps) { const { t } = useI18n() const trackRef = useRef(null) const draggingRef = useRef(false) const markerRefs = useRef([]) const stars = useMemo(() => buildStars(axis), [axis]) const syncReveal = useCallback( (value: number) => { const reveal = Math.max(0, Math.min(1, value)) const track = trackRef.current if (track) { track.style.setProperty('--starmap-reveal', String(reveal)) track.setAttribute('aria-valuenow', String(Math.round(reveal * 100))) } ringStops.forEach((stop, i) => { const el = markerRefs.current[i] if (!el) { return } const active = stop <= reveal el.classList.toggle(ACTIVE_MARKER_CLASS, active) el.classList.toggle(INACTIVE_MARKER_CLASS, !active) }) }, [ringStops] ) useEffect(() => revealStore.subscribe(syncReveal), [revealStore, syncReveal]) useEffect(() => { markerRefs.current.length = ringStops.length syncReveal(revealStore.get()) }, [revealStore, ringStops.length, syncReveal]) const ratioAt = (clientX: number): number => { const rect = trackRef.current?.getBoundingClientRect() if (!rect || rect.width === 0) { return revealStore.get() } return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) } const onPointerDown = (e: React.PointerEvent) => { draggingRef.current = true e.currentTarget.setPointerCapture(e.pointerId) onScrub(ratioAt(e.clientX)) } const onPointerMove = (e: React.PointerEvent) => { if (draggingRef.current) { onScrub(ratioAt(e.clientX)) } } const onPointerUp = (e: React.PointerEvent) => { draggingRef.current = false if (e.currentTarget.hasPointerCapture(e.pointerId)) { e.currentTarget.releasePointerCapture(e.pointerId) } } const colorFor = (kind: Star['kind']) => (kind === 'skill' ? 'var(--theme-primary)' : memoryColor) return (
{/* Dashed midline — a faint horizontal axis the stars ride over. */}
{/* Dim constellation — the unrevealed future. */}
{stars.map((star, i) => (
))}
{/* Ignited constellation — bright + twinkling, clipped to the reveal. */}
{stars.map((star, i) => { const color = colorFor(star.kind) return (
) })}
{/* Ring-spawn anchor ticks — small bright stars that light up on pass. */}
{ringStops.map((stop, i) => (
{ if (el) { markerRefs.current[i] = el } }} style={{ left: `${stop * 100}%` }} /> ))}
{/* Playhead — a thin white sweep line. */}
) })