← 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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; };
});