Files
aiturk-hermes-ide/apps/desktop/src/app/starmap/timeline.tsx
T

292 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<HTMLDivElement | null>(null)
const draggingRef = useRef(false)
const markerRefs = useRef<HTMLDivElement[]>([])
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<HTMLDivElement>) => {
draggingRef.current = true
e.currentTarget.setPointerCapture(e.pointerId)
onScrub(ratioAt(e.clientX))
}
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (draggingRef.current) {
onScrub(ratioAt(e.clientX))
}
}
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
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 (
<div className="pointer-events-auto flex w-[28rem] max-w-full items-center gap-3 [-webkit-app-region:no-drag]">
<style>{'@keyframes starmap-twinkle{0%,100%{opacity:var(--o,1)}50%{opacity:calc(var(--o,1) * 0.35)}}'}</style>
<button
aria-label={playing ? t.surfaces.pause : t.surfaces.playTimeline}
className="flex size-5 shrink-0 items-center justify-center text-foreground/75 transition-colors hover:text-foreground"
onClick={onTogglePlay}
type="button"
>
<Codicon name={playing ? 'debug-pause' : 'triangle-right'} size={playing ? '0.8rem' : '0.95rem'} />
</button>
<div
aria-label={t.surfaces.timeline}
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={Math.round(revealStore.get() * 100)}
className="relative h-7 min-w-0 flex-1 cursor-pointer select-none touch-none"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
ref={trackRef}
role="slider"
style={{ '--starmap-reveal': revealStore.get() } as React.CSSProperties}
tabIndex={0}
>
{/* Dashed midline — a faint horizontal axis the stars ride over. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 border-t border-dashed border-foreground/5"
/>
{/* Dim constellation — the unrevealed future. */}
<div aria-hidden className="absolute inset-0">
{stars.map((star, i) => (
<div
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full"
key={i}
style={{
backgroundColor: colorFor(star.kind),
height: star.size,
left: `${star.leftPct}%`,
opacity: 0.22,
top: `${star.topPct}%`,
width: star.size
}}
/>
))}
</div>
{/* Ignited constellation — bright + twinkling, clipped to the reveal. */}
<div
aria-hidden
className="absolute inset-0"
style={{ clipPath: 'inset(0 calc((1 - var(--starmap-reveal, 1)) * 100%) 0 0)' }}
>
{stars.map((star, i) => {
const color = colorFor(star.kind)
return (
<div
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full"
key={i}
style={
{
'--o': star.opacity,
animation: `starmap-twinkle ${star.duration}s ease-in-out ${star.delay}s infinite`,
backgroundColor: color,
height: star.size,
left: `${star.leftPct}%`,
opacity: star.opacity,
top: `${star.topPct}%`,
width: star.size
} as React.CSSProperties
}
/>
)
})}
</div>
{/* Ring-spawn anchor ticks — small bright stars that light up on pass. */}
<div aria-hidden className="absolute inset-0">
{ringStops.map((stop, i) => (
<div
className={`pointer-events-none absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--theme-primary)] ${INACTIVE_MARKER_CLASS}`}
key={i}
ref={el => {
if (el) {
markerRefs.current[i] = el
}
}}
style={{ left: `${stop * 100}%` }}
/>
))}
</div>
{/* Playhead — a thin white sweep line. */}
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2 bg-foreground"
style={{ left: 'calc(var(--starmap-reveal, 1) * 100%)' }}
/>
</div>
</div>
)
})