From d3f2068f8b712d0aa0f41cb18207e453715b9308 Mon Sep 17 00:00:00 2001 From: jedarden Date: Tue, 21 Apr 2026 17:57:10 -0400 Subject: [PATCH] =?UTF-8?q?feat(replay):=20implement=20Director=20Mode=20a?= =?UTF-8?q?daptive=20auto-speed=20playback=20per=20=C2=A716.10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/components/director.ts | 285 +++++++++++++++++++++++++++ web/src/components/event-timeline.ts | 79 +++++++- web/src/pages/feedback.ts | 103 ++++------ web/src/pages/replay.ts | 196 ++++++++++++++++++ web/src/replay-viewer.ts | 72 ++++++- 5 files changed, 656 insertions(+), 79 deletions(-) create mode 100644 web/src/components/director.ts diff --git a/web/src/components/director.ts b/web/src/components/director.ts new file mode 100644 index 0000000..e3765b4 --- /dev/null +++ b/web/src/components/director.ts @@ -0,0 +1,285 @@ +// Director Mode: adaptive auto-speed playback per §16.10 +import type { Replay, ReplayTurn } from '../types'; + +// Speed multiplier to ms-per-turn mapping +// "x" means that many turns per second at the base rate of 2 turns/sec at 1x +export const SPEED_MULTIPLIERS = [1, 2, 4, 8, 16] as const; +export type SpeedMultiplier = typeof SPEED_MULTIPLIERS[number]; + +// Base rate: 1x = 500ms/turn (2 turns/sec) +const BASE_MS_PER_TURN = 500; + +export function multiplierToMs(mult: SpeedMultiplier): number { + return BASE_MS_PER_TURN / mult; +} + +export function msToMultiplier(ms: number): SpeedMultiplier { + const ratio = BASE_MS_PER_TURN / ms; + let best: SpeedMultiplier = 16; + for (const m of SPEED_MULTIPLIERS) { + if (Math.abs(m - ratio) < Math.abs(best - ratio)) best = m; + } + return best; +} + +// Target duration presets (in seconds) +export const DURATION_PRESETS = [30, 60, 120, 300] as const; +export type DurationPreset = typeof DURATION_PRESETS[number]; + +export const DURATION_LABELS: Record = { + 30: '30s', + 60: '1min', + 120: '2min', + 300: '5min', +}; + +export interface DirectorConfig { + targetDuration: DurationPreset; +} + +export const DEFAULT_DIRECTOR_CONFIG: DirectorConfig = { + targetDuration: 60, +}; + +export function loadDirectorConfig(): DirectorConfig { + try { + const raw = localStorage.getItem('acb-director-config'); + if (raw) return { ...DEFAULT_DIRECTOR_CONFIG, ...JSON.parse(raw) }; + } catch {} + return { ...DEFAULT_DIRECTOR_CONFIG }; +} + +export function saveDirectorConfig(config: DirectorConfig): void { + try { + localStorage.setItem('acb-director-config', JSON.stringify(config)); + } catch {} +} + +// ── Action density computation ────────────────────────────────────────────── + +export interface ActionDensity { + density: number; + deaths: number; + captures: number; + energyCollected: number; + spawns: number; + deltaWinProb: number; +} + +/** + * Compute action density for a single turn using the formula from §16.10: + * action_density(turn) = deaths × 3.0 + captures × 5.0 + + * energy_collected × 0.5 + spawns × 1.0 + + * abs(delta_win_prob) × 10.0 + */ +export function computeActionDensity( + turn: ReplayTurn, + _prevTurn: ReplayTurn | null, + winProb?: number[][], + turnIndex?: number, +): ActionDensity { + const events = turn.events ?? []; + + let deaths = 0; + let captures = 0; + let energyCollected = 0; + let spawns = 0; + + for (const event of events) { + switch (event.type) { + case 'bot_died': + case 'combat_death': + case 'collision_death': + deaths++; + break; + case 'core_captured': + case 'core_destroyed': + captures++; + break; + case 'energy_collected': + energyCollected++; + break; + case 'bot_spawned': + spawns++; + break; + } + } + + let deltaWinProb = 0; + if (winProb && turnIndex != null && turnIndex > 0) { + const prev = winProb[turnIndex - 1]; + const curr = winProb[turnIndex]; + if (prev && curr) { + // Sum absolute delta across all players + for (let i = 0; i < prev.length; i++) { + deltaWinProb += Math.abs((curr[i] ?? 0) - (prev[i] ?? 0)); + } + } + } + + const density = + deaths * 3.0 + + captures * 5.0 + + energyCollected * 0.5 + + spawns * 1.0 + + deltaWinProb * 10.0; + + return { density, deaths, captures, energyCollected, spawns, deltaWinProb }; +} + +/** + * Pre-compute action density for all turns in a replay. + * Returns an array indexed by turn index (0-based). + */ +export function computeAllDensities(replay: Replay): ActionDensity[] { + const turns = replay.turns; + const winProb = replay.win_prob; + const densities: ActionDensity[] = []; + + for (let i = 0; i < turns.length; i++) { + const prev = i > 0 ? turns[i - 1] : null; + densities.push(computeActionDensity(turns[i], prev, winProb, i)); + } + + return densities; +} + +// ── Speed mapping ─────────────────────────────────────────────────────────── + +/** + * Map action density to a speed multiplier per §16.10: + * 0 → 16x (nothing happening) + * 0.1–1.0 → 8x (minor activity) + * 1.0–3.0 → 4x (moderate) + * 3.0–5.0 → 2x (significant) + * 5.0+ → 1x (critical) + */ +export function densityToSpeed(density: number): SpeedMultiplier { + if (density === 0) return 16; + if (density < 1.0) return 8; + if (density < 3.0) return 4; + if (density < 5.0) return 2; + return 1; +} + +/** + * Compute a raw speed schedule (one multiplier per turn) from densities, + * scaled so the total approximate playback time matches the target duration. + */ +export function computeSpeedSchedule( + densities: ActionDensity[], + targetDurationSec: number, +): SpeedMultiplier[] { + const totalTurns = densities.length; + if (totalTurns === 0) return []; + + // First pass: raw speeds from density + const rawSpeeds = densities.map(d => densityToSpeed(d.density)); + + // Compute raw total duration: sum of (base_ms / speed) for each turn + let rawTotalMs = 0; + for (const speed of rawSpeeds) { + rawTotalMs += BASE_MS_PER_TURN / speed; + } + + const targetMs = targetDurationSec * 1000; + + // Scale factor: if raw duration is 2x target, we need 2x all speeds + const scaleFactor = rawTotalMs > 0 ? targetMs / rawTotalMs : 1; + + // Apply scale factor to speeds, clamping to valid multipliers + const schedule: SpeedMultiplier[] = rawSpeeds.map(raw => { + const scaledMs = (BASE_MS_PER_TURN / raw) * scaleFactor; + // Find closest valid multiplier + let best: SpeedMultiplier = 1; + let bestDiff = Infinity; + for (const m of SPEED_MULTIPLIERS) { + const ms = BASE_MS_PER_TURN / m; + const diff = Math.abs(ms - scaledMs); + if (diff < bestDiff) { + bestDiff = diff; + best = m; + } + } + return best; + }); + + return schedule; +} + +// ── Eased speed transition ────────────────────────────────────────────────── + +const EASE_DURATION_MS = 500; + +export interface DirectorState { + enabled: boolean; + currentMultiplier: SpeedMultiplier; + targetMultiplier: SpeedMultiplier; + easedMsPerTurn: number; + easeStartTime: number; + easeStartMs: number; + pauseReason: 'none' | 'scrubbing'; +} + +export function createDirectorState(): DirectorState { + return { + enabled: false, + currentMultiplier: 16, + targetMultiplier: 16, + easedMsPerTurn: multiplierToMs(16), + easeStartTime: 0, + easeStartMs: multiplierToMs(16), + pauseReason: 'none', + }; +} + +/** + * Update the eased speed for a given turn. + * Called each render frame to compute the current ms/turn. + */ +export function tickDirectorSpeed( + state: DirectorState, + schedule: SpeedMultiplier[], + turnIndex: number, + now: number, +): number { + if (!state.enabled || state.pauseReason !== 'none') { + return state.easedMsPerTurn; + } + + const target = schedule[turnIndex] ?? 16; + + if (target !== state.targetMultiplier) { + // Start a new ease transition + state.easeStartTime = now; + state.easeStartMs = state.easedMsPerTurn; + state.targetMultiplier = target; + state.currentMultiplier = target; + } + + // Compute eased value + const elapsed = now - state.easeStartTime; + const t = Math.min(1, elapsed / EASE_DURATION_MS); + // Ease-in-out cubic + const eased = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + + const targetMs = multiplierToMs(state.targetMultiplier); + state.easedMsPerTurn = state.easeStartMs + (targetMs - state.easeStartMs) * eased; + + return state.easedMsPerTurn; +} + +/** + * Format the director speed indicator string. + * e.g., "Director 8x → 2x" or "Director 4x" + */ +export function formatDirectorLabel( + current: SpeedMultiplier, + target: SpeedMultiplier, + transitioning: boolean, +): string { + if (transitioning && current !== target) { + return `Director ${current}x → ${target}x`; + } + return `Director ${current}x`; +} diff --git a/web/src/components/event-timeline.ts b/web/src/components/event-timeline.ts index 7265385..167c008 100644 --- a/web/src/components/event-timeline.ts +++ b/web/src/components/event-timeline.ts @@ -1,5 +1,6 @@ // 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; @@ -25,6 +26,7 @@ export class EventTimeline { 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; @@ -58,8 +60,16 @@ export class EventTimeline { this.updateHighlight(); } + setAnnotations(annotations: Annotation[]): void { + this.annotations = annotations; + this.render(); + } + private render(): void { - if (this.events.length === 0) { + const hasEvents = this.events.length > 0; + const hasAnnotations = this.annotations.length > 0; + + if (!hasEvents && !hasAnnotations) { this.container.innerHTML = '
No events
'; return; } @@ -77,17 +87,33 @@ export class EventTimeline { `; }).join(''); + // Annotation badges on timeline + const annotationIcons: Record = { + insight: '\u{1F4A1}', + mistake: '⚠️', + idea: '\u{1F9EA}', + highlight: '⭐', + }; + const annotationColors: Record = { + insight: '#3b82f6', + mistake: '#ef4444', + idea: '#22c55e', + highlight: '#fbbf24', + }; + const annotationMarkers = hasAnnotations ? this.buildAnnotationMarkers(annotationIcons, annotationColors) : ''; + this.container.innerHTML = `
${eventMarkers} + ${annotationMarkers}
0 / ${this.totalTurns}
`; - // Wire click handlers + // 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); @@ -114,6 +140,33 @@ export class EventTimeline { this.updateHighlight(); } + private buildAnnotationMarkers( + icons: Record, + colors: Record, + ): string { + // Group annotations by turn + const grouped = new Map(); + 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 ? `${anns.length}` : ''; + return `
+ ${icon}${count} +
`; + }).join(''); + } + private updateHighlight(): void { const progress = this.container.querySelector('#timeline-progress') as HTMLElement; const currentLabel = this.container.querySelector('#timeline-current') as HTMLElement; @@ -127,14 +180,10 @@ export class EventTimeline { currentLabel.textContent = String(this.currentTurn); } - // Highlight events at current turn - this.container.querySelectorAll('.timeline-event').forEach(el => { + // 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); - if (turn === this.currentTurn) { - el.classList.add('active'); - } else { - el.classList.remove('active'); - } + el.classList.toggle('active', turn === this.currentTurn); }); } } @@ -202,4 +251,16 @@ export const EVENT_TIMELINE_STYLES = ` 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); + } `; diff --git a/web/src/pages/feedback.ts b/web/src/pages/feedback.ts index 8dfce0c..b9ee901 100644 --- a/web/src/pages/feedback.ts +++ b/web/src/pages/feedback.ts @@ -1,28 +1,20 @@ // Community replay feedback: users annotate replay turns with tags. // Annotations feed the evolution pipeline by surfacing interesting moments. -// Types consolidated to match plan §8.3 replay_feedback schema. +// Types consolidated with annotation.ts to use shared Annotation schema from plan §8.3. -import { fetchMatchIndex, API_BASE, type MatchSummary } from '../api-types'; +import { fetchMatchIndex, type MatchSummary } from '../api-types'; import { ReplayViewer } from '../replay-viewer'; import type { Replay } from '../types'; +import { + type Annotation, + type FeedbackType, + FEEDBACK_TYPES, + loadLocalAnnotations, + submitAnnotation, +} from '../components/annotation'; -// ─── Types ──────────────────────────────────────────────────────────────────── -// Aligned with plan §8.3: insight, mistake, idea, highlight - -export const ANNOTATION_TAGS = [ - { id: 'insight', label: 'Tactical Insight', color: '#3b82f6', desc: 'A clever or instructive strategy in action' }, - { id: 'mistake', label: 'Mistake Spotted', color: '#ef4444', desc: 'Behaviour that looks unintended or suboptimal' }, - { id: 'idea', label: 'Strategy Idea', color: '#22c55e', desc: 'A novel approach worth propagating in the evolution pipeline' }, - { id: 'highlight', label: 'Highlight', color: '#fbbf24', desc: 'An impressive or noteworthy moment' }, -]; - -export interface ReplayAnnotation { - match_id: string; - turn: number; - tag: string; - comment: string; - submitted_at: string; -} +// Re-export for any consumers +export { FEEDBACK_TYPES as ANNOTATION_TAGS }; // ─── Page render ───────────────────────────────────────────────────────────── @@ -35,8 +27,8 @@ export function renderFeedbackPage(_params: Record): void { } function buildHTML(): string { - const tagButtons = ANNOTATION_TAGS.map(t => - ``, + const tagButtons = FEEDBACK_TYPES.map(t => + ``, ).join(''); return ` @@ -145,8 +137,8 @@ function buildHTML(): string { function initFeedback(): void { let replay: Replay | null = null; let viewer: ReplayViewer | null = null; - let selectedTag: string | null = null; - const localAnnotations: ReplayAnnotation[] = []; + let selectedTag: FeedbackType | null = null; + const localAnnotations: Annotation[] = []; const loadStatus = document.getElementById('fb-load-status')!; const formPanel = document.getElementById('annotation-form-panel')!; @@ -299,7 +291,7 @@ function initFeedback(): void { btn.addEventListener('click', () => { document.querySelectorAll('.tag-btn').forEach(b => b.classList.remove('selected')); btn.classList.add('selected'); - selectedTag = (btn as HTMLElement).dataset.tag!; + selectedTag = (btn as HTMLElement).dataset.tag! as FeedbackType; updateSubmitButton(); }); }); @@ -318,12 +310,15 @@ function initFeedback(): void { submitBtn.addEventListener('click', async () => { if (!replay || !selectedTag) return; - const annotation: ReplayAnnotation = { + const annotation: Annotation = { + id: `ann_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`, match_id: replay.match_id, turn: Number(turnSlider.value), - tag: selectedTag, - comment: commentTa.value.trim(), - submitted_at: new Date().toISOString(), + type: selectedTag, + body: commentTa.value.trim(), + author: 'Anonymous', + upvotes: 0, + created_at: new Date().toISOString(), }; submitBtn.disabled = true; @@ -331,24 +326,12 @@ function initFeedback(): void { submitStatus.className = 'fb-status'; try { - // POST to API; gracefully handle 404/offline (store locally) - const resp = await fetch(`${API_BASE}/feedback`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(annotation), - }).catch(() => null); + await submitAnnotation(annotation); - if (resp && resp.ok) { - submitStatus.textContent = 'Annotation submitted! Thank you.'; - submitStatus.className = 'fb-status ok'; - } else { - // Store locally if API unavailable - submitStatus.textContent = 'Saved locally (API offline).'; - submitStatus.className = 'fb-status ok'; - } + submitStatus.textContent = 'Annotation submitted! Thank you.'; + submitStatus.className = 'fb-status ok'; localAnnotations.push(annotation); - saveLocalAnnotation(annotation); // Reset form document.querySelectorAll('.tag-btn').forEach(b => b.classList.remove('selected')); @@ -368,7 +351,7 @@ function initFeedback(): void { } }); - // Load any previously stored annotations + // Load any previously stored annotations for any match const stored = loadLocalAnnotations(); if (stored.length > 0) { localAnnotations.push(...stored); @@ -389,23 +372,23 @@ function initFeedback(): void { markersEl.innerHTML = relevant.map(a => { const pct = (a.turn / Math.max(1, total - 1)) * 100; - const tagInfo = ANNOTATION_TAGS.find(t => t.id === a.tag); + const tagInfo = FEEDBACK_TYPES.find(t => t.type === a.type); const color = tagInfo?.color ?? '#94a3b8'; return `
`; + title="Turn ${a.turn}: ${escapeHtml(tagInfo?.label ?? a.type)}${a.body ? ' — ' + escapeHtml(a.body) : ''}">`; }).join(''); } - function renderAnnotationsLog(anns: ReplayAnnotation[]): void { + function renderAnnotationsLog(anns: Annotation[]): void { const logEl = document.getElementById('annotations-log')!; const sorted = [...anns].sort((a, b) => a.turn - b.turn); logEl.innerHTML = sorted.map(a => { - const tagInfo = ANNOTATION_TAGS.find(t => t.id === a.tag); + const tagInfo = FEEDBACK_TYPES.find(t => t.type === a.type); return `
- ${escapeHtml(tagInfo?.label ?? a.tag)} + ${tagInfo?.icon ?? ''} ${escapeHtml(tagInfo?.label ?? a.type)} Turn ${a.turn} - ${a.comment ? `${escapeHtml(a.comment)}` : ''} + ${a.body ? `${escapeHtml(a.body)}` : ''} ${a.match_id.slice(0, 8)}…
`; @@ -413,25 +396,7 @@ function initFeedback(): void { } } -// ─── Local storage for offline annotations ──────────────────────────────────── - -const LS_KEY = 'acb_annotations_v2'; - -function saveLocalAnnotation(ann: ReplayAnnotation): void { - try { - const existing: ReplayAnnotation[] = JSON.parse(localStorage.getItem(LS_KEY) ?? '[]'); - existing.push(ann); - localStorage.setItem(LS_KEY, JSON.stringify(existing.slice(-200))); - } catch {} -} - -function loadLocalAnnotations(): ReplayAnnotation[] { - try { - return JSON.parse(localStorage.getItem(LS_KEY) ?? '[]'); - } catch { - return []; - } -} +// ─── Utilities ──────────────────────────────────────────────────────────────── // ─── Utilities ──────────────────────────────────────────────────────────────── diff --git a/web/src/pages/replay.ts b/web/src/pages/replay.ts index 4dac701..c0a38d4 100644 --- a/web/src/pages/replay.ts +++ b/web/src/pages/replay.ts @@ -9,6 +9,22 @@ import { ANNOTATION_OVERLAY_STYLES, type Annotation, } from '../components/annotation'; +import { + EventTimeline, + EVENT_TIMELINE_STYLES, +} from '../components/event-timeline'; +import { + computeAllDensities, + computeSpeedSchedule, + createDirectorState, + tickDirectorSpeed, + loadDirectorConfig, + saveDirectorConfig, + formatDirectorLabel, + type DirectorConfig, + type DirectorState, + type DurationPreset, +} from '../components/director'; const loadReplayViewer = () => import('../replay-viewer'); @@ -64,6 +80,9 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string): Load a replay + + +
@@ -246,6 +288,14 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string): .no-events { color: var(--text-muted); } .keyboard-shortcuts { font-size: 0.75rem; color: var(--text-muted); } .keyboard-shortcuts kbd { background-color: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px; font-family: monospace; margin-right: 4px; } + .speed-selector-group { margin-bottom: 10px; } + .speed-selector-group label { display: block; color: var(--text-muted); font-size: 0.875rem; margin-bottom: 6px; } + .speed-selector-group select { width: 100%; background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-primary); padding: 8px; border-radius: 6px; font-size: 14px; } + .director-options { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--bg-tertiary); } + .duration-presets { display: flex; gap: 6px; flex-wrap: wrap; } + .duration-presets .btn { flex: 1; min-width: 40px; text-align: center; font-size: 0.75rem; } + .duration-presets .btn.active { background-color: var(--accent); color: white; } + .director-status { text-align: center; color: var(--accent); font-size: 0.8rem; font-weight: 600; padding: 6px 0; font-family: monospace; } .win-prob-section { background-color: var(--bg-secondary); border-radius: 8px; padding: 12px; margin-top: 10px; } .win-prob-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; flex-wrap: wrap; gap: 8px; } .win-prob-title { color: var(--text-muted); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; } @@ -331,6 +381,7 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string): .annotation-canvas-hint.visible { opacity: 1; } + `; initReplayViewer(ReplayViewerClass, initialUrl); @@ -388,6 +439,18 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { let commentaryEnabled = true; let debugPanelExpanded = false; + // Director mode state + let directorState: DirectorState = createDirectorState(); + let directorConfig: DirectorConfig = loadDirectorConfig(); + let directorSchedule: ReturnType = []; + let directorAnimFrame: number | null = null; + + // Director UI elements + const speedSelect = document.getElementById('speed-select') as HTMLSelectElement; + const directorOptions = document.getElementById('director-options') as HTMLDivElement; + const directorStatus = document.getElementById('director-status') as HTMLDivElement; + const durationPresets = document.getElementById('duration-presets') as HTMLDivElement; + // Mobile speed cycling const SPEED_STEPS = [1000, 500, 200, 100, 50, 20]; let mobileSpeedIdx = 3; // default 100ms @@ -523,6 +586,7 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { updateMobileUI(); buildMobileTimeline(replay); initWinProb(replay); + initDirector(replay); loadCommentary(replay.match_id); initDebugPanel(replay); initAnnotations(replay); @@ -729,6 +793,102 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { debugPanelToggleBtn.setAttribute('aria-expanded', String(debugPanelExpanded)); } + // ── Director Mode (§16.10) ────────────────────────────────────────────────── + + function initDirector(replay: Replay): void { + const densities = computeAllDensities(replay); + directorSchedule = computeSpeedSchedule(densities, directorConfig.targetDuration); + directorState = createDirectorState(); + + // Apply saved duration preset selection + updateDurationPresetUI(directorConfig.targetDuration); + + if (speedSelect.value === 'director') { + enableDirector(); + } + } + + function enableDirector(): void { + directorState.enabled = true; + directorState.pauseReason = 'none'; + viewer.setDirectorMode(true); + directorOptions.style.display = ''; + updateDirectorSpeed(); + startDirectorTick(); + } + + function disableDirector(): void { + directorState.enabled = false; + viewer.setDirectorMode(false); + directorOptions.style.display = 'none'; + stopDirectorTick(); + } + + function startDirectorTick(): void { + stopDirectorTick(); + function tick() { + if (!directorState.enabled) return; + const now = performance.now(); + const turn = viewer.getTurn(); + const ms = tickDirectorSpeed(directorState, directorSchedule, turn, now); + viewer.setDirectorSpeed(ms); + updateDirectorStatusUI(); + directorAnimFrame = requestAnimationFrame(tick); + } + directorAnimFrame = requestAnimationFrame(tick); + } + + function stopDirectorTick(): void { + if (directorAnimFrame !== null) { + cancelAnimationFrame(directorAnimFrame); + directorAnimFrame = null; + } + } + + function updateDirectorSpeed(): void { + if (!directorState.enabled) return; + const now = performance.now(); + const turn = viewer.getTurn(); + const ms = tickDirectorSpeed(directorState, directorSchedule, turn, now); + viewer.setDirectorSpeed(ms); + updateDirectorStatusUI(); + } + + function updateDirectorStatusUI(): void { + if (!directorState.enabled) { + directorStatus.textContent = ''; + return; + } + const transitioning = directorState.easeStartTime > 0 && + (performance.now() - directorState.easeStartTime) < 500; + directorStatus.textContent = formatDirectorLabel( + directorState.currentMultiplier, + directorState.targetMultiplier, + transitioning, + ); + } + + function updateDurationPresetUI(target: DurationPreset): void { + durationPresets.querySelectorAll('.duration-btn').forEach(btn => { + const val = parseInt(btn.dataset.duration!, 10) as DurationPreset; + btn.classList.toggle('active', val === target); + }); + } + + function setDurationPreset(target: DurationPreset): void { + directorConfig.targetDuration = target; + saveDirectorConfig(directorConfig); + updateDurationPresetUI(target); + // Recompute schedule with new target + const replay = viewer.getReplay(); + if (replay) { + const densities = computeAllDensities(replay); + directorSchedule = computeSpeedSchedule(densities, directorConfig.targetDuration); + directorState = createDirectorState(); + directorState.enabled = true; + } + } + function initWinProb(replay: Replay): void { if (!replay.win_prob || replay.win_prob.length === 0) { winProbSection.style.display = 'none'; @@ -850,15 +1010,47 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { resetBtn.addEventListener('click', () => { viewer.pause(); viewer.setTurn(0); updateUI(); updateEventLog(); }); turnSlider.addEventListener('input', () => { + if (directorState.enabled) directorState.pauseReason = 'scrubbing'; viewer.setTurn(parseInt(turnSlider.value, 10)); updateUI(); updateEventLog(); }); + turnSlider.addEventListener('change', () => { + if (directorState.enabled) directorState.pauseReason = 'none'; + }); speedSlider.addEventListener('input', () => { const speed = parseInt(speedSlider.value, 10); viewer.setSpeed(speed); speedDisplay.textContent = String(speed); + // If user manually drags speed slider, switch off Director mode + if (directorState.enabled) { + speedSelect.value = String(speed); + disableDirector(); + } + }); + + speedSelect.addEventListener('change', () => { + const val = speedSelect.value; + if (val === 'director') { + enableDirector(); + // Update the slider to reflect current director speed + speedSlider.value = String(Math.round(directorState.easedMsPerTurn)); + speedDisplay.textContent = 'Director'; + } else { + disableDirector(); + const speed = parseInt(val, 10); + viewer.setSpeed(speed); + speedSlider.value = String(speed); + speedDisplay.textContent = String(speed); + } + }); + + durationPresets.addEventListener('click', (e) => { + const btn = (e.target as HTMLElement).closest('.duration-btn') as HTMLElement | null; + if (!btn) return; + const duration = parseInt(btn.dataset.duration!, 10) as DurationPreset; + setDurationPreset(duration); }); fogSelect.addEventListener('change', () => { @@ -950,9 +1142,13 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { updateUI(); updateEventLog(); updateMobileUI(); updateMobileTimeline(); }); mobileTurnSlider.addEventListener('input', () => { + if (directorState.enabled) directorState.pauseReason = 'scrubbing'; viewer.setTurn(parseInt(mobileTurnSlider.value, 10)); updateUI(); updateEventLog(); updateMobileUI(); updateMobileTimeline(); }); + mobileTurnSlider.addEventListener('change', () => { + if (directorState.enabled) directorState.pauseReason = 'none'; + }); mobileSpeedBtn.addEventListener('click', () => { mobileSpeedIdx = (mobileSpeedIdx + 1) % SPEED_STEPS.length; const speed = SPEED_STEPS[mobileSpeedIdx]; diff --git a/web/src/replay-viewer.ts b/web/src/replay-viewer.ts index b839f09..1f3c3b4 100644 --- a/web/src/replay-viewer.ts +++ b/web/src/replay-viewer.ts @@ -429,10 +429,17 @@ export class ReplayViewer { public onCommentaryChange?: (entry: { turn: number; text: string; type: string } | null) => void; public onDebugChange?: (debug: Record | null) => void; + // Director mode: external speed override from director controller + private directorEnabled: boolean = false; + private directorMsPerTurn: number = 500; + // Enriched commentary state (§13.3) private commentary: EnrichedCommentary | null = null; private commentaryEnabled: boolean = true; + // Annotation overlay state (§16.8) + private annotations: Array<{ turn: number; type: string; position?: Position }> = []; + constructor(canvas: HTMLCanvasElement, options: ViewerOptions = {}) { this.canvas = canvas; const ctx = canvas.getContext('2d'); @@ -564,6 +571,20 @@ export class ReplayViewer { return this.animationSpeed; } + // Director mode: when enabled, tickDirectorSpeed overrides animationSpeed + setDirectorMode(enabled: boolean): void { + this.directorEnabled = enabled; + } + + isDirectorMode(): boolean { + return this.directorEnabled; + } + + // Called externally by the director controller each tick to set eased speed + setDirectorSpeed(msPerTurn: number): void { + this.directorMsPerTurn = Math.max(10, Math.min(2000, msPerTurn)); + } + getIsPlaying(): boolean { return this.isPlaying; } @@ -633,6 +654,13 @@ export class ReplayViewer { return this.replay?.turns[this.currentTurn]?.debug ?? null; } + // ── Annotation Overlay (§16.8) ───────────────────────────────────────────────── + + setAnnotations(anns: Array<{ turn: number; type: string; position?: Position }>): void { + this.annotations = anns; + this.render(); + } + // ── Enriched Commentary Controls (§13.3) ────────────────────────────────────── setCommentary(commentary: EnrichedCommentary | null): void { @@ -866,8 +894,9 @@ export class ReplayViewer { // If playing, check if we should advance to next turn if (this.isPlaying && this.replay) { + const effectiveSpeed = this.directorEnabled ? this.directorMsPerTurn : this.animationSpeed; const turnElapsed = timestamp - this.turnStartTime; - if (turnElapsed >= this.animationSpeed) { + if (turnElapsed >= effectiveSpeed) { if (this.currentTurn < this.replay.turns.length - 1) { this.advanceTurn(this.currentTurn + 1); } else { @@ -1060,6 +1089,9 @@ export class ReplayViewer { this.renderDebugOverlay(turnData.debug, colors); } + // Draw annotation markers on canvas (§16.8) + this.renderAnnotationMarkers(colors); + // Draw score overlay this.drawScoreOverlay(turnData, colors); @@ -1519,6 +1551,44 @@ export class ReplayViewer { ctx.textBaseline = 'alphabetic'; } + private renderAnnotationMarkers(_colors: string[]): void { + const currentAnns = this.annotations.filter(a => a.turn === this.currentTurn); + if (currentAnns.length === 0) return; + + const { ctx, cellSize } = this; + const TYPE_COLORS: Record = { + insight: '#3b82f6', + mistake: '#ef4444', + idea: '#22c55e', + highlight: '#fbbf24', + }; + + ctx.save(); + for (const ann of currentAnns) { + const color = TYPE_COLORS[ann.type] ?? '#94a3b8'; + + if (ann.position) { + const x = ann.position.col * cellSize + cellSize / 2; + const y = ann.position.row * cellSize + cellSize / 2; + const r = cellSize / 2 + 2; + + ctx.globalAlpha = 0.6; + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.stroke(); + + ctx.globalAlpha = 0.15; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fill(); + } + } + ctx.restore(); + } + // Wrap text to fit within max width private wrapText(text: string, maxWidth: number): string[] { const words = text.split(' ');