4882225751
Hand viewer: - stacks now decrement as players commit chips (street-aware "to"-amount accounting), showing e.g. 300 -> 285 after a 15 open, "all in" at 0; pot is computed from total committed (accurate, no double-counting raises) Theme (match the rec-theory-optimal look — warm black & orange, not Halloween): - deep near-black bg (#070707 / #0e0e0e panels), warm orange accent (#ff7a00), amber-gold secondary (#ffb347), muted green (#8fd694); warm dark borders - killed the neon-orange glows and the purple accents; chat app + all standalone pages (logs/self/journal/hand/recap/hands) on one palette Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
252 lines
13 KiB
HTML
252 lines
13 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||
<meta name="theme-color" content="#070707" />
|
||
<title>Lyra — Hand</title>
|
||
<style>
|
||
:root {
|
||
--bg:#070707; --bg-elev:#0e0e0e; --border:#2a1d12; --text:#e8e8e8;
|
||
--fade:#8a8a8a; --accent:#ff7a00; --felt:#16322a; --feltline:#0f5132;
|
||
--chip:#ffb347; --hero:#ff7a00;
|
||
}
|
||
*{box-sizing:border-box;}
|
||
html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text);
|
||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;-webkit-text-size-adjust:100%;}
|
||
header{position:sticky;top:0;z-index:10;background:var(--bg-elev);border-bottom:1px solid var(--border);
|
||
padding:env(safe-area-inset-top) 14px 0;}
|
||
.topbar{display:flex;align-items:baseline;gap:10px;padding:12px 0;flex-wrap:wrap;}
|
||
.topbar h1{font-size:1.02rem;margin:0;font-weight:600;}
|
||
.topbar a.back{color:var(--accent);text-decoration:none;font-size:.92rem;}
|
||
.sub{color:var(--fade);font-size:.85rem;margin-left:auto;}
|
||
main{max-width:760px;margin:0 auto;padding:14px;}
|
||
|
||
.table-wrap{position:relative;width:100%;max-width:560px;margin:8px auto;aspect-ratio:1.45/1;}
|
||
.felt{position:absolute;inset:8%;background:radial-gradient(ellipse at center,#1c4a3c,var(--felt));
|
||
border:6px solid #25201a;border-radius:50%/50%;box-shadow:inset 0 0 40px rgba(0,0,0,.5);}
|
||
.center{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;width:80%;}
|
||
.board{display:flex;gap:5px;justify-content:center;min-height:46px;align-items:center;flex-wrap:wrap;}
|
||
.pot{margin-top:8px;color:var(--chip);font-size:.85rem;font-variant-numeric:tabular-nums;}
|
||
.street{color:var(--fade);font-size:.72rem;text-transform:uppercase;letter-spacing:.6px;margin-bottom:4px;}
|
||
|
||
.card{display:inline-flex;flex-direction:column;align-items:center;justify-content:center;
|
||
width:32px;height:44px;background:#f4f4f0;color:#111;border-radius:5px;font-weight:700;
|
||
box-shadow:0 1px 3px rgba(0,0,0,.4);line-height:1;}
|
||
.card.sm{width:26px;height:36px;font-size:.8rem;}
|
||
.card .r{font-size:1rem;}
|
||
.card.red{color:#c8102e;}
|
||
.card.back{background:#2a3550;color:#2a3550;}
|
||
.card.unknown{background:#2a3550;color:#7c879e;font-size:1.2rem;}
|
||
.card .nosuit{color:#9aa3b5;}
|
||
|
||
.seat{position:absolute;transform:translate(-50%,-50%);width:96px;text-align:center;
|
||
background:rgba(13,16,22,.85);border:1px solid var(--border);border-radius:10px;padding:5px 4px;}
|
||
.seat.hero{border-color:var(--hero);box-shadow:0 0 10px rgba(255,122,0,.4);}
|
||
.seat.acting{border-color:var(--chip);box-shadow:0 0 12px rgba(255,179,71,.6);}
|
||
.seat .pos{font-size:.66rem;color:var(--accent);font-weight:700;letter-spacing:.4px;}
|
||
.seat .nm{font-size:.66rem;color:var(--fade);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.seat .cards{display:flex;gap:3px;justify-content:center;margin:3px 0;}
|
||
.seat .stack{font-size:.66rem;color:var(--text);font-variant-numeric:tabular-nums;}
|
||
.seat .act{font-size:.62rem;color:var(--chip);min-height:.8em;}
|
||
.seat.folded{opacity:.4;}
|
||
|
||
.controls{display:flex;gap:8px;align-items:center;justify-content:center;margin:14px 0 6px;}
|
||
.controls button{background:#241400;border:1px solid var(--border);color:var(--text);
|
||
border-radius:8px;padding:8px 14px;font-size:.95rem;cursor:pointer;-webkit-tap-highlight-color:transparent;}
|
||
.controls button:disabled{opacity:.4;}
|
||
.step-label{color:var(--fade);font-size:.8rem;min-width:80px;text-align:center;}
|
||
.now{text-align:center;color:var(--text);font-size:.95rem;min-height:1.3em;margin-bottom:6px;}
|
||
|
||
.log{margin-top:14px;border-top:1px solid var(--border);padding-top:10px;}
|
||
.log .ln{padding:5px 8px;border-radius:6px;font-size:.9rem;display:flex;gap:8px;}
|
||
.log .ln.cur{background:#241400;}
|
||
.log .ln.brd{color:var(--fade);font-style:italic;}
|
||
.log .st{color:var(--fade);font-size:.72rem;width:54px;flex:none;text-transform:uppercase;}
|
||
.summary{margin-top:14px;background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:12px;}
|
||
.summary .lbl{color:var(--fade);font-size:.72rem;text-transform:uppercase;letter-spacing:.5px;}
|
||
.err{color:#ff6b6b;text-align:center;padding:40px;}
|
||
.net-pos{color:#8fd694;} .net-neg{color:#ff6b6b;}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<div class="topbar">
|
||
<h1>🃏 Hand</h1>
|
||
<a class="back" href="/">← Chat</a>
|
||
<span class="sub" id="sub"></span>
|
||
</div>
|
||
</header>
|
||
<main id="root"><p class="err" id="boot">Loading hand…</p></main>
|
||
|
||
<script>
|
||
const SUIT = {s:"♠", h:"♥", d:"♦", c:"♣"};
|
||
const RED = new Set(["h", "d"]);
|
||
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML;}
|
||
|
||
function cardEl(code, sm){
|
||
if(!code) return '';
|
||
const c = String(code).trim();
|
||
if(c.toLowerCase()==='x') return `<span class="card${sm?' sm':''} unknown">?</span>`;
|
||
const m = c.match(/^(10|[2-9TJQKA])\s*([shdcx])$/i);
|
||
if(!m) return `<span class="card${sm?' sm':''}">${esc(c)}</span>`;
|
||
const r = m[1].toUpperCase().replace('10','T'); const s = m[2].toLowerCase();
|
||
if(s==='x') return `<span class="card${sm?' sm':''}"><span class="r">${r}</span><span class="nosuit">·</span></span>`;
|
||
return `<span class="card${sm?' sm':''}${RED.has(s)?' red':''}"><span class="r">${r}</span><span>${SUIT[s]}</span></span>`;
|
||
}
|
||
const cards = (arr, sm) => (arr||[]).map(c=>cardEl(c,sm)).join('');
|
||
|
||
function render(h){
|
||
const sub = document.getElementById('sub');
|
||
const data = h.structured;
|
||
if(!data){ document.getElementById('root').innerHTML = '<p class="err">This hand has no structured data to replay.</p>'; return; }
|
||
|
||
const players = (data.players||[]).slice();
|
||
// order so hero sits at the bottom
|
||
let heroIdx = players.findIndex(p => p.pos === data.hero_pos);
|
||
if(heroIdx < 0) heroIdx = 0;
|
||
const ordered = players.slice(heroIdx).concat(players.slice(0, heroIdx));
|
||
const n = Math.max(ordered.length, 1);
|
||
|
||
const acts = data.actions || [];
|
||
let step = 0; // number of actions applied
|
||
|
||
sub.textContent = [data.stakes, data.game].filter(Boolean).join(' ');
|
||
|
||
const root = document.getElementById('root');
|
||
root.innerHTML = `
|
||
<div class="table-wrap" id="tw">
|
||
<div class="felt"></div>
|
||
<div class="center">
|
||
<div class="street" id="street"></div>
|
||
<div class="board" id="board"></div>
|
||
<div class="pot" id="pot"></div>
|
||
</div>
|
||
<div id="seats"></div>
|
||
</div>
|
||
<div class="now" id="now"></div>
|
||
<div class="controls">
|
||
<button id="prev">◀ Prev</button>
|
||
<span class="step-label" id="steplab"></span>
|
||
<button id="next">Next ▶</button>
|
||
<button id="all">End</button>
|
||
</div>
|
||
<div class="log" id="log"></div>
|
||
${data.result ? `<div class="summary"><div class="lbl">Result</div>
|
||
<div>${esc(data.result.summary||'')}</div>
|
||
${data.result.hero_net!=null ? `<div class="${data.result.hero_net>=0?'net-pos':'net-neg'}">Hero net: ${data.result.hero_net>=0?'+':''}${esc(data.result.hero_net)}</div>`:''}
|
||
</div>`:''}
|
||
`;
|
||
|
||
// place seats around the oval
|
||
const seatsEl = document.getElementById('seats');
|
||
const starts = {};
|
||
ordered.forEach((p,i)=>{
|
||
starts[p.pos] = (p.stack!=null ? Number(p.stack) : null);
|
||
const ang = (90 + i*(360/n)) * Math.PI/180; // bottom = 90deg
|
||
const x = 50 + 46*Math.cos(ang), y = 50 + 44*Math.sin(ang);
|
||
const el = document.createElement('div');
|
||
el.className = 'seat' + (p.pos===data.hero_pos?' hero':'');
|
||
el.style.left = x+'%'; el.style.top = y+'%';
|
||
el.dataset.pos = p.pos;
|
||
const hcards = (p.pos===data.hero_pos ? (p.cards||data.hero_cards) : p.cards);
|
||
el.innerHTML = `<div class="pos">${esc(p.pos||'')}</div>`
|
||
+ (p.name?`<div class="nm">${esc(p.name)}</div>`:'')
|
||
+ `<div class="cards">${hcards?cards(hcards,true):'<span class="card sm back">x</span><span class="card sm back">x</span>'}</div>`
|
||
+ `<div class="stack" data-stack>${p.stack!=null?esc(p.stack):''}</div>`
|
||
+ `<div class="act" data-act></div>`;
|
||
seatsEl.appendChild(el);
|
||
});
|
||
|
||
const boardEl=document.getElementById('board'), potEl=document.getElementById('pot'),
|
||
streetEl=document.getElementById('street'), nowEl=document.getElementById('now'),
|
||
logEl=document.getElementById('log'), steplab=document.getElementById('steplab');
|
||
|
||
// build the log
|
||
logEl.innerHTML = acts.map((a,idx)=>{
|
||
if(a.board) return `<div class="ln brd" data-i="${idx}"><span class="st">${esc(a.street)}</span>${cards(a.board,true)}</div>`;
|
||
const amt = a.amount!=null ? ' '+a.amount : '';
|
||
return `<div class="ln" data-i="${idx}"><span class="st">${esc(a.street||'')}</span>${esc(a.pos||'')} ${esc(a.action||'')}${amt}</div>`;
|
||
}).join('');
|
||
|
||
const cap = s => s ? s[0].toUpperCase()+s.slice(1) : s;
|
||
const fmt = n => Number.isInteger(n) ? n : Math.round(n*100)/100;
|
||
|
||
function draw(){
|
||
let board = [], street = 'Preflop';
|
||
const lastAct = {}, folded = {};
|
||
// street-aware chip accounting: amounts are "to" totals for the street
|
||
const contrib = {}; // committed in prior (flushed) streets
|
||
let streetCommit = {}, streetBet = 0, curStreet = 'preflop';
|
||
const flushStreet = () => { for(const p in streetCommit){ contrib[p]=(contrib[p]||0)+streetCommit[p]; } streetCommit={}; streetBet=0; };
|
||
for(let i=0;i<step;i++){
|
||
const a = acts[i];
|
||
if(a.board){ flushStreet(); curStreet=a.street; board=a.board; street=cap(a.street); continue; }
|
||
if(a.street && a.street!==curStreet){ flushStreet(); curStreet=a.street; }
|
||
if(a.street) street = cap(a.street);
|
||
const pos=a.pos, amt=(a.amount!=null?Number(a.amount):null);
|
||
if(pos){
|
||
switch(a.action){
|
||
case 'post': case 'bet': streetCommit[pos]=amt||0; streetBet=Math.max(streetBet, amt||0); break;
|
||
case 'raise': case 'allin': streetCommit[pos]=(amt!=null?amt:streetBet); streetBet=Math.max(streetBet, streetCommit[pos]); break;
|
||
case 'call': streetCommit[pos]=(amt!=null?amt:streetBet); break;
|
||
case 'fold': folded[pos]=true; break;
|
||
}
|
||
lastAct[pos]=(a.action||'')+(amt!=null?' '+amt:'');
|
||
}
|
||
}
|
||
// committed total per player (flushed streets + current street), pot = sum
|
||
const committed={}, allPos=new Set([...Object.keys(contrib),...Object.keys(streetCommit)]);
|
||
let pot=0;
|
||
allPos.forEach(p=>{ committed[p]=(contrib[p]||0)+(streetCommit[p]||0); pot+=committed[p]; });
|
||
boardEl.innerHTML = cards(board);
|
||
potEl.textContent = pot ? ('Pot '+fmt(pot)) : '';
|
||
streetEl.textContent = street;
|
||
document.querySelectorAll('.seat').forEach(s=>{
|
||
const pos=s.dataset.pos;
|
||
s.querySelector('[data-act]').textContent = lastAct[pos]||'';
|
||
s.classList.toggle('folded', !!folded[pos]);
|
||
s.classList.remove('acting');
|
||
const stEl=s.querySelector('[data-stack]'), start=starts[pos], c=committed[pos]||0;
|
||
if(start!=null){ const rem=start-c; stEl.textContent = rem<=0 ? 'all in' : fmt(rem); }
|
||
else { stEl.textContent = c ? '−'+fmt(c) : ''; }
|
||
});
|
||
const cur = acts[step-1];
|
||
if(cur && cur.pos){
|
||
const s = [...document.querySelectorAll('.seat')].find(x=>x.dataset.pos===cur.pos);
|
||
if(s) s.classList.add('acting');
|
||
}
|
||
nowEl.innerHTML = step===0 ? 'Cards dealt — preflop.'
|
||
: (cur.board ? `${cur.street[0].toUpperCase()+cur.street.slice(1)}: ${cards(cur.board,true)}`
|
||
: `${esc(cur.pos||'')} ${esc(cur.action||'')}${cur.amount!=null?' '+cur.amount:''}`);
|
||
steplab.textContent = `${step} / ${acts.length}`;
|
||
document.getElementById('prev').disabled = step===0;
|
||
document.getElementById('next').disabled = step>=acts.length;
|
||
logEl.querySelectorAll('.ln').forEach(l=>l.classList.toggle('cur', Number(l.dataset.i)===step-1));
|
||
const curln = logEl.querySelector('.ln.cur'); if(curln) curln.scrollIntoView({block:'nearest'});
|
||
}
|
||
document.getElementById('prev').onclick=()=>{if(step>0){step--;draw();}};
|
||
document.getElementById('next').onclick=()=>{if(step<acts.length){step++;draw();}};
|
||
document.getElementById('all').onclick=()=>{step=acts.length;draw();};
|
||
document.addEventListener('keydown',e=>{
|
||
if(e.key==='ArrowRight'){if(step<acts.length){step++;draw();}}
|
||
if(e.key==='ArrowLeft'){if(step>0){step--;draw();}}
|
||
});
|
||
logEl.querySelectorAll('.ln').forEach(l=>l.onclick=()=>{step=Number(l.dataset.i)+1;draw();});
|
||
draw();
|
||
}
|
||
|
||
async function load(){
|
||
const id = location.pathname.split('/')[2];
|
||
try{
|
||
const r = await fetch(`/hand/${id}/data`,{cache:'no-store'});
|
||
const h = await r.json();
|
||
if(!h || !h.id){ document.getElementById('root').innerHTML='<p class="err">Hand not found.</p>'; return; }
|
||
render(h);
|
||
}catch(e){ document.getElementById('root').innerHTML='<p class="err">Couldn\'t load the hand.</p>'; }
|
||
}
|
||
load();
|
||
</script>
|
||
</body>
|
||
</html>
|