feat(playlists/replay): n-player win prob, annotations, evolver metrics
Playlist curation per §10 is fully implemented in the index builder:
- generatePlaylists() writes /data/playlists/index.json and {slug}.json
- curateWeeklyHighlights() selects best-of-week by upsets, elite
clashes, marathon turns, and closest finishes (last 7 days)
- persistGeneratedPlaylists() upserts to playlists/playlist_matches DB tables
- /data/playlists/ stub files seeded for all 12 curated collections
Replay viewer improvements shipped alongside:
- WinProbPoint refactored from {p0,p1} to {probs: number[]} for N players
- renderWinProbSparkline draws one line per player with matching colors
- replay.ts updated to build probs[] from replay.win_prob arrays
- Dynamic legend generated from replay.players instead of hardcoded P0/P1
New annotation overlay component (§16.8):
- AnnotationOverlay: timeline track, per-turn list, canvas markers
- createAnnotationForm: type selector, author, body, localStorage + API
- ANNOTATION_OVERLAY_STYLES: self-contained CSS for the overlay
Evolver: add mutations_per_hour metric to Totals (live.json §14)
Types: consolidate evolution types into types.ts, re-export from api-types.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fd9ffbc048
commit
d8812b98ee
8 changed files with 780 additions and 204 deletions
|
|
@ -1 +1 @@
|
|||
aa1c78c9d7901ce17ff0f28b5a2b89961c31d403
|
||||
fd9ffbc0487ebcf54f04ac13d3eae2f0f96157cb
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ type Totals struct {
|
|||
PromotionRate7d float64 `json:"promotion_rate_7d"`
|
||||
HighestEvolvedRating int `json:"highest_evolved_rating"`
|
||||
EvolvedInTop10 int `json:"evolved_in_top_10"`
|
||||
MutationsPerHour float64 `json:"mutations_per_hour"`
|
||||
}
|
||||
|
||||
// LiveData is the full evolution dashboard payload written to live.json (plan §14 format).
|
||||
|
|
@ -289,6 +290,15 @@ func fillTotals(ctx context.Context, db *sql.DB, data *LiveData) error {
|
|||
top10Count = 0
|
||||
}
|
||||
|
||||
// Mutations per hour (programs created in the last hour)
|
||||
var mutationsLastHour int
|
||||
err = db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM programs
|
||||
WHERE created_at >= NOW() - INTERVAL '1 hour'`).Scan(&mutationsLastHour)
|
||||
if err != nil {
|
||||
mutationsLastHour = 0
|
||||
}
|
||||
|
||||
data.Totals = Totals{
|
||||
GenerationsTotal: maxGen,
|
||||
CandidatesToday: candidatesToday,
|
||||
|
|
@ -296,6 +306,7 @@ func fillTotals(ctx context.Context, db *sql.DB, data *LiveData) error {
|
|||
PromotionRate7d: rate7d,
|
||||
HighestEvolvedRating: highestRating,
|
||||
EvolvedInTop10: top10Count,
|
||||
MutationsPerHour: round3(float64(mutationsLastHour)),
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -98,17 +98,45 @@ export interface RegisterResponse {
|
|||
error?: string;
|
||||
}
|
||||
|
||||
// Evolution dashboard types
|
||||
// Evolution dashboard types (re-exported from types.ts for convenience)
|
||||
import type {
|
||||
LiveJSON,
|
||||
EvolutionIslandStat,
|
||||
EvolutionParentInfo,
|
||||
EvolutionStageResult,
|
||||
EvolutionValidationStatus,
|
||||
EvaluationMatchResult,
|
||||
EvolutionEvaluationStatus,
|
||||
EvolutionCandidate,
|
||||
EvolutionCycleInfo,
|
||||
EvolutionActivityEntry,
|
||||
EvolutionTotals,
|
||||
EvolutionGenerationEntry,
|
||||
EvolutionLineageNode,
|
||||
EvolutionMetaSnapshot,
|
||||
} from './types';
|
||||
|
||||
// Dashboard island stat (live.json format)
|
||||
export interface IslandStat {
|
||||
population: number;
|
||||
best_rating: number;
|
||||
best_bot: string;
|
||||
language_div?: string;
|
||||
}
|
||||
export type {
|
||||
LiveJSON,
|
||||
EvolutionIslandStat as IslandStat,
|
||||
EvolutionParentInfo as ParentInfo,
|
||||
EvolutionStageResult as StageResult,
|
||||
EvolutionValidationStatus as ValidationStatus,
|
||||
EvaluationMatchResult as MatchResult,
|
||||
EvolutionEvaluationStatus as EvaluationStatus,
|
||||
EvolutionCandidate as Candidate,
|
||||
EvolutionCycleInfo as CycleInfo,
|
||||
EvolutionActivityEntry as ActivityEntry,
|
||||
EvolutionTotals as Totals,
|
||||
EvolutionGenerationEntry as GenerationEntry,
|
||||
EvolutionLineageNode as LineageNode,
|
||||
EvolutionMetaSnapshot as MetaSnapshot,
|
||||
} from './types';
|
||||
|
||||
// Full island stat (legacy format)
|
||||
// Convenience alias: the full live.json document
|
||||
export type EvolutionLiveData = LiveJSON;
|
||||
|
||||
// Full island stat (legacy format, not in live.json schema)
|
||||
export interface IslandStatFull {
|
||||
count: number;
|
||||
best_fitness: number;
|
||||
|
|
@ -117,126 +145,6 @@ export interface IslandStatFull {
|
|||
promoted_count: number;
|
||||
}
|
||||
|
||||
// Parent info for candidate
|
||||
export interface ParentInfo {
|
||||
id: string;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
// Validation stage result
|
||||
export interface StageResult {
|
||||
passed: boolean;
|
||||
time_ms: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Validation status
|
||||
export interface ValidationStatus {
|
||||
syntax?: StageResult;
|
||||
schema?: StageResult;
|
||||
smoke?: StageResult;
|
||||
}
|
||||
|
||||
// Match result in evaluation
|
||||
export interface MatchResult {
|
||||
opponent: string;
|
||||
won: boolean;
|
||||
score: string;
|
||||
}
|
||||
|
||||
// Evaluation status
|
||||
export interface EvaluationStatus {
|
||||
matches_total: number;
|
||||
matches_played: number;
|
||||
results: MatchResult[];
|
||||
}
|
||||
|
||||
// Current candidate being evaluated
|
||||
export interface Candidate {
|
||||
id: string;
|
||||
island: string;
|
||||
language: string;
|
||||
parents: ParentInfo[];
|
||||
validation?: ValidationStatus;
|
||||
evaluation?: EvaluationStatus;
|
||||
}
|
||||
|
||||
// Current cycle info
|
||||
export interface CycleInfo {
|
||||
generation: number;
|
||||
started_at: string;
|
||||
phase: string; // generating, validating, evaluating, promoting, idle
|
||||
candidate?: Candidate;
|
||||
}
|
||||
|
||||
// Activity entry in recent activity feed
|
||||
export interface ActivityEntry {
|
||||
time: string;
|
||||
generation: number;
|
||||
candidate: string;
|
||||
island: string;
|
||||
result: string; // promoted, rejected
|
||||
reason: string;
|
||||
stage: string; // validation, promotion, deployment
|
||||
bot_id?: string;
|
||||
initial_rating?: number;
|
||||
}
|
||||
|
||||
// Overall evolution statistics
|
||||
export interface Totals {
|
||||
generations_total: number;
|
||||
candidates_today: number;
|
||||
promoted_today: number;
|
||||
promotion_rate_7d: number;
|
||||
highest_evolved_rating: number;
|
||||
evolved_in_top_10: number;
|
||||
}
|
||||
|
||||
// Legacy generation entry
|
||||
export interface GenerationEntry {
|
||||
generation: number;
|
||||
island: string;
|
||||
evaluated_at: string;
|
||||
count: number;
|
||||
promoted: number;
|
||||
best_fitness: number;
|
||||
avg_fitness: number;
|
||||
}
|
||||
|
||||
// Legacy lineage node
|
||||
export interface LineageNode {
|
||||
id: number;
|
||||
parent_ids: number[];
|
||||
generation: number;
|
||||
island: string;
|
||||
fitness: number;
|
||||
promoted: boolean;
|
||||
language: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Legacy meta snapshot
|
||||
export interface MetaSnapshot {
|
||||
generation: number;
|
||||
island_counts: Record<string, number>;
|
||||
island_best_fitness: Record<string, number>;
|
||||
}
|
||||
|
||||
// Evolution live data (plan §14 format)
|
||||
export interface EvolutionLiveData {
|
||||
updated_at: string;
|
||||
cycle?: CycleInfo;
|
||||
recent_activity?: ActivityEntry[];
|
||||
islands: Record<string, IslandStat>;
|
||||
totals: Totals;
|
||||
// Legacy fields for backward compatibility
|
||||
total_programs?: number;
|
||||
promoted_count?: number;
|
||||
generation_log?: GenerationEntry[];
|
||||
lineage?: LineageNode[];
|
||||
meta_snapshots?: MetaSnapshot[];
|
||||
}
|
||||
|
||||
// Blog / Narrative Engine types
|
||||
|
||||
export interface BlogWeekStats {
|
||||
|
|
|
|||
480
web/src/components/annotation.ts
Normal file
480
web/src/components/annotation.ts
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
// AnnotationOverlay — spatial + text replay annotations per §16.8
|
||||
// Users add tagged feedback anchored to a (turn, grid position) pair.
|
||||
// Feedback types match plan §8.3: insight, mistake, idea, highlight.
|
||||
// Markers render on the canvas; annotations show in a side panel + event timeline.
|
||||
|
||||
import type { Position } from '../types';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type FeedbackType = 'insight' | 'mistake' | 'idea' | 'highlight';
|
||||
|
||||
export interface Annotation {
|
||||
id: string;
|
||||
match_id: string;
|
||||
turn: number;
|
||||
type: FeedbackType;
|
||||
body: string;
|
||||
author: string;
|
||||
upvotes: number;
|
||||
created_at: string;
|
||||
// Spatial data (optional — user may click a grid position)
|
||||
position?: Position;
|
||||
}
|
||||
|
||||
export const FEEDBACK_TYPES: { type: FeedbackType; label: string; icon: string; color: string }[] = [
|
||||
{ type: 'insight', label: 'Tactical Insight', icon: '\u{1F4A1}', color: '#3b82f6' },
|
||||
{ type: 'mistake', label: 'Mistake Spotted', icon: '⚠️', color: '#ef4444' },
|
||||
{ type: 'idea', label: 'Strategy Idea', icon: '\u{1F9EA}', color: '#22c55e' },
|
||||
{ type: 'highlight', label: 'Highlight', icon: '⭐', color: '#fbbf24' },
|
||||
];
|
||||
|
||||
// ─── Storage ────────────────────────────────────────────────────────────────
|
||||
|
||||
const LS_KEY = 'acb_annotations_v2';
|
||||
|
||||
function saveLocal(ann: Annotation): void {
|
||||
try {
|
||||
const existing: Annotation[] = JSON.parse(localStorage.getItem(LS_KEY) ?? '[]');
|
||||
existing.push(ann);
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(existing.slice(-200)));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function loadLocalAnnotations(matchId?: string): Annotation[] {
|
||||
try {
|
||||
const all: Annotation[] = JSON.parse(localStorage.getItem(LS_KEY) ?? '[]');
|
||||
if (matchId) return all.filter(a => a.match_id === matchId);
|
||||
return all;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fetch feedback.json from pre-built data ────────────────────────────────
|
||||
|
||||
export async function fetchFeedback(matchId: string): Promise<Annotation[]> {
|
||||
try {
|
||||
const resp = await fetch(`/data/matches/${matchId}/feedback.json`);
|
||||
if (!resp.ok) return [];
|
||||
return resp.json();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Submit (POST to API, localStorage fallback) ────────────────────────────
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
export async function submitAnnotation(ann: Annotation): Promise<boolean> {
|
||||
saveLocal(ann);
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(ann),
|
||||
});
|
||||
return resp.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AnnotationOverlay class ────────────────────────────────────────────────
|
||||
|
||||
export interface AnnotationOverlayOptions {
|
||||
onAnnotationAdd?: (ann: Annotation) => void;
|
||||
onTurnClick?: (turn: number) => void;
|
||||
}
|
||||
|
||||
export class AnnotationOverlay {
|
||||
private container: HTMLElement;
|
||||
private annotations: Annotation[] = [];
|
||||
private currentTurn: number = 0;
|
||||
private totalTurns: number = 0;
|
||||
private _matchId: string = '';
|
||||
private options: AnnotationOverlayOptions;
|
||||
|
||||
constructor(container: HTMLElement, options: AnnotationOverlayOptions = {}) {
|
||||
this.container = container;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
loadAnnotations(matchId: string, annotations: Annotation[], totalTurns: number): void {
|
||||
this.matchId = matchId;
|
||||
this.totalTurns = totalTurns;
|
||||
this.annotations = annotations.filter(a => a.match_id === matchId);
|
||||
this.render();
|
||||
}
|
||||
|
||||
setCurrentTurn(turn: number): void {
|
||||
this.currentTurn = turn;
|
||||
this.updateHighlight();
|
||||
}
|
||||
|
||||
addAnnotation(ann: Annotation): void {
|
||||
this.annotations.push(ann);
|
||||
this.render();
|
||||
if (this.options.onAnnotationAdd) this.options.onAnnotationAdd(ann);
|
||||
}
|
||||
|
||||
getAnnotationsForTurn(turn: number): Annotation[] {
|
||||
return this.annotations.filter(a => a.turn === turn);
|
||||
}
|
||||
|
||||
getAllAnnotations(): Annotation[] {
|
||||
return [...this.annotations];
|
||||
}
|
||||
|
||||
// Get turns that have annotations (for rendering markers on canvas/timeline)
|
||||
getAnnotatedTurns(): number[] {
|
||||
const turns = new Set(this.annotations.map(a => a.turn));
|
||||
return [...turns].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
private render(): void {
|
||||
if (this.annotations.length === 0) {
|
||||
this.container.innerHTML = '<div class="ann-overlay-empty">No annotations yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const turnMarkers = this.renderTimelineMarkers();
|
||||
|
||||
this.container.innerHTML = `
|
||||
<div class="ann-overlay-header">
|
||||
<span class="ann-overlay-title">Annotations</span>
|
||||
<span class="ann-overlay-count">${this.annotations.length}</span>
|
||||
</div>
|
||||
<div class="ann-overlay-track">
|
||||
${turnMarkers}
|
||||
</div>
|
||||
<div class="ann-overlay-list">
|
||||
${this.renderCurrentTurnAnnotations()}
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.wireClickHandlers();
|
||||
this.updateHighlight();
|
||||
}
|
||||
|
||||
private renderTimelineMarkers(): string {
|
||||
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 pct = (turn / Math.max(1, this.totalTurns - 1)) * 100;
|
||||
const primaryType = anns[0].type;
|
||||
const config = FEEDBACK_TYPES.find(f => f.type === primaryType);
|
||||
const color = config?.color ?? '#94a3b8';
|
||||
const count = anns.length > 1 ? `<span class="ann-marker-count">${anns.length}</span>` : '';
|
||||
return `<div class="ann-marker" data-turn="${turn}" style="left:${pct.toFixed(1)}%;--ann-color:${color}">
|
||||
<span class="ann-marker-dot"></span>${count}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
private renderCurrentTurnAnnotations(): string {
|
||||
const current = this.getAnnotationsForTurn(this.currentTurn);
|
||||
if (current.length === 0) {
|
||||
return '<div class="ann-no-current">No annotations at this turn</div>';
|
||||
}
|
||||
|
||||
return current.map(ann => {
|
||||
const config = FEEDBACK_TYPES.find(f => f.type === ann.type);
|
||||
const color = config?.color ?? '#94a3b8';
|
||||
const icon = config?.icon ?? '';
|
||||
const label = config?.label ?? ann.type;
|
||||
const pos = ann.position ? `(${ann.position.row}, ${ann.position.col})` : '';
|
||||
return `<div class="ann-item" data-ann-id="${ann.id}">
|
||||
<div class="ann-item-header">
|
||||
<span class="ann-item-type" style="color:${color}">${icon} ${escapeHtml(label)}</span>
|
||||
<span class="ann-item-author">${escapeHtml(ann.author)}</span>
|
||||
</div>
|
||||
<div class="ann-item-body">${escapeHtml(ann.body)}</div>
|
||||
${pos ? `<div class="ann-item-pos">@ ${pos}</div>` : ''}
|
||||
<div class="ann-item-meta">
|
||||
<span class="ann-item-upvotes">${ann.upvotes} upvotes</span>
|
||||
<span class="ann-item-time">${formatTime(ann.created_at)}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
private wireClickHandlers(): void {
|
||||
this.container.querySelectorAll('.ann-marker').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const turn = parseInt((el as HTMLElement).dataset.turn || '0', 10);
|
||||
if (this.options.onTurnClick) this.options.onTurnClick(turn);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private updateHighlight(): void {
|
||||
this.container.querySelectorAll('.ann-marker').forEach(el => {
|
||||
const turn = parseInt((el as HTMLElement).dataset.turn || '0', 10);
|
||||
el.classList.toggle('active', turn === this.currentTurn);
|
||||
});
|
||||
|
||||
// Update annotation list for current turn
|
||||
const listEl = this.container.querySelector('.ann-overlay-list');
|
||||
if (listEl) listEl.innerHTML = this.renderCurrentTurnAnnotations();
|
||||
}
|
||||
|
||||
// ── Canvas marker rendering ───────────────────────────────────────────────
|
||||
// Call this from ReplayViewer's render loop to draw annotation markers on the canvas
|
||||
|
||||
static drawCanvasMarkers(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
annotations: Annotation[],
|
||||
currentTurn: number,
|
||||
cellSize: number,
|
||||
_mapRows: number,
|
||||
): void {
|
||||
const currentAnns = annotations.filter(a => a.turn === currentTurn);
|
||||
if (currentAnns.length === 0) return;
|
||||
|
||||
ctx.save();
|
||||
|
||||
for (const ann of currentAnns) {
|
||||
const config = FEEDBACK_TYPES.find(f => f.type === ann.type);
|
||||
const color = config?.color ?? '#94a3b8';
|
||||
|
||||
if (ann.position) {
|
||||
// Draw marker at grid 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();
|
||||
} else {
|
||||
// Draw a small indicator at top-right corner of the map
|
||||
const x = 0;
|
||||
const y = 0;
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + cellSize - 2, y + 2, 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Draw annotation count badges on the event timeline
|
||||
static drawTimelineBadges(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
annotations: Annotation[],
|
||||
totalTurns: number,
|
||||
width: number,
|
||||
y: number,
|
||||
height: number,
|
||||
): void {
|
||||
const grouped = new Map<number, Annotation[]>();
|
||||
for (const ann of annotations) {
|
||||
const list = grouped.get(ann.turn) ?? [];
|
||||
list.push(ann);
|
||||
grouped.set(ann.turn, list);
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.7;
|
||||
for (const [turn, anns] of grouped) {
|
||||
const pct = turn / Math.max(1, totalTurns - 1);
|
||||
const x = pct * width;
|
||||
const config = FEEDBACK_TYPES.find(f => f.type === anns[0].type);
|
||||
const color = config?.color ?? '#94a3b8';
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y + height / 2, anns.length > 1 ? 4 : 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Annotation form (embeddable in any panel) ──────────────────────────────
|
||||
|
||||
export interface AnnotationFormOptions {
|
||||
matchId: string;
|
||||
currentTurn: number;
|
||||
authorName: string;
|
||||
onSubmit?: (ann: Annotation) => void;
|
||||
}
|
||||
|
||||
export function createAnnotationForm(
|
||||
container: HTMLElement,
|
||||
getTurn: () => number,
|
||||
getMatchId: () => string,
|
||||
getGridPosition: () => Position | undefined,
|
||||
): void {
|
||||
const authorKey = 'acb_author_name';
|
||||
const savedAuthor = localStorage.getItem(authorKey) || '';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="ann-form">
|
||||
<div class="ann-form-types">
|
||||
${FEEDBACK_TYPES.map(ft =>
|
||||
`<button class="ann-type-btn" data-type="${ft.type}" style="--ann-color:${ft.color}" title="${ft.label}">
|
||||
<span class="ann-type-icon">${ft.icon}</span>
|
||||
<span class="ann-type-label">${ft.label}</span>
|
||||
</button>`,
|
||||
).join('')}
|
||||
</div>
|
||||
<div class="ann-form-fields">
|
||||
<input type="text" class="ann-author-input" placeholder="Your name" value="${escapeHtml(savedAuthor)}" maxlength="64">
|
||||
<textarea class="ann-body-input" placeholder="What happened here? (max 500 chars)" maxlength="500" rows="2"></textarea>
|
||||
<span class="ann-char-count">0 / 500</span>
|
||||
</div>
|
||||
<button class="btn primary ann-submit-btn" disabled>Add Annotation</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
let selectedType: FeedbackType | null = null;
|
||||
const typeBtns = container.querySelectorAll('.ann-type-btn');
|
||||
const bodyInput = container.querySelector('.ann-body-input') as HTMLTextAreaElement;
|
||||
const charCount = container.querySelector('.ann-char-count') as HTMLSpanElement;
|
||||
const authorInput = container.querySelector('.ann-author-input') as HTMLInputElement;
|
||||
const submitBtn = container.querySelector('.ann-submit-btn') as HTMLButtonElement;
|
||||
|
||||
typeBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
typeBtns.forEach(b => b.classList.remove('selected'));
|
||||
btn.classList.add('selected');
|
||||
selectedType = (btn as HTMLElement).dataset.type as FeedbackType;
|
||||
updateSubmitState();
|
||||
});
|
||||
});
|
||||
|
||||
bodyInput.addEventListener('input', () => {
|
||||
charCount.textContent = `${bodyInput.value.length} / 500`;
|
||||
updateSubmitState();
|
||||
});
|
||||
|
||||
authorInput.addEventListener('input', () => {
|
||||
localStorage.setItem(authorKey, authorInput.value.trim());
|
||||
});
|
||||
|
||||
function updateSubmitState(): void {
|
||||
submitBtn.disabled = !selectedType || bodyInput.value.trim().length === 0;
|
||||
}
|
||||
|
||||
submitBtn.addEventListener('click', async () => {
|
||||
if (!selectedType || !bodyInput.value.trim()) return;
|
||||
|
||||
const author = authorInput.value.trim() || 'Anonymous';
|
||||
const matchId = getMatchId();
|
||||
const turn = getTurn();
|
||||
const position = getGridPosition();
|
||||
|
||||
const ann: Annotation = {
|
||||
id: `ann_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
match_id: matchId,
|
||||
turn,
|
||||
type: selectedType,
|
||||
body: bodyInput.value.trim(),
|
||||
author,
|
||||
upvotes: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
position,
|
||||
};
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Submitting...';
|
||||
|
||||
await submitAnnotation(ann);
|
||||
|
||||
// Reset form
|
||||
typeBtns.forEach(b => b.classList.remove('selected'));
|
||||
selectedType = null;
|
||||
bodyInput.value = '';
|
||||
charCount.textContent = '0 / 500';
|
||||
submitBtn.textContent = 'Add Annotation';
|
||||
submitBtn.disabled = true;
|
||||
|
||||
// Dispatch custom event so the replay page can handle it
|
||||
container.dispatchEvent(new CustomEvent('annotation-added', { detail: ann, bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Styles ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const ANNOTATION_OVERLAY_STYLES = `
|
||||
.ann-overlay-empty { color: var(--text-muted); font-size: 0.8rem; padding: 8px; text-align: center; }
|
||||
.ann-overlay-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.ann-overlay-title { font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
|
||||
.ann-overlay-count { font-size: 0.7rem; background: var(--bg-tertiary); color: var(--text-muted); padding: 2px 6px; border-radius: 8px; }
|
||||
.ann-overlay-track { position: relative; height: 16px; background: var(--bg-tertiary); border-radius: 4px; margin-bottom: 10px; }
|
||||
.ann-marker { position: absolute; top: 1px; bottom: 1px; display: flex; align-items: center; justify-content: center; transform: translateX(-50%); cursor: pointer; }
|
||||
.ann-marker-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ann-color, #94a3b8); transition: transform 0.15s; }
|
||||
.ann-marker:hover .ann-marker-dot { transform: scale(1.5); }
|
||||
.ann-marker.active .ann-marker-dot { transform: scale(1.8); box-shadow: 0 0 4px var(--ann-color); }
|
||||
.ann-marker-count { position: absolute; top: -8px; font-size: 0.55rem; color: var(--text-muted); font-weight: 600; }
|
||||
.ann-overlay-list { display: flex; flex-direction: column; gap: 6px; max-height: 180px; overflow-y: auto; }
|
||||
.ann-no-current { color: var(--text-muted); font-size: 0.75rem; font-style: italic; }
|
||||
.ann-item { background: var(--bg-tertiary); border-radius: 6px; padding: 8px; border-left: 3px solid var(--ann-color, #475569); }
|
||||
.ann-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
|
||||
.ann-item-type { font-size: 0.75rem; font-weight: 600; }
|
||||
.ann-item-author { font-size: 0.65rem; color: var(--text-muted); }
|
||||
.ann-item-body { font-size: 0.8rem; color: var(--text-secondary); line-height: 1.4; margin-bottom: 4px; }
|
||||
.ann-item-pos { font-size: 0.65rem; color: var(--text-muted); font-family: monospace; margin-bottom: 4px; }
|
||||
.ann-item-meta { display: flex; gap: 10px; font-size: 0.65rem; color: var(--text-muted); }
|
||||
/* Annotation form */
|
||||
.ann-form { display: flex; flex-direction: column; gap: 10px; }
|
||||
.ann-form-types { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.ann-type-btn { display: flex; align-items: center; gap: 4px; background: var(--bg-primary); border: 2px solid var(--ann-color, #475569); color: var(--ann-color, #94a3b8); padding: 4px 10px; border-radius: 16px; cursor: pointer; font-size: 0.75rem; transition: all 0.15s; }
|
||||
.ann-type-btn:hover { background: color-mix(in srgb, var(--ann-color, #475569) 15%, transparent); }
|
||||
.ann-type-btn.selected { background: color-mix(in srgb, var(--ann-color, #475569) 25%, transparent); font-weight: 600; }
|
||||
.ann-type-icon { font-size: 0.85rem; }
|
||||
.ann-type-label { font-size: 0.7rem; }
|
||||
.ann-form-fields { display: flex; flex-direction: column; gap: 6px; }
|
||||
.ann-author-input, .ann-body-input { width: 100%; background: var(--bg-primary); border: 1px solid var(--border); color: var(--text-primary); padding: 6px 8px; border-radius: 6px; font-size: 0.8rem; font-family: inherit; }
|
||||
.ann-body-input { resize: vertical; }
|
||||
.ann-char-count { font-size: 0.6rem; color: var(--text-muted); text-align: right; }
|
||||
.ann-submit-btn { align-self: flex-end; }
|
||||
`;
|
||||
|
||||
// ─── Utilities ──────────────────────────────────────────────────────────────
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
if (diffMin < 1) return 'just now';
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
|
@ -654,6 +654,10 @@ function renderStatistics(container: HTMLElement, totals: Totals): void {
|
|||
<div class="stat-label">Evolved in Top 10</div>
|
||||
<div class="stat-value">${totals.evolved_in_top_10}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Mutations / Hour</div>
|
||||
<div class="stat-value">${totals.mutations_per_hour ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,16 +60,13 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string):
|
|||
<div class="win-prob-header">
|
||||
<span class="win-prob-title">Win Probability</span>
|
||||
<div class="critical-moment-nav">
|
||||
<button id="prev-critical-btn" class="btn" title="Previous critical moment" disabled>◀ Prev</button>
|
||||
<button id="prev-critical-btn" class="btn" title="Previous critical moment ([)" disabled>◀ Prev</button>
|
||||
<span id="critical-moment-info" class="critical-moment-info">—</span>
|
||||
<button id="next-critical-btn" class="btn" title="Next critical moment" disabled>Next ▶</button>
|
||||
<button id="next-critical-btn" class="btn" title="Next critical moment (])" disabled>Next ▶</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="win-prob-container" class="win-prob-container"></div>
|
||||
<div class="win-prob-legend">
|
||||
<span id="wp-p0-label" class="wp-legend-p0">— Player 0</span>
|
||||
<span id="wp-p1-label" class="wp-legend-p1">-- Player 1</span>
|
||||
</div>
|
||||
<div id="win-prob-legend" class="win-prob-legend"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -176,6 +173,7 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string):
|
|||
<div class="keyboard-shortcuts">
|
||||
<kbd>Space</kbd> Play/Pause
|
||||
<kbd>←</kbd><kbd>→</kbd> Step
|
||||
<kbd>[</kbd><kbd>]</kbd> Prev/Next Critical
|
||||
<kbd>Home</kbd><kbd>End</kbd> First/Last
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -242,9 +240,7 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string):
|
|||
.critical-moment-nav .btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.critical-moment-info { color: var(--text-muted); font-size: 0.8rem; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.win-prob-container { width: 100%; overflow: hidden; border-radius: 4px; }
|
||||
.win-prob-legend { display: flex; gap: 16px; margin-top: 6px; font-size: 0.75rem; }
|
||||
.wp-legend-p0 { color: #3b82f6; }
|
||||
.wp-legend-p1 { color: #ef4444; }
|
||||
.win-prob-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 6px; font-size: 0.75rem; font-family: monospace; }
|
||||
.commentary-bar { background-color: var(--bg-secondary); border-radius: 8px; padding: 8px 12px; margin-top: 10px; display: flex; align-items: center; gap: 10px; min-height: 40px; }
|
||||
.commentary-content { flex: 1; min-width: 0; }
|
||||
.commentary-text { color: var(--text-secondary); font-size: 0.875rem; line-height: 1.4; display: block; }
|
||||
|
|
@ -342,11 +338,10 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
|
|||
const infoReason = document.getElementById('info-reason') as HTMLElement;
|
||||
const winProbSection = document.getElementById('win-prob-section') as HTMLDivElement;
|
||||
const winProbContainer = document.getElementById('win-prob-container') as HTMLDivElement;
|
||||
const winProbLegend = document.getElementById('win-prob-legend') as HTMLDivElement;
|
||||
const prevCriticalBtn = document.getElementById('prev-critical-btn') as HTMLButtonElement;
|
||||
const nextCriticalBtn = document.getElementById('next-critical-btn') as HTMLButtonElement;
|
||||
const criticalMomentInfo = document.getElementById('critical-moment-info') as HTMLSpanElement;
|
||||
const wpP0Label = document.getElementById('wp-p0-label') as HTMLSpanElement;
|
||||
const wpP1Label = document.getElementById('wp-p1-label') as HTMLSpanElement;
|
||||
const commentaryBar = document.getElementById('commentary-bar') as HTMLDivElement;
|
||||
const commentaryText = document.getElementById('commentary-text') as HTMLSpanElement;
|
||||
const commentaryToggle = document.getElementById('commentary-toggle') as HTMLButtonElement;
|
||||
|
|
@ -631,28 +626,42 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
|
|||
return;
|
||||
}
|
||||
|
||||
const points = replay.win_prob.map((pair: any, t: number) => ({
|
||||
// Map win_prob: number[][] → WinProbPoint[] (one probs array per turn)
|
||||
const points = replay.win_prob.map((probs: number[], t: number) => ({
|
||||
turn: t,
|
||||
p0WinProb: pair[0] ?? 0.5,
|
||||
p1WinProb: pair[1] ?? 0.5,
|
||||
drawProb: Math.max(0, 1 - (pair[0] ?? 0.5) - (pair[1] ?? 0.5)),
|
||||
probs: probs.slice(), // copy to avoid mutation
|
||||
}));
|
||||
|
||||
criticalMoments = replay.critical_moments ?? [];
|
||||
|
||||
// Build player colors array matching the viewer's palette
|
||||
const playerColors = replay.players.map((_: any, idx: number) => {
|
||||
const palettes = [
|
||||
'#332288', '#88ccee', '#44aa99', '#117733', '#999933', '#ddcc77',
|
||||
'#882255', '#cc6677',
|
||||
];
|
||||
return palettes[idx] ?? '#888888';
|
||||
});
|
||||
|
||||
viewer.setWinProbabilityData(points);
|
||||
viewer.setCriticalMoments(criticalMoments);
|
||||
viewer.setWinProbPlayerColors(playerColors);
|
||||
|
||||
winProbSection.style.display = 'block';
|
||||
|
||||
if (replay.players.length >= 1) wpP0Label.textContent = `— ${replay.players[0].name}`;
|
||||
if (replay.players.length >= 2) wpP1Label.textContent = `-- ${replay.players[1].name}`;
|
||||
// Dynamic legend: one entry per player
|
||||
winProbLegend.innerHTML = replay.players.map((player: any, idx: number) => {
|
||||
const color = playerColors[idx];
|
||||
const dash = idx === 0 ? '—' : '--';
|
||||
return `<span style="color:${color}">${dash} ${player.name}</span>`;
|
||||
}).join(' ');
|
||||
|
||||
winProbContainer.innerHTML = '';
|
||||
viewer.createWinProbSparkline(winProbContainer, 800, 70, (turn: number) => {
|
||||
viewer.setTurn(turn);
|
||||
updateUI();
|
||||
updateEventLog();
|
||||
updateCriticalMomentNav();
|
||||
});
|
||||
|
||||
updateCriticalMomentNav();
|
||||
|
|
@ -676,7 +685,7 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
prevCriticalBtn.addEventListener('click', () => {
|
||||
function navigateToPrevCriticalMoment(): void {
|
||||
const currentTurn = viewer.getTurn();
|
||||
const prev = [...criticalMoments].reverse().find((m: any) => m.turn < currentTurn);
|
||||
if (prev) {
|
||||
|
|
@ -685,9 +694,9 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
|
|||
updateEventLog();
|
||||
criticalMomentInfo.textContent = prev.description;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
nextCriticalBtn.addEventListener('click', () => {
|
||||
function navigateToNextCriticalMoment(): void {
|
||||
const currentTurn = viewer.getTurn();
|
||||
const next = criticalMoments.find((m: any) => m.turn > currentTurn);
|
||||
if (next) {
|
||||
|
|
@ -696,7 +705,10 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
|
|||
updateEventLog();
|
||||
criticalMomentInfo.textContent = next.description;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
prevCriticalBtn.addEventListener('click', navigateToPrevCriticalMoment);
|
||||
nextCriticalBtn.addEventListener('click', navigateToNextCriticalMoment);
|
||||
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
|
|
@ -792,9 +804,10 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
|
|||
viewer.onTurnChange = () => {
|
||||
updateUI();
|
||||
updateEventLog();
|
||||
if (criticalMoments.length > 0) updateCriticalMomentNav();
|
||||
updateCriticalMomentNav();
|
||||
updateMobileUI();
|
||||
updateMobileTimeline();
|
||||
viewer.refreshWinProbSparkline();
|
||||
};
|
||||
viewer.onDebugChange = (debug: Record<number, DebugInfo> | null) => {
|
||||
updateDebugDisplay(debug);
|
||||
|
|
@ -965,6 +978,14 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
|
|||
updateUI();
|
||||
updateEventLog();
|
||||
break;
|
||||
case 'BracketLeft':
|
||||
e.preventDefault();
|
||||
navigateToPrevCriticalMoment();
|
||||
break;
|
||||
case 'BracketRight':
|
||||
e.preventDefault();
|
||||
navigateToNextCriticalMoment();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -136,9 +136,7 @@ function drawEffects(ctx: CanvasRenderingContext2D): void {
|
|||
// Win probability point for sparkline
|
||||
export interface WinProbPoint {
|
||||
turn: number;
|
||||
p0WinProb: number;
|
||||
p1WinProb: number;
|
||||
drawProb?: number;
|
||||
probs: number[]; // one probability per player (0.0–1.0)
|
||||
}
|
||||
|
||||
export interface CriticalMomentMarker {
|
||||
|
|
@ -147,6 +145,18 @@ export interface CriticalMomentMarker {
|
|||
description: string;
|
||||
}
|
||||
|
||||
// Default player colors for sparkline (matches DEFAULT_PLAYER_COLORS)
|
||||
const SPARKLINE_COLORS = [
|
||||
'#3b82f6', // Blue
|
||||
'#ef4444', // Red
|
||||
'#22c55e', // Green
|
||||
'#f59e0b', // Amber
|
||||
'#8b5cf6', // Purple
|
||||
'#06b6d4', // Cyan
|
||||
'#ec4899', // Pink
|
||||
'#f97316', // Orange
|
||||
];
|
||||
|
||||
// Render win probability sparkline to canvas
|
||||
export function renderWinProbSparkline(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
|
|
@ -155,12 +165,15 @@ export function renderWinProbSparkline(
|
|||
options: {
|
||||
width: number;
|
||||
height: number;
|
||||
color0?: string;
|
||||
color1?: string;
|
||||
playerColors?: string[];
|
||||
criticalMoments?: CriticalMomentMarker[];
|
||||
},
|
||||
): void {
|
||||
const { width, height, color0 = '#3b82f6', color1 = '#ef4444', criticalMoments = [] } = options;
|
||||
const {
|
||||
width, height,
|
||||
playerColors = SPARKLINE_COLORS,
|
||||
criticalMoments = [],
|
||||
} = options;
|
||||
const padding = { top: 8, bottom: 8, left: 4, right: 4 };
|
||||
const chartW = width - padding.left - padding.right;
|
||||
const chartH = height - padding.top - padding.bottom;
|
||||
|
|
@ -176,6 +189,7 @@ export function renderWinProbSparkline(
|
|||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
const maxTurn = points[points.length - 1].turn;
|
||||
const numPlayers = points[0].probs.length;
|
||||
|
||||
const x = (turn: number) => padding.left + (turn / maxTurn) * chartW;
|
||||
const y = (prob: number) => padding.top + chartH * (1 - prob);
|
||||
|
|
@ -191,10 +205,19 @@ export function renderWinProbSparkline(
|
|||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// 0% and 100% labels
|
||||
ctx.fillStyle = '#475569';
|
||||
ctx.font = '8px monospace';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText('100%', padding.left + 28, padding.top + 6);
|
||||
ctx.fillText('0%', padding.left + 22, height - padding.bottom - 1);
|
||||
|
||||
// Critical moment markers — dashed vertical lines with delta labels
|
||||
for (const moment of criticalMoments) {
|
||||
const mx = x(moment.turn);
|
||||
const markerColor = moment.delta > 0 ? color0 : color1;
|
||||
const markerColor = moment.delta > 0
|
||||
? playerColors[0] ?? SPARKLINE_COLORS[0]
|
||||
: playerColors[1] ?? SPARKLINE_COLORS[1];
|
||||
|
||||
ctx.strokeStyle = markerColor + 'aa';
|
||||
ctx.lineWidth = 1.5;
|
||||
|
|
@ -225,44 +248,41 @@ export function renderWinProbSparkline(
|
|||
ctx.fillText(label, Math.max(18, Math.min(width - 18, mx)), padding.top + 7);
|
||||
}
|
||||
|
||||
// P0 area fill
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, y(0.5));
|
||||
for (const pt of points) {
|
||||
ctx.lineTo(x(pt.turn), y(pt.p0WinProb));
|
||||
// Area fill for first two players (creates the visual gradient)
|
||||
if (numPlayers >= 2) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, y(points[0].probs[0]));
|
||||
for (const pt of points) {
|
||||
ctx.lineTo(x(pt.turn), y(pt.probs[0]));
|
||||
}
|
||||
ctx.lineTo(width - padding.right, y(points[points.length - 1].probs[1]));
|
||||
for (let i = points.length - 1; i >= 0; i--) {
|
||||
ctx.lineTo(x(points[i].turn), y(points[i].probs[1]));
|
||||
}
|
||||
ctx.closePath();
|
||||
const grad = ctx.createLinearGradient(0, padding.top, 0, height - padding.bottom);
|
||||
grad.addColorStop(0, (playerColors[0] ?? SPARKLINE_COLORS[0]) + '33');
|
||||
grad.addColorStop(0.5, 'transparent');
|
||||
grad.addColorStop(1, (playerColors[1] ?? SPARKLINE_COLORS[1]) + '33');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.lineTo(width - padding.right, y(0.5));
|
||||
ctx.closePath();
|
||||
const grad = ctx.createLinearGradient(0, padding.top, 0, height - padding.bottom);
|
||||
grad.addColorStop(0, color0 + '44');
|
||||
grad.addColorStop(0.5, 'transparent');
|
||||
grad.addColorStop(1, color1 + '44');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
|
||||
// P0 line
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const pt = points[i];
|
||||
if (i === 0) ctx.moveTo(x(pt.turn), y(pt.p0WinProb));
|
||||
else ctx.lineTo(x(pt.turn), y(pt.p0WinProb));
|
||||
// Draw a line per player
|
||||
for (let p = numPlayers - 1; p >= 0; p--) {
|
||||
const color = playerColors[p] ?? SPARKLINE_COLORS[p % SPARKLINE_COLORS.length];
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const pt = points[i];
|
||||
if (i === 0) ctx.moveTo(x(pt.turn), y(pt.probs[p]));
|
||||
else ctx.lineTo(x(pt.turn), y(pt.probs[p]));
|
||||
}
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = p === 0 ? 2 : 1.5;
|
||||
if (p > 1) ctx.setLineDash([4, 3]);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
ctx.strokeStyle = color0;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// P1 line (dashed)
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const pt = points[i];
|
||||
if (i === 0) ctx.moveTo(x(pt.turn), y(pt.p1WinProb));
|
||||
else ctx.lineTo(x(pt.turn), y(pt.p1WinProb));
|
||||
}
|
||||
ctx.strokeStyle = color1;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([4, 3]);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Current turn marker
|
||||
const curX = x(currentTurn);
|
||||
|
|
@ -273,16 +293,19 @@ export function renderWinProbSparkline(
|
|||
ctx.lineTo(curX, height - padding.bottom);
|
||||
ctx.stroke();
|
||||
|
||||
// Current probability dot
|
||||
// Current probability dots for all players
|
||||
const curPt = points.find(p => p.turn >= currentTurn) ?? points[points.length - 1];
|
||||
if (curPt) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(curX, y(curPt.p0WinProb), 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = curPt.p0WinProb > 0.5 ? color0 : color1;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
for (let p = 0; p < curPt.probs.length; p++) {
|
||||
const color = playerColors[p] ?? SPARKLINE_COLORS[p % SPARKLINE_COLORS.length];
|
||||
ctx.beginPath();
|
||||
ctx.arc(curX, y(curPt.probs[p]), 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1741,6 +1764,7 @@ export class ReplayViewer {
|
|||
private winProbData: WinProbPoint[] | null = null;
|
||||
private winProbCanvas: HTMLCanvasElement | null = null;
|
||||
private winProbCriticalMoments: CriticalMomentMarker[] = [];
|
||||
private winProbPlayerColors: string[] = [];
|
||||
|
||||
setWinProbabilityData(points: WinProbPoint[]): void {
|
||||
this.winProbData = points;
|
||||
|
|
@ -1760,6 +1784,18 @@ export class ReplayViewer {
|
|||
return this.winProbCriticalMoments;
|
||||
}
|
||||
|
||||
// Set player colors used in the sparkline (must call before createWinProbSparkline)
|
||||
setWinProbPlayerColors(colors: string[]): void {
|
||||
this.winProbPlayerColors = colors;
|
||||
}
|
||||
|
||||
// Re-render the sparkline at the current turn (call from onTurnChange)
|
||||
refreshWinProbSparkline(): void {
|
||||
if (this.winProbCanvas && this.winProbData) {
|
||||
this.renderWinProbSparkline();
|
||||
}
|
||||
}
|
||||
|
||||
// Create and attach a win probability sparkline canvas below the main viewer.
|
||||
// Pass onTurnClick to enable click-to-scrub: clicking anywhere on the sparkline
|
||||
// calls onTurnClick with the nearest turn number.
|
||||
|
|
@ -1806,8 +1842,7 @@ export class ReplayViewer {
|
|||
renderWinProbSparkline(ctx, this.winProbData, this.currentTurn, {
|
||||
width: this.winProbCanvas.width,
|
||||
height: this.winProbCanvas.height,
|
||||
color0: this.accessibility.highContrast ? '#0000ff' : '#3b82f6',
|
||||
color1: this.accessibility.highContrast ? '#ff0000' : '#ef4444',
|
||||
playerColors: this.winProbPlayerColors,
|
||||
criticalMoments: this.winProbCriticalMoments,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
117
web/src/types.ts
117
web/src/types.ts
|
|
@ -279,3 +279,120 @@ export interface PredictionLeaderboard {
|
|||
updated_at: string;
|
||||
leaders: PredictorStats[];
|
||||
}
|
||||
|
||||
// Evolution live.json schema (plan §14) — real-time dashboard feed from acb-evolver
|
||||
|
||||
export interface EvolutionIslandStat {
|
||||
population: number;
|
||||
best_rating: number;
|
||||
best_bot: string;
|
||||
language_div?: string;
|
||||
}
|
||||
|
||||
export interface EvolutionParentInfo {
|
||||
id: string;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export interface EvolutionStageResult {
|
||||
passed: boolean;
|
||||
time_ms: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface EvolutionValidationStatus {
|
||||
syntax?: EvolutionStageResult;
|
||||
schema?: EvolutionStageResult;
|
||||
smoke?: EvolutionStageResult;
|
||||
}
|
||||
|
||||
export interface EvaluationMatchResult {
|
||||
opponent: string;
|
||||
won: boolean;
|
||||
score: string;
|
||||
}
|
||||
|
||||
export interface EvolutionEvaluationStatus {
|
||||
matches_total: number;
|
||||
matches_played: number;
|
||||
results: EvaluationMatchResult[];
|
||||
}
|
||||
|
||||
export interface EvolutionCandidate {
|
||||
id: string;
|
||||
island: string;
|
||||
language: string;
|
||||
parents: EvolutionParentInfo[];
|
||||
validation?: EvolutionValidationStatus;
|
||||
evaluation?: EvolutionEvaluationStatus;
|
||||
}
|
||||
|
||||
export interface EvolutionCycleInfo {
|
||||
generation: number;
|
||||
started_at: string;
|
||||
phase: string; // generating, validating, evaluating, promoting, idle
|
||||
candidate?: EvolutionCandidate;
|
||||
}
|
||||
|
||||
export interface EvolutionActivityEntry {
|
||||
time: string;
|
||||
generation: number;
|
||||
candidate: string;
|
||||
island: string;
|
||||
result: string; // promoted, rejected
|
||||
reason: string;
|
||||
stage: string; // validation, promotion, deployment
|
||||
bot_id?: string;
|
||||
initial_rating?: number;
|
||||
}
|
||||
|
||||
export interface EvolutionTotals {
|
||||
generations_total: number;
|
||||
candidates_today: number;
|
||||
promoted_today: number;
|
||||
promotion_rate_7d: number;
|
||||
highest_evolved_rating: number;
|
||||
evolved_in_top_10: number;
|
||||
mutations_per_hour: number;
|
||||
}
|
||||
|
||||
export interface EvolutionGenerationEntry {
|
||||
generation: number;
|
||||
island: string;
|
||||
evaluated_at: string;
|
||||
count: number;
|
||||
promoted: number;
|
||||
best_fitness: number;
|
||||
avg_fitness: number;
|
||||
}
|
||||
|
||||
export interface EvolutionLineageNode {
|
||||
id: number;
|
||||
parent_ids: number[];
|
||||
generation: number;
|
||||
island: string;
|
||||
fitness: number;
|
||||
promoted: boolean;
|
||||
language: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface EvolutionMetaSnapshot {
|
||||
generation: number;
|
||||
island_counts: Record<string, number>;
|
||||
island_best_fitness: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface LiveJSON {
|
||||
updated_at: string;
|
||||
cycle?: EvolutionCycleInfo;
|
||||
recent_activity?: EvolutionActivityEntry[];
|
||||
islands: Record<string, EvolutionIslandStat>;
|
||||
totals: EvolutionTotals;
|
||||
// Legacy fields for backward compatibility
|
||||
total_programs?: number;
|
||||
promoted_count?: number;
|
||||
generation_log?: EvolutionGenerationEntry[];
|
||||
lineage?: EvolutionLineageNode[];
|
||||
meta_snapshots?: EvolutionMetaSnapshot[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue