← back to Debate Ring Viewer
public/app.js
306 lines
// Debate Ring Viewer client.
// Loads runs from the API, places 8 fighters around an octagonal ring, and
// plays a round by sequencing punches + speech bubbles per panelist's text.
const FIGHTERS = [
{ id:'claude', name:'Claude' },
{ id:'codex', name:'Codex' },
{ id:'deepseek', name:'DeepSeek' },
{ id:'gptoss', name:'GPT-OSS' },
{ id:'kimi', name:'Kimi K2.5' },
{ id:'mistral', name:'Mistral' },
{ id:'phi4', name:'Phi-4' },
{ id:'qwen', name:'Qwen3' },
];
// 8 corners of the ring, in % left/top inside the ring element.
// Two rows of 4 to keep the perspective readable.
const SLOTS = [
{ left:18, top:18 }, { left:42, top:14 }, { left:62, top:14 }, { left:84, top:18 },
{ left:18, top:78 }, { left:42, top:84 }, { left:62, top:84 }, { left:84, top:78 },
];
const PUNCHES = ['jab','cross','hook','uppercut'];
const $ = sel => document.querySelector(sel);
const $$ = sel => Array.from(document.querySelectorAll(sel));
let state = {
runs:[], runName:null, manifest:null, idx:0,
playing:false, speed:1.0, sound:true,
audioCtx:null, abort:null, runToken:0,
};
function setControlsEnabled(enabled){
const ids = ['play','prev','next'];
ids.forEach(id => { const el = document.getElementById(id); if (el) el.disabled = !enabled; });
}
// ── audio ────────────────────────────────────────────────────────────────
function ac(){
if (!state.sound) return null;
if (!state.audioCtx) state.audioCtx = new (window.AudioContext||window.webkitAudioContext)();
if (state.audioCtx.state === 'suspended') state.audioCtx.resume().catch(()=>{});
return state.audioCtx;
}
function bell(){
const c = ac(); if (!c) return;
const o = c.createOscillator(), g = c.createGain();
o.frequency.value = 880;
o.type = 'triangle';
g.gain.setValueAtTime(0, c.currentTime);
g.gain.linearRampToValueAtTime(.3, c.currentTime+.01);
g.gain.exponentialRampToValueAtTime(.0001, c.currentTime+1.4);
o.connect(g).connect(c.destination);
o.start(); o.stop(c.currentTime+1.4);
}
function thud(){
const c = ac(); if (!c) return;
const o = c.createOscillator(), g = c.createGain();
o.type = 'square';
o.frequency.setValueAtTime(180, c.currentTime);
o.frequency.exponentialRampToValueAtTime(40, c.currentTime+.18);
g.gain.setValueAtTime(.18, c.currentTime);
g.gain.exponentialRampToValueAtTime(.0001, c.currentTime+.2);
o.connect(g).connect(c.destination);
o.start(); o.stop(c.currentTime+.21);
}
function whoosh(){
const c = ac(); if (!c) return;
const buf = c.createBuffer(1, c.sampleRate*.18, c.sampleRate);
const d = buf.getChannelData(0);
for (let i=0;i<d.length;i++) d[i] = (Math.random()*2-1) * Math.pow(1-i/d.length,2);
const src = c.createBufferSource(); src.buffer = buf;
const f = c.createBiquadFilter(); f.type='bandpass'; f.frequency.value=900;
const g = c.createGain(); g.gain.value=.15;
src.connect(f).connect(g).connect(c.destination);
src.start();
}
// ── DOM ──────────────────────────────────────────────────────────────────
function makeFighters(){
const tpl = $('#fighter-tpl');
const host = $('#fighters'); host.innerHTML='';
FIGHTERS.forEach((f, i) => {
const node = tpl.content.firstElementChild.cloneNode(true);
node.dataset.id = f.id;
node.classList.add('idle');
node.style.left = SLOTS[i].left + '%';
node.style.top = SLOTS[i].top + '%';
node.querySelector('.nameplate .nm').textContent = f.name;
node.querySelector('.body text').textContent = f.name.toUpperCase();
const svg = node.querySelector('svg.body');
if (svg && !svg.querySelector('title')) {
const t = document.createElementNS('http://www.w3.org/2000/svg', 'title');
t.textContent = f.name + ' fighter';
svg.insertBefore(t, svg.firstChild);
svg.setAttribute('role', 'img');
svg.setAttribute('aria-label', f.name + ' fighter');
}
host.appendChild(node);
});
}
function fighterEl(id){ return $(`.fighter[data-id="${id}"]`); }
function bubble(id, text){
const el = fighterEl(id);
if (!el) return;
const fIdx = FIGHTERS.findIndex(f => f.id===id);
if (fIdx === -1) return;
const slot = SLOTS[fIdx];
const b = document.createElement('div');
b.className = 'bubble';
b.style.left = slot.left + '%';
b.style.top = slot.top + '%';
const fname = FIGHTERS[fIdx].name;
const who = document.createElement('span');
who.className = 'who';
who.textContent = fname;
b.appendChild(who);
b.appendChild(document.createTextNode(text));
// tint the WHO label by reading the fighter's CSS variable
const shorts = getComputedStyle(el).getPropertyValue('--shorts');
who.style.color = shorts.trim();
$('#bubbles').appendChild(b);
requestAnimationFrame(() => b.classList.add('show'));
setTimeout(() => { b.classList.remove('show'); setTimeout(() => b.remove(), 400); },
Math.max(2400, Math.min(5400, text.length * 70)) / state.speed);
}
function escapeHtml(s){
return s.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
function impactAt(id){
const el = fighterEl(id);
if (!el) return;
const fIdx = FIGHTERS.findIndex(f => f.id===id);
if (fIdx === -1) return;
const slot = SLOTS[fIdx];
const fx = document.createElement('div');
fx.className = 'impact';
fx.style.left = (slot.left + (slot.top<50? 6: -6)) + '%';
fx.style.top = (slot.top + 6) + '%';
$('#fighters').appendChild(fx);
$('#ring').classList.add('shake');
setTimeout(() => { fx.remove(); $('#ring').classList.remove('shake'); }, 420);
thud();
}
function throwPunch(id){
const el = fighterEl(id); if (!el) return;
const kind = PUNCHES[Math.floor(Math.random()*PUNCHES.length)];
el.classList.remove('idle');
el.classList.add(kind, 'active');
whoosh();
setTimeout(() => {
el.classList.remove(kind);
el.classList.add('idle');
// pick a random *other* fighter to take the impact
const others = FIGHTERS.filter(f => f.id !== id).map(f => f.id);
const target = others[Math.floor(Math.random()*others.length)];
impactAt(target);
const tEl = fighterEl(target);
if (tEl) {
tEl.classList.add('stagger');
setTimeout(() => tEl.classList.remove('stagger'), 280);
}
}, 220 / state.speed);
}
// chunk a long text block into sentence-sized punches
function chunkText(s){
if (!s) return [];
return s
.replace(/\s+/g,' ')
.split(/(?<=[.!?])\s+/)
.map(x => x.trim())
.filter(Boolean)
.slice(0, 8); // cap so the round stays watchable
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
function aborted(){ return state.abort && state.abort.aborted; }
async function playRound(n){
$('#roundN').textContent = n;
bell();
await sleep(500 / state.speed);
if (aborted()) return;
const r = await fetch(`/api/runs/${state.runName}/round/${n}`)
.then(r=>r.json())
.catch(() => ({ error: 'fetch failed' }));
if (!r || r.error || !r.texts) return;
const order = FIGHTERS.map(f => f.id)
.sort(() => Math.random() - .5); // randomize so it feels chaotic
for (const id of order) {
if (aborted()) return;
const txt = (r.texts[id] || '').trim();
if (!txt) {
// Empty file = panelist no-show / KO'd this round
const el = fighterEl(id);
if (el) {
el.classList.add('ko');
bubble(id, '— silent —');
await sleep(700 / state.speed);
el.classList.remove('ko');
}
continue;
}
const lines = chunkText(txt);
for (const line of lines) {
if (aborted()) return;
throwPunch(id);
bubble(id, line);
await sleep( Math.max(900, Math.min(3200, line.length * 32)) / state.speed );
}
}
}
async function playFromHere(){
if (state.playing) return;
if (!state.manifest || !Array.isArray(state.manifest.rounds) || !state.manifest.rounds.length) return;
state.playing = true;
state.abort = { aborted:false };
const myToken = ++state.runToken;
$('#play').textContent = '⏸ Pause';
$('#play').onclick = stop;
try {
for (let i=state.idx; i < state.manifest.rounds.length; i++) {
if (aborted() || state.runToken !== myToken) break;
state.idx = i;
updateNow();
await playRound(state.manifest.rounds[i]);
}
} catch (err) {
console.error('playFromHere failed:', err);
} finally {
if (state.runToken === myToken) {
state.playing = false;
$('#play').textContent = '▶ Fight';
$('#play').onclick = playFromHere;
}
}
}
function stop(){
if (!state.playing) return;
if (state.abort) state.abort.aborted = true;
state.runToken++;
state.playing = false;
$('#play').textContent = '▶ Fight';
$('#play').onclick = playFromHere;
}
function updateNow(){
if (!state.manifest) { $('#now').textContent = '—'; return; }
const total = state.manifest.rounds.length;
const cur = state.manifest.rounds[state.idx];
$('#now').innerHTML = `<b>${state.runName}</b><br>Round ${cur} of ${total}`;
$('#roundN').textContent = cur;
$('#roundT').textContent = total;
}
async function loadRuns(){
const list = await fetch('/api/runs').then(r=>r.json());
state.runs = list;
const ul = $('#runs'); ul.innerHTML='';
list.forEach(r => {
const li = document.createElement('li');
const nm = document.createElement('span');
nm.textContent = r.name;
const rd = document.createElement('span');
rd.className = 'r';
rd.textContent = r.rounds + 'r';
li.appendChild(nm);
li.appendChild(rd);
li.onclick = () => loadRun(r.name);
li.dataset.name = r.name;
ul.appendChild(li);
});
if (list[0]) loadRun(list[0].name);
}
async function loadRun(name){
stop();
state.runName = name;
state.idx = 0;
$$('#runs li').forEach(li => li.classList.toggle('active', li.dataset.name===name));
state.manifest = await fetch(`/api/runs/${name}`).then(r=>r.json());
if (state.manifest.final) {
$('#finalWrap').hidden = false;
$('#final').textContent = state.manifest.final;
} else { $('#finalWrap').hidden = true; }
updateNow();
}
// ── wire up ─────────────────────────────────────────────────────────────
window.addEventListener('DOMContentLoaded', () => {
makeFighters();
loadRuns();
$('#play').onclick = playFromHere;
$('#prev').onclick = () => { stop(); state.idx = Math.max(0, state.idx-1); updateNow(); };
$('#next').onclick = () => { stop(); state.idx = Math.min(state.manifest.rounds.length-1, state.idx+1); updateNow(); };
$('#speed').oninput = e => { state.speed = +e.target.value; $('#speedV').textContent = state.speed.toFixed(1)+'×'; };
$('#sound').onchange = e => { state.sound = e.target.checked; };
});