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>
This commit is contained in:
jedarden 2026-04-21 17:57:10 -04:00
parent eaa8193cac
commit d3f2068f8b
5 changed files with 656 additions and 79 deletions

View file

@ -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<DurationPreset, string> = {
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.11.0 8x (minor activity)
* 1.03.0 4x (moderate)
* 3.05.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`;
}

View file

@ -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 = '<div class="timeline-empty">No events</div>';
return;
}
@ -77,17 +87,33 @@ export class EventTimeline {
`;
}).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
// 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<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;
@ -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);
}
`;

View file

@ -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<string, string>): void {
}
function buildHTML(): string {
const tagButtons = ANNOTATION_TAGS.map(t =>
`<button class="tag-btn" data-tag="${t.id}" style="--tag-color:${t.color}" title="${escapeHtml(t.desc)}">${escapeHtml(t.label)}</button>`,
const tagButtons = FEEDBACK_TYPES.map(t =>
`<button class="tag-btn" data-tag="${t.type}" style="--tag-color:${t.color}" title="${escapeHtml(t.label)}">${t.icon} ${escapeHtml(t.label)}</button>`,
).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 `<div class="ann-marker" style="left:${pct.toFixed(1)}%;background:${color}"
title="Turn ${a.turn}: ${escapeHtml(tagInfo?.label ?? a.tag)}${a.comment ? ' — ' + escapeHtml(a.comment) : ''}"></div>`;
title="Turn ${a.turn}: ${escapeHtml(tagInfo?.label ?? a.type)}${a.body ? ' — ' + escapeHtml(a.body) : ''}"></div>`;
}).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 `
<div class="ann-log-row">
<span class="ann-tag-pill" style="background:${tagInfo?.color ?? '#94a3b8'}22;color:${tagInfo?.color ?? '#94a3b8'}">${escapeHtml(tagInfo?.label ?? a.tag)}</span>
<span class="ann-tag-pill" style="background:${tagInfo?.color ?? '#94a3b8'}22;color:${tagInfo?.color ?? '#94a3b8'}">${tagInfo?.icon ?? ''} ${escapeHtml(tagInfo?.label ?? a.type)}</span>
<span class="ann-turn">Turn ${a.turn}</span>
${a.comment ? `<span class="ann-comment-text">${escapeHtml(a.comment)}</span>` : ''}
${a.body ? `<span class="ann-comment-text">${escapeHtml(a.body)}</span>` : ''}
<span class="ann-match-id">${a.match_id.slice(0, 8)}</span>
</div>
`;
@ -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 ────────────────────────────────────────────────────────────────

View file

@ -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):
<span style="color:var(--text-muted);font-size:0.75rem;padding:4px 8px">Load a replay</span>
</div>
<!-- Desktop event timeline with annotation badges (hidden on mobile) -->
<div class="event-timeline-container" id="event-timeline-container" style="display:none"></div>
<div id="win-prob-section" class="win-prob-section" style="display:none">
<div class="win-prob-header">
<span class="win-prob-title">Win Probability</span>
@ -116,6 +135,29 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string):
<label>Speed: <span id="speed-display">100</span>ms/turn</label>
<input type="range" id="speed-slider" min="20" max="1000" value="100">
</div>
<div class="speed-selector-group">
<label for="speed-select">Speed Preset:</label>
<select id="speed-select">
<option value="500">1x</option>
<option value="250">2x</option>
<option value="125">4x</option>
<option value="62">8x</option>
<option value="31">16x</option>
<option value="director">Director</option>
</select>
</div>
<div id="director-options" class="director-options" style="display:none">
<div class="slider-group">
<label>Target Duration:</label>
<div class="duration-presets" id="duration-presets">
<button class="btn small secondary duration-btn" data-duration="30">30s</button>
<button class="btn small secondary duration-btn active" data-duration="60">1min</button>
<button class="btn small secondary duration-btn" data-duration="120">2min</button>
<button class="btn small secondary duration-btn" data-duration="300">5min</button>
</div>
</div>
<div class="director-status" id="director-status">Director 16x</div>
</div>
</div>
<div class="panel">
@ -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; }
</style>
<style>${ANNOTATION_OVERLAY_STYLES}</style>
<style>${EVENT_TIMELINE_STYLES}</style>
`;
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<typeof computeSpeedSchedule> = [];
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<HTMLElement>('.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];

View file

@ -429,10 +429,17 @@ export class ReplayViewer {
public onCommentaryChange?: (entry: { turn: number; text: string; type: string } | null) => void;
public onDebugChange?: (debug: Record<number, DebugInfo> | 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<string, string> = {
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(' ');