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:
parent
eaa8193cac
commit
d3f2068f8b
5 changed files with 656 additions and 79 deletions
285
web/src/components/director.ts
Normal file
285
web/src/components/director.ts
Normal 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.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`;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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(' ');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue