ai-code-battle/web/src/components/event-timeline.ts
jedarden d3f2068f8b feat(replay): implement Director Mode adaptive auto-speed playback per §16.10
Add director.ts component with action density computation, speed schedule
generation, and eased speed transitions. Integrate into replay viewer with
Director option in speed selector, target duration presets (30s/1min/2min/5min),
speed indicator display, and scrubbing pause/resume.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 17:57:10 -04:00

266 lines
7.7 KiB
TypeScript

// Event Timeline Ribbon - Visual event strip with click-to-jump
import type { GameEvent } from '../types';
import type { Annotation } from './annotation';
export interface TimelineEvent {
turn: number;
type: string;
icon: string;
color: string;
}
// Map event types to icons and colors
const EVENT_CONFIG: Record<string, { icon: string; color: string }> = {
bot_spawned: { icon: '◆', color: '#22c55e' },
bot_died: { icon: '✕', color: '#ef4444' },
combat_death: { icon: '⚔', color: '#f97316' },
collision_death: { icon: '💥', color: '#eab308' },
energy_collected: { icon: '★', color: '#fbbf24' },
core_captured: { icon: '◉', color: '#3b82f6' },
core_destroyed: { icon: '⊘', color: '#6b7280' },
};
export class EventTimeline {
private container: HTMLElement;
private events: TimelineEvent[] = [];
private currentTurn: number = 0;
private totalTurns: number = 0;
private onTurnClick?: (turn: number) => void;
private annotations: Annotation[] = [];
constructor(container: HTMLElement, options?: { onTurnClick?: (turn: number) => void }) {
this.container = container;
this.onTurnClick = options?.onTurnClick;
this.render();
}
// Extract events from replay turns
setEvents(turns: { turn: number; events?: GameEvent[] }[]): void {
this.events = [];
this.totalTurns = turns.length;
for (const turnData of turns) {
const turnEvents = turnData.events ?? [];
for (const event of turnEvents) {
const config = EVENT_CONFIG[event.type] || { icon: '•', color: '#94a3b8' };
this.events.push({
turn: turnData.turn,
type: event.type,
icon: config.icon,
color: config.color,
});
}
}
this.render();
}
setCurrentTurn(turn: number): void {
this.currentTurn = turn;
this.updateHighlight();
}
setAnnotations(annotations: Annotation[]): void {
this.annotations = annotations;
this.render();
}
private render(): void {
const hasEvents = this.events.length > 0;
const hasAnnotations = this.annotations.length > 0;
if (!hasEvents && !hasAnnotations) {
this.container.innerHTML = '<div class="timeline-empty">No events</div>';
return;
}
const eventMarkers = this.events.map((e, idx) => {
const leftPercent = (e.turn / Math.max(1, this.totalTurns - 1)) * 100;
return `
<div class="timeline-event"
data-index="${idx}"
data-turn="${e.turn}"
style="left: ${leftPercent}%; color: ${e.color}"
title="Turn ${e.turn}: ${e.type.replace(/_/g, ' ')}">
${e.icon}
</div>
`;
}).join('');
// Annotation badges on timeline
const annotationIcons: Record<string, string> = {
insight: '\u{1F4A1}',
mistake: '⚠️',
idea: '\u{1F9EA}',
highlight: '⭐',
};
const annotationColors: Record<string, string> = {
insight: '#3b82f6',
mistake: '#ef4444',
idea: '#22c55e',
highlight: '#fbbf24',
};
const annotationMarkers = hasAnnotations ? this.buildAnnotationMarkers(annotationIcons, annotationColors) : '';
this.container.innerHTML = `
<div class="timeline-track">
<div class="timeline-progress" id="timeline-progress"></div>
${eventMarkers}
${annotationMarkers}
</div>
<div class="timeline-turn-label">
<span id="timeline-current">0</span> / <span id="timeline-total">${this.totalTurns}</span>
</div>
`;
// Wire click handlers (both events and annotation markers)
this.container.querySelectorAll('.timeline-event').forEach(el => {
el.addEventListener('click', (e) => {
const turn = parseInt((e.currentTarget as HTMLElement).dataset.turn || '0', 10);
if (this.onTurnClick) {
this.onTurnClick(turn);
}
});
});
// Click on track to seek
const track = this.container.querySelector('.timeline-track') as HTMLElement | null;
if (track) {
track.addEventListener('click', (e: MouseEvent) => {
const rect = track.getBoundingClientRect();
const x = e.clientX - rect.left;
const percent = x / rect.width;
const turn = Math.floor(percent * this.totalTurns);
if (this.onTurnClick) {
this.onTurnClick(turn);
}
});
}
this.updateHighlight();
}
private buildAnnotationMarkers(
icons: Record<string, string>,
colors: Record<string, string>,
): string {
// Group annotations by turn
const grouped = new Map<number, Annotation[]>();
for (const ann of this.annotations) {
const list = grouped.get(ann.turn) ?? [];
list.push(ann);
grouped.set(ann.turn, list);
}
return [...grouped.entries()].map(([turn, anns]) => {
const leftPercent = (turn / Math.max(1, this.totalTurns - 1)) * 100;
const primary = anns[0].type;
const icon = icons[primary] ?? '\u{1F4CC}';
const color = colors[primary] ?? '#94a3b8';
const count = anns.length > 1 ? `<span class="ann-marker-count">${anns.length}</span>` : '';
return `<div class="timeline-event timeline-annotation"
data-turn="${turn}"
style="left: ${leftPercent}%; color: ${color}"
title="Turn ${turn}: ${anns.length} annotation${anns.length > 1 ? 's' : ''}">
${icon}${count}
</div>`;
}).join('');
}
private updateHighlight(): void {
const progress = this.container.querySelector('#timeline-progress') as HTMLElement;
const currentLabel = this.container.querySelector('#timeline-current') as HTMLElement;
if (progress) {
const percent = (this.currentTurn / Math.max(1, this.totalTurns - 1)) * 100;
progress.style.width = `${percent}%`;
}
if (currentLabel) {
currentLabel.textContent = String(this.currentTurn);
}
// Highlight events and annotations at current turn
this.container.querySelectorAll('.timeline-event, .timeline-annotation').forEach(el => {
const turn = parseInt((el as HTMLElement).dataset.turn || '0', 10);
el.classList.toggle('active', turn === this.currentTurn);
});
}
}
// CSS styles for timeline (inject into document)
export const EVENT_TIMELINE_STYLES = `
.event-timeline-container {
background-color: var(--bg-secondary, #1e293b);
border-radius: 8px;
padding: 12px;
margin-bottom: 16px;
}
.timeline-track {
position: relative;
height: 28px;
background-color: var(--bg-tertiary, #334155);
border-radius: 4px;
cursor: pointer;
overflow: visible;
}
.timeline-progress {
position: absolute;
top: 0;
left: 0;
height: 100%;
background-color: var(--accent, #3b82f6);
opacity: 0.3;
border-radius: 4px;
transition: width 0.05s linear;
}
.timeline-event {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
font-size: 14px;
cursor: pointer;
transition: transform 0.15s, text-shadow 0.15s;
z-index: 1;
text-shadow: 0 0 2px rgba(0,0,0,0.8);
}
.timeline-event:hover {
transform: translate(-50%, -50%) scale(1.4);
text-shadow: 0 0 4px currentColor;
}
.timeline-event.active {
transform: translate(-50%, -50%) scale(1.5);
text-shadow: 0 0 6px currentColor;
}
.timeline-turn-label {
text-align: center;
font-size: 12px;
color: var(--text-muted, #94a3b8);
margin-top: 6px;
}
.timeline-empty {
text-align: center;
color: var(--text-muted, #94a3b8);
font-size: 14px;
padding: 8px;
}
.timeline-annotation {
font-size: 10px;
}
.ann-marker-count {
position: absolute;
top: -8px;
font-size: 8px;
font-weight: 600;
color: var(--text-muted, #94a3b8);
}
`;