Files
3pmonitor/static/index.html
2026-02-22 20:41:47 +05:00

1654 lines
58 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Teen Patti Live Monitor</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #232736;
--surface3: #2a2e42;
--border: #2d3148;
--text: #e4e6f0;
--text2: #8b8fa3;
--text3: #5a5f75;
--accent: #6c5ce7;
--chair-a: #3b82f6;
--chair-b: #ec4899;
--chair-c: #f59e0b;
--green: #10b981;
--red: #ef4444;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
overflow-x: hidden;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 20px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.header h1 { font-size: 15px; font-weight: 700; letter-spacing: -0.3px; }
.header .round-info {
font-size: 13px; color: var(--text2);
display: flex; align-items: center; gap: 8px;
}
.header .status {
display: flex; align-items: center; gap: 6px; font-size: 12px;
}
.status-dot {
width: 7px; height: 7px; border-radius: 50%; background: var(--red);
}
.status-dot.connected { background: var(--green); }
.phase-badge {
padding: 2px 8px; border-radius: 4px; font-size: 11px;
font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px;
}
.phase-BETTING { background: #10b98125; color: var(--green); }
.phase-REVEALING { background: #f59e0b25; color: var(--chair-c); }
.phase-ENDED { background: #ef444425; color: var(--red); }
.phase-NEW { background: #6c5ce725; color: var(--accent); }
/* Grid */
.grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 1px;
background: var(--border);
height: calc(100vh - 45px);
}
.panel {
background: var(--surface);
padding: 14px;
overflow-y: auto;
}
.panel-title {
font-size: 10px; text-transform: uppercase; letter-spacing: 1.2px;
color: var(--text2); margin-bottom: 10px; font-weight: 700;
}
/* Timer */
.timer {
font-size: 28px; font-weight: 800; text-align: center; padding: 4px;
color: var(--text3); font-variant-numeric: tabular-nums;
}
.timer.active { color: var(--green); }
.total-pot {
text-align: center; font-size: 12px; color: var(--text2); margin-bottom: 10px;
}
.total-pot span { color: var(--text); font-weight: 700; font-size: 15px; }
/* Chairs */
.chairs { display: flex; gap: 6px; margin-bottom: 14px; }
.chair {
flex: 1; border-radius: 8px; padding: 10px 8px; text-align: center;
border: 2px solid transparent; transition: all 0.3s;
}
.chair-A { background: #3b82f610; border-color: #3b82f630; }
.chair-B { background: #ec489910; border-color: #ec489930; }
.chair-C { background: #f59e0b10; border-color: #f59e0b30; }
.chair.winner {
box-shadow: 0 0 24px rgba(16,185,129,0.3);
border-color: var(--green) !important;
}
.chair-label { font-size: 12px; font-weight: 800; }
.chair-A .chair-label { color: var(--chair-a); }
.chair-B .chair-label { color: var(--chair-b); }
.chair-C .chair-label { color: var(--chair-c); }
.chair-bet { font-size: 18px; font-weight: 800; margin: 6px 0; font-variant-numeric: tabular-nums; }
.chair-bar {
height: 3px; border-radius: 2px; background: var(--border);
margin-top: 6px; overflow: hidden;
}
.chair-bar-fill { height: 100%; border-radius: 2px; transition: width 0.5s ease; }
.chair-A .chair-bar-fill { background: var(--chair-a); }
.chair-B .chair-bar-fill { background: var(--chair-b); }
.chair-C .chair-bar-fill { background: var(--chair-c); }
.chair-rank {
font-size: 9px; font-weight: 700; letter-spacing: 0.3px;
text-transform: uppercase; margin-top: 4px;
padding: 1px 6px; border-radius: 3px; display: inline-block;
}
.chair-rank-high { background: #ef444425; color: #f87171; }
.chair-rank-mid { background: #f59e0b25; color: #fbbf24; }
.chair-rank-low { background: #10b98125; color: #34d399; }
.chair.rank-high { background: #ef444412; border-color: #ef444450; }
.chair.rank-mid { background: #f59e0b12; border-color: #f59e0b50; }
.chair.rank-low { background: #10b98112; border-color: #10b98150; }
.chair-predict {
font-size: 8px; font-weight: 600; letter-spacing: 0.3px;
text-transform: uppercase; margin-top: 2px;
color: var(--text3); opacity: 0.8;
}
.chair-predict span {
padding: 0px 4px; border-radius: 2px;
font-weight: 700;
}
.predict-high { background: #ef444418; color: #f87171; }
.predict-mid { background: #f59e0b18; color: #fbbf24; }
.predict-low { background: #10b98118; color: #34d399; }
.predict-same { color: var(--text3); }
.predict-change { color: #a78bfa; }
/* Cards */
.cards-section { margin-bottom: 14px; }
.hand-row {
display: flex; align-items: center; gap: 6px;
margin-bottom: 5px; padding: 6px 10px; border-radius: 6px;
transition: background 0.3s;
}
.hand-row.winner-hand {
background: linear-gradient(135deg, #10b98118, #10b98108);
border: 1px solid #10b98130;
}
.hand-row.loser-hand { opacity: 0.5; }
.hand-label { font-weight: 800; width: 18px; font-size: 13px; }
.hand-cards { font-size: 17px; letter-spacing: 1px; font-family: 'SF Mono', Consolas, monospace; }
.hand-type { font-size: 10px; color: var(--text2); margin-left: auto; text-transform: uppercase; letter-spacing: 0.5px; }
.hand-type.winning-type { color: var(--green); font-weight: 700; }
.card-red { color: #ef4444; }
.card-white { color: #e4e6f0; }
.winner-badge {
font-size: 9px; background: var(--green); color: #fff;
padding: 2px 6px; border-radius: 3px; font-weight: 700;
letter-spacing: 0.5px; animation: glow 2s ease-in-out infinite;
}
@keyframes glow {
0%, 100% { box-shadow: 0 0 4px #10b98140; }
50% { box-shadow: 0 0 12px #10b98180; }
}
.win-reason {
font-size: 10px; color: var(--green); padding: 4px 10px 2px 34px;
opacity: 0.8;
}
/* Biggest Winner */
.biggest-winner {
background: linear-gradient(135deg, #f59e0b12, #f59e0b05);
border: 1px solid #f59e0b30; border-radius: 8px;
padding: 10px 12px; margin-bottom: 14px;
display: flex; align-items: center; gap: 10px;
cursor: pointer; transition: border-color 0.2s;
}
.biggest-winner:hover { border-color: #f59e0b60; }
.bw-crown { font-size: 20px; }
.bw-info { flex: 1; min-width: 0; }
.bw-name {
font-size: 13px; font-weight: 700; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis;
}
.bw-stats { font-size: 11px; color: var(--text2); margin-top: 2px; }
.bw-pnl { font-size: 16px; font-weight: 800; color: var(--green); }
/* Live Bets Feed */
.bets-feed { max-height: 220px; overflow-y: auto; font-size: 12px; }
.bet-entry {
display: flex; align-items: center; gap: 5px;
padding: 4px 0; border-bottom: 1px solid #2d314820;
animation: fadeIn 0.3s;
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; } }
.bet-user {
color: var(--text); font-weight: 600; max-width: 130px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
cursor: pointer; transition: color 0.2s;
}
.bet-user:hover { color: var(--accent); text-decoration: underline; }
.bet-arrow { color: var(--text3); font-size: 10px; }
.bet-chair { font-weight: 800; font-size: 13px; }
.bet-chair-A { color: var(--chair-a); }
.bet-chair-B { color: var(--chair-b); }
.bet-chair-C { color: var(--chair-c); }
.bet-amount { margin-left: auto; font-weight: 700; color: var(--chair-c); font-variant-numeric: tabular-nums; }
.bet-session {
font-size: 10px; color: var(--text3); min-width: 50px; text-align: right;
}
/* Round Top Bettors */
.top-bettors { margin-bottom: 14px; }
.tb-row {
display: flex; align-items: center; gap: 5px;
padding: 4px 6px; font-size: 12px;
border-bottom: 1px solid #2d314815;
cursor: pointer; transition: background 0.15s;
}
.tb-row:hover { background: var(--surface2); }
.tb-rank { width: 16px; color: var(--text3); font-weight: 700; font-size: 10px; }
.tb-name {
flex: 1; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; font-weight: 600;
}
.tb-chairs {
display: flex; gap: 3px; align-items: center;
}
.tb-chip {
font-size: 10px; font-weight: 700; padding: 1px 5px;
border-radius: 3px; font-variant-numeric: tabular-nums;
}
.tb-chip-A { background: #3b82f620; color: var(--chair-a); }
.tb-chip-B { background: #ec489920; color: var(--chair-b); }
.tb-chip-C { background: #f59e0b20; color: var(--chair-c); }
.tb-total {
font-weight: 800; min-width: 55px; text-align: right;
font-variant-numeric: tabular-nums; font-size: 12px;
}
/* Whale Trend */
.whale-trend { margin-bottom: 14px; }
.whale-trend-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 0; font-size: 13px;
}
.whale-trend-chair {
font-weight: 900; font-size: 16px; min-width: 22px;
text-align: center;
}
.whale-trend-bar-bg {
flex: 1; height: 20px; border-radius: 4px;
background: var(--surface3); overflow: hidden; position: relative;
}
.whale-trend-bar-fill {
height: 100%; border-radius: 4px;
transition: width 0.4s ease;
min-width: 2px;
}
.whale-trend-bar-fill.chair-A { background: linear-gradient(90deg, #3b82f680, #3b82f6); }
.whale-trend-bar-fill.chair-B { background: linear-gradient(90deg, #ec489980, #ec4899); }
.whale-trend-bar-fill.chair-C { background: linear-gradient(90deg, #f59e0b80, #f59e0b); }
.whale-trend-pct {
font-weight: 800; min-width: 42px; text-align: right;
font-variant-numeric: tabular-nums; font-size: 12px;
}
.whale-trend-amt {
font-size: 10px; color: var(--text3); min-width: 50px;
text-align: right; font-variant-numeric: tabular-nums;
}
.whale-trend-badge {
font-size: 9px; font-weight: 700; padding: 1px 5px;
border-radius: 3px; letter-spacing: 0.3px;
}
.whale-trend-badge-1 { background: #fbbf2430; color: #fbbf24; }
.whale-trend-badge-2 { background: #94a3b830; color: #94a3b8; }
.whale-note {
font-size: 10px; color: var(--text3); margin-top: 4px;
font-style: italic;
}
/* Hot/Cold Tracker */
.hc-section { margin-bottom: 10px; }
.hc-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.hc-panel {
background: var(--surface2); border-radius: 6px;
padding: 8px; border: 1px solid var(--border);
}
.hc-panel-title {
font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px;
font-weight: 700; margin-bottom: 6px; display: flex; align-items: center; gap: 4px;
}
.hc-panel-title.hot { color: var(--green); }
.hc-panel-title.cold { color: var(--red); }
.hc-player {
display: flex; align-items: center; gap: 4px;
padding: 3px 0; font-size: 11px;
border-bottom: 1px solid #2d314815;
cursor: pointer; transition: opacity 0.15s;
}
.hc-player:hover { opacity: 0.8; }
.hc-player:last-child { border-bottom: none; }
.hc-name {
flex: 1; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; font-weight: 600; font-size: 11px;
}
.hc-pnl {
font-size: 10px; font-weight: 700; min-width: 40px; text-align: right;
font-variant-numeric: tabular-nums;
}
.hc-pnl.positive { color: var(--green); }
.hc-pnl.negative { color: var(--red); }
.hc-lean {
display: flex; gap: 2px; align-items: center;
}
.hc-dot {
width: 7px; height: 7px; border-radius: 50%;
display: inline-block;
}
.hc-dot-A { background: var(--chair-a); }
.hc-dot-B { background: var(--chair-b); }
.hc-dot-C { background: var(--chair-c); }
.hc-lean-chip {
font-size: 9px; font-weight: 700; padding: 1px 4px;
border-radius: 3px; font-variant-numeric: tabular-nums;
}
.hc-lean-chip-A { background: #3b82f620; color: var(--chair-a); }
.hc-lean-chip-B { background: #ec489920; color: var(--chair-b); }
.hc-lean-chip-C { background: #f59e0b20; color: var(--chair-c); }
.hc-idle { font-size: 10px; color: var(--text3); font-style: italic; }
.hc-wr { font-size: 9px; color: var(--text3); min-width: 22px; text-align: center; }
.hc-trend-summary {
margin-top: 6px; padding-top: 6px;
border-top: 1px solid var(--border);
}
.hc-trend-label {
font-size: 9px; text-transform: uppercase; letter-spacing: 0.4px;
color: var(--text3); font-weight: 700; margin-bottom: 4px;
}
.hc-trend-row {
display: flex; align-items: center; gap: 4px;
padding: 2px 0; font-size: 11px;
}
.hc-trend-chair {
font-weight: 900; font-size: 13px; min-width: 14px; text-align: center;
}
.hc-trend-bar-bg {
flex: 1; height: 14px; border-radius: 3px;
background: var(--surface3); overflow: hidden;
}
.hc-trend-bar-fill {
height: 100%; border-radius: 3px;
transition: width 0.4s ease; min-width: 2px;
}
.hc-trend-bar-fill.chair-A { background: linear-gradient(90deg, #3b82f660, #3b82f6); }
.hc-trend-bar-fill.chair-B { background: linear-gradient(90deg, #ec489960, #ec4899); }
.hc-trend-bar-fill.chair-C { background: linear-gradient(90deg, #f59e0b60, #f59e0b); }
.hc-trend-pct {
font-weight: 700; min-width: 30px; text-align: right;
font-variant-numeric: tabular-nums; font-size: 10px;
}
.hc-trend-note {
font-size: 9px; color: var(--text3); margin-top: 2px;
font-style: italic;
}
/* Leaderboard */
.leaderboard { margin-top: 14px; }
.lb-row {
display: flex; align-items: center; gap: 6px;
padding: 5px 4px; font-size: 12px;
border-bottom: 1px solid var(--border);
cursor: pointer; transition: background 0.15s;
}
.lb-row:hover { background: var(--surface2); }
.lb-rank { width: 18px; color: var(--text3); font-weight: 700; font-size: 11px; }
.lb-name {
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.lb-stats { font-size: 10px; color: var(--text3); min-width: 40px; text-align: center; }
.lb-pnl { font-weight: 800; min-width: 65px; text-align: right; font-variant-numeric: tabular-nums; }
.lb-pnl.positive { color: var(--green); }
.lb-pnl.negative { color: var(--red); }
/* Chart */
.chart-container { position: relative; height: 120px; margin-bottom: 10px; }
/* History Table */
.history-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.history-table th {
text-align: left; padding: 5px 6px;
border-bottom: 2px solid var(--border);
color: var(--text3); font-size: 10px;
text-transform: uppercase; letter-spacing: 0.5px; font-weight: 700;
}
.history-table td { padding: 4px 6px; border-bottom: 1px solid #2d314830; }
.history-table tr:hover { background: var(--surface2); }
.winner-cell { font-weight: 800; }
.winner-A { color: var(--chair-a); }
.winner-B { color: var(--chair-b); }
.winner-C { color: var(--chair-c); }
.history-hand { font-family: 'SF Mono', Consolas, monospace; font-size: 11px; white-space: nowrap; }
.history-hand.winning-hand { font-weight: 700; }
.history-hand.losing-hand { opacity: 0.4; }
.history-pot { font-size: 9px; color: var(--text3); font-variant-numeric: tabular-nums; margin-right: 3px; }
.hand-tag {
font-size: 9px; padding: 1px 4px; border-radius: 3px;
margin-left: 4px; font-weight: 600; display: inline-block;
vertical-align: middle;
}
.hand-tag-win { background: #10b98120; color: var(--green); }
.hand-tag-type { background: var(--surface3); color: var(--text2); }
/* Bet rank badges */
.bet-rank {
font-size: 9px; padding: 2px 6px; border-radius: 3px;
font-weight: 700; letter-spacing: 0.3px; text-transform: uppercase;
}
.bet-rank-low { background: #10b98125; color: #34d399; }
.bet-rank-mid { background: #f59e0b25; color: #fbbf24; }
.bet-rank-high { background: #ef444425; color: #f87171; }
/* Win distribution */
.dist-row { display: flex; gap: 8px; margin-top: 8px; margin-bottom: 14px; }
.dist-item {
flex: 1; text-align: center; padding: 8px 4px;
border-radius: 6px; background: var(--surface2);
}
.dist-label { font-size: 10px; color: var(--text3); font-weight: 600; }
.dist-value { font-size: 20px; font-weight: 800; margin-top: 3px; }
.dist-pct { font-size: 10px; color: var(--text3); }
/* User Profile Modal */
.modal-overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.7); z-index: 100;
justify-content: center; align-items: center;
}
.modal-overlay.open { display: flex; }
.modal {
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; padding: 20px; width: 420px; max-width: 95vw;
max-height: 85vh; overflow-y: auto;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.modal-close {
float: right; background: none; border: none; color: var(--text2);
font-size: 20px; cursor: pointer; padding: 0 4px;
}
.modal-close:hover { color: var(--text); }
.modal-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.modal-avatar {
width: 48px; height: 48px; border-radius: 50%;
background: var(--surface2); object-fit: cover;
}
.modal-name { font-size: 16px; font-weight: 700; }
.modal-uid { font-size: 11px; color: var(--text3); }
.modal-badges { display: flex; gap: 6px; margin-top: 4px; }
.modal-badge {
font-size: 10px; padding: 2px 6px; border-radius: 3px;
font-weight: 600;
}
.badge-rich { background: #f59e0b25; color: var(--chair-c); }
.badge-actor { background: #ec489925; color: var(--chair-b); }
.badge-gender { background: var(--surface3); color: var(--text2); }
.modal-stats {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
margin-bottom: 16px;
}
.modal-stat {
background: var(--surface2); border-radius: 6px;
padding: 8px; text-align: center;
}
.modal-stat-label { font-size: 9px; color: var(--text3); text-transform: uppercase; letter-spacing: 0.5px; }
.modal-stat-value { font-size: 16px; font-weight: 800; margin-top: 2px; }
.modal-stat-value.positive { color: var(--green); }
.modal-stat-value.negative { color: var(--red); }
.modal-section-title {
font-size: 10px; text-transform: uppercase; letter-spacing: 1px;
color: var(--text3); margin: 12px 0 6px; font-weight: 700;
}
.modal-spend {
display: flex; gap: 12px; margin-bottom: 12px; font-size: 12px;
}
.modal-spend-item { color: var(--text2); }
.modal-spend-item span { color: var(--text); font-weight: 600; }
.modal-bets-table { width: 100%; border-collapse: collapse; font-size: 11px; }
.modal-bets-table th {
text-align: left; padding: 4px 6px; color: var(--text3);
font-size: 9px; text-transform: uppercase; border-bottom: 1px solid var(--border);
}
.modal-bets-table td { padding: 3px 6px; border-bottom: 1px solid #2d314830; }
.bet-won { color: var(--green); }
.bet-lost { color: var(--red); }
/* ── Mobile ── */
@media (max-width: 768px) {
.header {
flex-wrap: wrap; gap: 6px; padding: 8px 12px;
}
.header h1 { font-size: 13px; order: 1; }
.header .round-info { font-size: 11px; order: 2; }
.header .status { order: 3; width: 100%; justify-content: flex-end; }
.grid {
grid-template-columns: 1fr;
height: auto;
min-height: 100vh;
}
.panel {
padding: 10px;
overflow-y: visible;
}
.timer { font-size: 22px; }
.total-pot { font-size: 11px; }
.total-pot span { font-size: 13px; }
.chairs { gap: 4px; }
.chair { padding: 8px 4px; border-radius: 6px; }
.chair-bet { font-size: 14px; }
.chair-label { font-size: 11px; }
.chair-rank { font-size: 8px; }
.chair-predict { font-size: 7px; }
.top-bettors .tb-row { padding: 3px 4px; font-size: 11px; }
.tb-name { font-size: 11px; }
.tb-chip { font-size: 9px; padding: 1px 3px; }
.tb-total { font-size: 11px; min-width: 45px; }
.whale-trend-row { gap: 4px; font-size: 11px; }
.whale-trend-chair { font-size: 13px; min-width: 18px; }
.whale-trend-bar-bg { height: 16px; }
.whale-trend-pct { font-size: 11px; min-width: 34px; }
.whale-trend-amt { font-size: 9px; min-width: 40px; }
.bets-feed { max-height: 200px; font-size: 11px; }
.bet-user { max-width: 80px; font-size: 11px; }
.bet-amount { font-size: 11px; }
.bet-session { font-size: 9px; min-width: 40px; }
.lb-row { padding: 4px 2px; font-size: 11px; }
.lb-pnl { min-width: 50px; font-size: 11px; }
.hc-grid { grid-template-columns: 1fr; gap: 6px; }
.hc-player { font-size: 10px; }
.hc-name { font-size: 10px; }
.chart-container { height: 100px; }
.dist-row { gap: 4px; }
.dist-item { padding: 6px 2px; }
.dist-value { font-size: 16px; }
.dist-label { font-size: 9px; }
.dist-pct { font-size: 9px; }
.history-table { font-size: 10px; }
.history-table th, .history-table td { padding: 3px 3px; }
.hand-cards { font-size: 13px; }
.hand-tag { font-size: 8px; padding: 0px 3px; }
.history-hand { font-size: 10px; }
.history-pot { font-size: 8px; }
.biggest-winner { padding: 8px 10px; gap: 8px; }
.bw-crown { font-size: 16px; }
.bw-name { font-size: 12px; }
.bw-stats { font-size: 10px; }
.bw-pnl { font-size: 14px; }
.modal { width: 95vw; max-height: 90vh; padding: 14px; border-radius: 8px; }
.modal-stats { grid-template-columns: repeat(2, 1fr); gap: 6px; }
.modal-stat { padding: 6px; }
.modal-stat-value { font-size: 14px; }
.modal-header { gap: 8px; }
.modal-name { font-size: 14px; }
.modal-avatar { width: 40px; height: 40px; }
.modal-bets-table { font-size: 10px; }
.panel-title { font-size: 9px; margin-bottom: 8px; }
}
</style>
</head>
<body>
<div class="header">
<h1>Teen Patti Live Monitor</h1>
<div class="round-info">
Round <span id="round-no">&mdash;</span>
<span id="phase-badge" class="phase-badge phase-NEW">&mdash;</span>
</div>
<div class="status">
<a href="/analytics" style="color:var(--accent);text-decoration:none;font-size:12px;font-weight:600;margin-right:10px">Analytics &rarr;</a>
<div id="status-dot" class="status-dot"></div>
<span id="status-text">Connecting...</span>
</div>
</div>
<div class="grid">
<!-- Left Panel -->
<div class="panel">
<div class="panel-title">Current Game</div>
<div id="timer" class="timer">&mdash;</div>
<div class="total-pot">Total Pot: <span id="total-pot">0</span></div>
<div class="chairs">
<div class="chair chair-A" id="chair-a">
<div class="chair-label">A</div>
<div class="chair-bet" id="bet-a">0</div>
<div class="chair-bar"><div class="chair-bar-fill" id="bar-a" style="width:0%"></div></div>
<div id="rank-a"></div>
<div class="chair-predict" id="predict-a"></div>
</div>
<div class="chair chair-B" id="chair-b">
<div class="chair-label">B</div>
<div class="chair-bet" id="bet-b">0</div>
<div class="chair-bar"><div class="chair-bar-fill" id="bar-b" style="width:0%"></div></div>
<div id="rank-b"></div>
<div class="chair-predict" id="predict-b"></div>
</div>
<div class="chair chair-C" id="chair-c">
<div class="chair-label">C</div>
<div class="chair-bet" id="bet-c">0</div>
<div class="chair-bar"><div class="chair-bar-fill" id="bar-c" style="width:0%"></div></div>
<div id="rank-c"></div>
<div class="chair-predict" id="predict-c"></div>
</div>
</div>
<div class="top-bettors" id="top-bettors-section" style="display:none">
<div class="panel-title">Round Top Bettors</div>
<div id="top-bettors"></div>
</div>
<div class="whale-trend" id="whale-trend-section" style="display:none">
<div class="panel-title">Whale Trend (Top 5 Bettors)</div>
<div id="whale-trend"></div>
<div class="whale-note" id="whale-note"></div>
</div>
<div class="whale-trend" id="majority-trend-section" style="display:none">
<div class="panel-title">Majority Trend (Total Pool)</div>
<div id="majority-trend"></div>
</div>
<div class="cards-section" id="cards-section" style="display:none">
<div class="panel-title">Cards</div>
<div id="cards-display"></div>
</div>
<!-- Biggest Winner -->
<div id="biggest-winner-section" style="display:none">
<div class="panel-title">Session MVP</div>
<div class="biggest-winner" id="biggest-winner" onclick="showBiggestWinner()"></div>
</div>
<div class="panel-title" style="margin-top:10px">Live Bets</div>
<div class="bets-feed" id="bets-feed"></div>
<div class="leaderboard">
<div class="panel-title" style="margin-top:14px">Leaderboard (P&amp;L)</div>
<div id="leaderboard"></div>
</div>
</div>
<!-- Right Panel -->
<div class="panel">
<div class="panel-title">Bet Flow</div>
<div class="chart-container">
<canvas id="bet-chart"></canvas>
</div>
<div class="hc-section" id="hc-section" style="display:none">
<div class="panel-title">Smart Money Tracker (Last 10 Bets P&amp;L)</div>
<div class="hc-grid">
<div class="hc-panel">
<div class="hc-panel-title hot">&#9650; Hot Hands</div>
<div id="hc-hot"></div>
<div id="hc-hot-trend" class="hc-trend-summary"></div>
</div>
<div class="hc-panel">
<div class="hc-panel-title cold">&#9660; Cold Streak</div>
<div id="hc-cold"></div>
<div id="hc-cold-trend" class="hc-trend-summary"></div>
</div>
</div>
</div>
<div class="panel-title">Win Distribution</div>
<div class="dist-row">
<div class="dist-item">
<div class="dist-label" style="color:var(--chair-a)">Chair A</div>
<div class="dist-value" id="dist-a" style="color:var(--chair-a)">0</div>
<div class="dist-pct" id="dist-a-pct">&mdash;</div>
</div>
<div class="dist-item">
<div class="dist-label" style="color:var(--chair-b)">Chair B</div>
<div class="dist-value" id="dist-b" style="color:var(--chair-b)">0</div>
<div class="dist-pct" id="dist-b-pct">&mdash;</div>
</div>
<div class="dist-item">
<div class="dist-label" style="color:var(--chair-c)">Chair C</div>
<div class="dist-value" id="dist-c" style="color:var(--chair-c)">0</div>
<div class="dist-pct" id="dist-c-pct">&mdash;</div>
</div>
</div>
<div class="panel-title">Winner Bet Size</div>
<div class="dist-row">
<div class="dist-item">
<div class="dist-label" style="color:#34d399">Low Bet</div>
<div class="dist-value" id="dist-low" style="color:#34d399">0</div>
<div class="dist-pct" id="dist-low-pct">&mdash;</div>
</div>
<div class="dist-item">
<div class="dist-label" style="color:#fbbf24">Mid Bet</div>
<div class="dist-value" id="dist-mid" style="color:#fbbf24">0</div>
<div class="dist-pct" id="dist-mid-pct">&mdash;</div>
</div>
<div class="dist-item">
<div class="dist-label" style="color:#f87171">High Bet</div>
<div class="dist-value" id="dist-high" style="color:#f87171">0</div>
<div class="dist-pct" id="dist-high-pct">&mdash;</div>
</div>
</div>
<div class="panel-title">History</div>
<div style="max-height:400px;overflow-y:auto">
<table class="history-table">
<thead>
<tr>
<th>#</th>
<th>W</th>
<th>Pot</th>
<th>Bet</th>
<th>Hand A</th>
<th>Hand B</th>
<th>Hand C</th>
</tr>
</thead>
<tbody id="history-body"></tbody>
</table>
</div>
</div>
</div>
<!-- User Profile Modal -->
<div class="modal-overlay" id="modal-overlay" onclick="closeModal(event)">
<div class="modal" id="modal" onclick="event.stopPropagation()">
<button class="modal-close" onclick="closeModal()">&times;</button>
<div id="modal-content">Loading...</div>
</div>
</div>
<script>
const $ = id => document.getElementById(id);
const fmt = n => {
if (n === null || n === undefined) return '0';
const abs = Math.abs(n);
if (abs >= 1e6) return (n/1e6).toFixed(1) + 'M';
if (abs >= 1e3) return (n/1e3).toFixed(1) + 'K';
return String(n);
};
const fmtFull = n => {
if (n === null || n === undefined) return '0';
return Number(n).toLocaleString();
};
const CHAIRS = {1:'A', 2:'B', 3:'C'};
const CHAIR_COLORS = {A:'#3b82f6', B:'#ec4899', C:'#f59e0b'};
const HAND_TYPES = {1:'High Card', 2:'Pair', 3:'Flush', 4:'Straight', 5:'Str. Flush', 6:'Trail'};
const HAND_RANK = {1:1, 2:2, 3:3, 4:4, 5:5, 6:6};
let biggestWinnerData = null;
// Round-level per-user bet tracking: { [userId]: { nick_name, chairs: {A:0,B:0,C:0}, total:0 } }
let roundBettors = {};
let hotColdData = {hot: [], cold: []};
// ── WebSocket ──
let ws, reconnectDelay = 1000;
function connect() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen = () => {
$('status-dot').classList.add('connected');
$('status-text').textContent = 'Connected';
reconnectDelay = 1000;
};
ws.onclose = () => {
$('status-dot').classList.remove('connected');
$('status-text').textContent = 'Reconnecting...';
setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 1.5, 30000);
};
ws.onmessage = e => {
const msg = JSON.parse(e.data);
handleEvent(msg.type, msg.data);
};
}
function handleEvent(type, data) {
switch(type) {
case 'game_state': updateGameState(data); break;
case 'user_bet': addBetToFeed(data); break;
case 'round_result': onRoundResult(data); break;
case 'cards_revealed': showCards(data); break;
case 'history': renderHistory(data); break;
case 'win_distribution': updateWinDist(data); break;
case 'leaderboard': renderLeaderboard(data); break;
case 'biggest_winner': updateBiggestWinner(data); break;
case 'hot_cold': updateHotCold(data); break;
}
}
// ── Game State ──
let lastGameNo = null;
let betChartData = {A:[], B:[], C:[], labels:[]};
let betChartIdx = 0;
let betSnapshots = []; // [{ts, a, b, c}, ...] for prediction
function updateGameState(s) {
$('round-no').textContent = '#' + s.game_no;
const badge = $('phase-badge');
badge.textContent = s.status_name;
badge.className = 'phase-badge phase-' + s.status_name;
const timer = $('timer');
if (s.status === 1) {
timer.textContent = Math.ceil(s.remaining_s) + 's';
timer.classList.add('active');
} else {
timer.textContent = s.status_name;
timer.classList.remove('active');
}
if (s.game_no !== lastGameNo) {
lastGameNo = s.game_no;
resetForNewRound();
}
const a = s.bets.A || 0, b = s.bets.B || 0, c = s.bets.C || 0;
const total = a + b + c || 1;
$('bet-a').textContent = fmt(a);
$('bet-b').textContent = fmt(b);
$('bet-c').textContent = fmt(c);
$('total-pot').textContent = fmt(s.total_pot || total);
$('bar-a').style.width = (a/total*100) + '%';
$('bar-b').style.width = (b/total*100) + '%';
$('bar-c').style.width = (c/total*100) + '%';
// Chair rank labels
updateChairRanks(a, b, c);
// Majority trend
renderMajorityTrend(a, b, c);
// Prediction
if (s.status === 1 && s.remaining_s > 0) {
betSnapshots.push({ts: Date.now(), a, b, c});
// Keep last 10 snapshots for velocity calculation
if (betSnapshots.length > 10) betSnapshots.shift();
updatePrediction(a, b, c, s.remaining_s);
} else {
clearPrediction();
}
['a','b','c'].forEach(ch => $('chair-'+ch).classList.remove('winner'));
if (s.winner_name) {
const el = $('chair-' + s.winner_name.toLowerCase());
if (el) el.classList.add('winner');
}
if (s.cards && Object.keys(s.cards).length > 0) showCards(s);
if (s.status === 1) {
betChartIdx++;
betChartData.labels.push(betChartIdx);
betChartData.A.push(a);
betChartData.B.push(b);
betChartData.C.push(c);
updateBetChart();
}
}
function resetForNewRound() {
$('cards-section').style.display = 'none';
$('cards-display').innerHTML = '';
$('bets-feed').innerHTML = '';
['a','b','c'].forEach(ch => {
const el = $('chair-'+ch);
el.classList.remove('winner','rank-high','rank-mid','rank-low');
});
betChartData = {A:[], B:[], C:[], labels:[]};
betChartIdx = 0;
betSnapshots = [];
clearPrediction();
updateBetChart();
roundBettors = {};
$('top-bettors').innerHTML = '';
$('top-bettors-section').style.display = 'none';
$('whale-trend').innerHTML = '';
$('whale-trend-section').style.display = 'none';
$('majority-trend').innerHTML = '';
$('majority-trend-section').style.display = 'none';
['a','b','c'].forEach(ch => $('rank-'+ch).innerHTML = '');
}
// ── Cards with winning explanation ──
function colorCard(text) {
return text.replace(/([^\s]+)/g, m => {
if (m.includes('\u2665') || m.includes('\u2666'))
return `<span class="card-red">${m}</span>`;
return `<span class="card-white">${m}</span>`;
});
}
function getWinReason(cards, winnerName) {
// Build hand info
const hands = {};
for (const ch of ['A','B','C']) {
const c = cards[ch];
if (!c) continue;
hands[ch] = { type: c.hand_type, typeId: c.hand_type_id || 0, hand: c.hand };
}
if (!hands[winnerName]) return '';
const winner = hands[winnerName];
const losers = Object.entries(hands).filter(([k]) => k !== winnerName);
// Check if winner has strictly higher hand type
const allSameType = losers.every(([,v]) => v.typeId === winner.typeId);
if (!allSameType) {
// Winner has better hand type
const beaten = losers
.filter(([,v]) => v.typeId < winner.typeId)
.map(([k,v]) => `${k}'s ${v.type}`);
if (beaten.length > 0) {
return `${winner.type} beats ${beaten.join(' & ')}`;
}
}
// Same type — higher cards win
if (allSameType) {
return `Highest ${winner.type} by card values`;
}
return `${winner.type} wins`;
}
function showCards(data) {
const sec = $('cards-section');
sec.style.display = 'block';
const disp = $('cards-display');
disp.innerHTML = '';
const winnerName = data.winner_name || CHAIRS[data.winner];
for (const ch of ['A','B','C']) {
const c = data.cards[ch];
if (!c) continue;
const isWinner = winnerName === ch;
const row = document.createElement('div');
row.className = 'hand-row' + (isWinner ? ' winner-hand' : ' loser-hand');
const typeClass = isWinner ? 'hand-type winning-type' : 'hand-type';
row.innerHTML = `
<span class="hand-label" style="color:${CHAIR_COLORS[ch]}">${ch}</span>
<span class="hand-cards">${colorCard(c.hand)}</span>
<span class="${typeClass}">${c.hand_type}</span>
${isWinner ? '<span class="winner-badge">WIN</span>' : ''}
`;
disp.appendChild(row);
}
// Win reason
const reason = getWinReason(data.cards, winnerName);
if (reason) {
const reasonEl = document.createElement('div');
reasonEl.className = 'win-reason';
reasonEl.textContent = reason;
disp.appendChild(reasonEl);
}
}
// ── Biggest Winner ──
function updateBiggestWinner(data) {
if (!data) return;
biggestWinnerData = data;
const el = $('biggest-winner');
const sec = $('biggest-winner-section');
sec.style.display = 'block';
const pnlSign = data.pnl >= 0 ? '+' : '';
el.innerHTML = `
<span class="bw-crown">&#x1F451;</span>
<div class="bw-info">
<div class="bw-name">${escHtml(data.nick_name)}</div>
<div class="bw-stats">${data.wins}/${data.total_bets} wins &middot; ${fmt(data.total_wagered)} wagered</div>
</div>
<div class="bw-pnl">${pnlSign}${fmt(data.pnl)}</div>
`;
}
function showBiggestWinner() {
if (biggestWinnerData) openUserModal(biggestWinnerData.user_id);
}
// ── Live Bets ──
function addBetToFeed(bet) {
const feed = $('bets-feed');
const el = document.createElement('div');
el.className = 'bet-entry';
el.innerHTML = `
<span class="bet-user" onclick="openUserModal(${bet.user_id})" title="Click for profile">${escHtml(bet.nick_name)}</span>
<span class="bet-arrow">&rarr;</span>
<span class="bet-chair bet-chair-${bet.chair_name}">${bet.chair_name}</span>
<span class="bet-amount">${fmt(bet.bet_amount)}</span>
<span class="bet-session">${fmt(bet.session_wagered || 0)}</span>
`;
feed.insertBefore(el, feed.firstChild);
while (feed.children.length > 60) feed.removeChild(feed.lastChild);
// Track round bettors
trackRoundBettor(bet);
}
function trackRoundBettor(bet) {
const uid = bet.user_id;
if (!roundBettors[uid]) {
roundBettors[uid] = {
user_id: uid,
nick_name: bet.nick_name,
chairs: {A: 0, B: 0, C: 0},
total: 0,
};
}
const rb = roundBettors[uid];
// Update nick if we got a real name
if (bet.nick_name && bet.nick_name !== String(uid)) {
rb.nick_name = bet.nick_name;
}
rb.chairs[bet.chair_name] = (rb.chairs[bet.chair_name] || 0) + bet.bet_amount;
rb.total += bet.bet_amount;
renderTopBettors();
renderWhaleTrend();
renderHotColdLeans();
}
function renderTopBettors() {
const el = $('top-bettors');
const sec = $('top-bettors-section');
const sorted = Object.values(roundBettors)
.sort((a, b) => b.total - a.total)
.slice(0, 8);
if (sorted.length === 0) {
sec.style.display = 'none';
return;
}
sec.style.display = 'block';
el.innerHTML = sorted.map((b, i) => {
const chips = ['A','B','C']
.filter(ch => b.chairs[ch] > 0)
.map(ch => `<span class="tb-chip tb-chip-${ch}">${ch} ${fmt(b.chairs[ch])}</span>`)
.join('');
return `
<div class="tb-row" onclick="openUserModal(${b.user_id})">
<span class="tb-rank">${i + 1}</span>
<span class="tb-name">${escHtml(b.nick_name)}</span>
<span class="tb-chairs">${chips}</span>
<span class="tb-total">${fmt(b.total)}</span>
</div>
`;
}).join('');
}
// ── Chair Rank Labels ──
function updateChairRanks(a, b, c) {
const chairEls = ['a','b','c'];
// Clear rank classes
chairEls.forEach(ch => {
const el = $('chair-'+ch);
el.classList.remove('rank-high','rank-mid','rank-low');
});
if (a + b + c === 0) {
chairEls.forEach(ch => $('rank-'+ch).innerHTML = '');
return;
}
const bets = [{ch:'a', v:a}, {ch:'b', v:b}, {ch:'c', v:c}];
const sorted = [...bets].sort((x, y) => x.v - y.v);
const ranks = {};
ranks[sorted[0].ch] = 'low';
ranks[sorted[2].ch] = 'high';
ranks[sorted[1].ch] = 'mid';
// Handle ties
if (sorted[0].v === sorted[1].v && sorted[1].v === sorted[2].v) {
ranks[sorted[0].ch] = ranks[sorted[1].ch] = ranks[sorted[2].ch] = 'mid';
} else if (sorted[0].v === sorted[1].v) {
ranks[sorted[0].ch] = ranks[sorted[1].ch] = 'low';
} else if (sorted[1].v === sorted[2].v) {
ranks[sorted[1].ch] = ranks[sorted[2].ch] = 'high';
}
for (const ch of chairEls) {
const r = ranks[ch];
$('rank-'+ch).innerHTML = `<span class="chair-rank chair-rank-${r}">${r}</span>`;
$('chair-'+ch).classList.add('rank-'+r);
}
}
// ── Bet Predictor ──
function updatePrediction(a, b, c, remainingS) {
// Need at least 3 snapshots (3 seconds of data)
if (betSnapshots.length < 3) {
['a','b','c'].forEach(ch => $('predict-'+ch).innerHTML = '');
return;
}
// Calculate velocity using linear regression over snapshots
const first = betSnapshots[0];
const last = betSnapshots[betSnapshots.length - 1];
const elapsed = (last.ts - first.ts) / 1000; // seconds
if (elapsed < 1) return;
const velA = (last.a - first.a) / elapsed;
const velB = (last.b - first.b) / elapsed;
const velC = (last.c - first.c) / elapsed;
// Project forward
const projA = Math.max(0, a + velA * remainingS);
const projB = Math.max(0, b + velB * remainingS);
const projC = Math.max(0, c + velC * remainingS);
// Rank projected
const proj = [{ch:'a', v:projA}, {ch:'b', v:projB}, {ch:'c', v:projC}];
const sorted = [...proj].sort((x, y) => x.v - y.v);
const predRanks = {};
predRanks[sorted[0].ch] = 'low';
predRanks[sorted[2].ch] = 'high';
predRanks[sorted[1].ch] = 'mid';
if (sorted[0].v === sorted[1].v && sorted[1].v === sorted[2].v) {
predRanks[sorted[0].ch] = predRanks[sorted[1].ch] = predRanks[sorted[2].ch] = 'mid';
} else if (sorted[0].v === sorted[1].v) {
predRanks[sorted[0].ch] = predRanks[sorted[1].ch] = 'low';
} else if (sorted[1].v === sorted[2].v) {
predRanks[sorted[1].ch] = predRanks[sorted[2].ch] = 'high';
}
// Current ranks for comparison
const cur = [{ch:'a', v:a}, {ch:'b', v:b}, {ch:'c', v:c}];
const curSorted = [...cur].sort((x, y) => x.v - y.v);
const curRanks = {};
curRanks[curSorted[0].ch] = 'low';
curRanks[curSorted[2].ch] = 'high';
curRanks[curSorted[1].ch] = 'mid';
for (const ch of ['a','b','c']) {
const pr = predRanks[ch];
const cr = curRanks[ch];
const changing = pr !== cr;
const arrow = changing ? '&rarr;' : '';
const cls = changing ? 'predict-change' : 'predict-same';
$('predict-'+ch).innerHTML = `<span class="${cls}">${arrow} <span class="predict-${pr}">${pr}</span></span>`;
}
}
function clearPrediction() {
['a','b','c'].forEach(ch => $('predict-'+ch).innerHTML = '');
}
// ── Majority Trend ──
function renderMajorityTrend(a, b, c) {
const sec = $('majority-trend-section');
const el = $('majority-trend');
const total = a + b + c;
if (total === 0) { sec.style.display = 'none'; return; }
sec.style.display = 'block';
const ranked = [
{chair: 'A', amount: a, pct: a / total * 100},
{chair: 'B', amount: b, pct: b / total * 100},
{chair: 'C', amount: c, pct: c / total * 100},
].sort((x, y) => y.amount - x.amount);
const chairColors = {A: 'var(--chair-a)', B: 'var(--chair-b)', C: 'var(--chair-c)'};
el.innerHTML = ranked.slice(0, 2).map((r, i) => `
<div class="whale-trend-row">
<span class="whale-trend-badge whale-trend-badge-${i+1}">${i === 0 ? '1ST' : '2ND'}</span>
<span class="whale-trend-chair" style="color:${chairColors[r.chair]}">${r.chair}</span>
<div class="whale-trend-bar-bg">
<div class="whale-trend-bar-fill chair-${r.chair}" style="width:${r.pct}%"></div>
</div>
<span class="whale-trend-pct" style="color:${chairColors[r.chair]}">${r.pct.toFixed(0)}%</span>
<span class="whale-trend-amt">${fmt(r.amount)}</span>
</div>
`).join('');
}
// ── Whale Trend ──
function renderWhaleTrend() {
const el = $('whale-trend');
const sec = $('whale-trend-section');
const note = $('whale-note');
const bettors = Object.values(roundBettors);
if (bettors.length < 2) {
sec.style.display = 'none';
return;
}
// Top 5 bettors by total bet this round
const whales = bettors.sort((a, b) => b.total - a.total).slice(0, 5);
const whaleCount = whales.length;
// Sum whale money per chair
const chairTotals = {A: 0, B: 0, C: 0};
for (const w of whales) {
chairTotals.A += w.chairs.A || 0;
chairTotals.B += w.chairs.B || 0;
chairTotals.C += w.chairs.C || 0;
}
const grandTotal = chairTotals.A + chairTotals.B + chairTotals.C;
if (grandTotal === 0) { sec.style.display = 'none'; return; }
// Sort chairs by whale money, take top 2
const ranked = ['A','B','C']
.map(ch => ({chair: ch, amount: chairTotals[ch], pct: chairTotals[ch] / grandTotal * 100}))
.sort((a, b) => b.amount - a.amount);
sec.style.display = 'block';
const chairColors = {A: 'var(--chair-a)', B: 'var(--chair-b)', C: 'var(--chair-c)'};
el.innerHTML = ranked.slice(0, 2).map((r, i) => `
<div class="whale-trend-row">
<span class="whale-trend-badge whale-trend-badge-${i+1}">${i === 0 ? '1ST' : '2ND'}</span>
<span class="whale-trend-chair" style="color:${chairColors[r.chair]}">${r.chair}</span>
<div class="whale-trend-bar-bg">
<div class="whale-trend-bar-fill chair-${r.chair}" style="width:${r.pct}%"></div>
</div>
<span class="whale-trend-pct" style="color:${chairColors[r.chair]}">${r.pct.toFixed(0)}%</span>
<span class="whale-trend-amt">${fmt(r.amount)}</span>
</div>
`).join('');
// Show which whales are split vs unanimous
const topChair = ranked[0].chair;
const whalesTowardTop = whales.filter(w => {
const maxCh = ['A','B','C'].reduce((a, b) => (w.chairs[a]||0) >= (w.chairs[b]||0) ? a : b);
return maxCh === topChair;
}).length;
if (ranked[0].pct >= 70) {
note.textContent = `${whalesTowardTop}/${whaleCount} whales heavy on ${topChair} (strong lean)`;
} else if (ranked[0].pct >= 50) {
note.textContent = `${whalesTowardTop}/${whaleCount} whales favor ${topChair}, split action on ${ranked[1].chair}`;
} else {
note.textContent = `Whales split across ${ranked[0].chair} & ${ranked[1].chair} — no clear consensus`;
}
}
// ── Hot/Cold Smart Money Tracker ──
function updateHotCold(data) {
hotColdData = data;
renderHotColdLeans();
}
function renderHotColdLeans() {
const sec = $('hc-section');
const hot = hotColdData.hot || [];
const cold = hotColdData.cold || [];
if (hot.length === 0 && cold.length === 0) {
sec.style.display = 'none';
return;
}
sec.style.display = 'block';
$('hc-hot').innerHTML = renderHcList(hot);
$('hc-cold').innerHTML = renderHcList(cold);
$('hc-hot-trend').innerHTML = renderHcTrend(hot, 'Hot hands');
$('hc-cold-trend').innerHTML = renderHcTrend(cold, 'Cold streak');
}
function renderHcList(players) {
if (players.length === 0) return '<div class="hc-idle">No data yet</div>';
return players.map(p => {
const rb = roundBettors[p.user_id];
const pnlClass = p.pnl >= 0 ? 'positive' : 'negative';
const pnlSign = p.pnl >= 0 ? '+' : '';
const wr = p.total_bets > 0 ? Math.round(p.wins / p.total_bets * 100) : 0;
let leanHtml;
if (rb && rb.total > 0) {
const chips = ['A','B','C']
.filter(ch => rb.chairs[ch] > 0)
.map(ch => `<span class="hc-lean-chip hc-lean-chip-${ch}">${ch}</span>`)
.join('');
leanHtml = `<span class="hc-lean">${chips}</span>`;
} else {
leanHtml = `<span class="hc-idle">--</span>`;
}
return `
<div class="hc-player" onclick="openUserModal(${p.user_id})">
<span class="hc-name">${escHtml(p.nick_name)}</span>
<span class="hc-wr">${wr}%</span>
<span class="hc-pnl ${pnlClass}">${pnlSign}${fmt(p.pnl)}</span>
${leanHtml}
</div>
`;
}).join('');
}
function renderHcTrend(players, label) {
const chairTotals = {A: 0, B: 0, C: 0};
let activePlayers = 0;
for (const p of players) {
const rb = roundBettors[p.user_id];
if (rb && rb.total > 0) {
chairTotals.A += rb.chairs.A || 0;
chairTotals.B += rb.chairs.B || 0;
chairTotals.C += rb.chairs.C || 0;
activePlayers++;
}
}
const total = chairTotals.A + chairTotals.B + chairTotals.C;
if (total === 0) return `<div class="hc-trend-note">Not betting this round</div>`;
const ranked = ['A','B','C']
.map(ch => ({chair: ch, amount: chairTotals[ch], pct: chairTotals[ch] / total * 100}))
.sort((a, b) => b.amount - a.amount);
const chairColors = {A: 'var(--chair-a)', B: 'var(--chair-b)', C: 'var(--chair-c)'};
const bars = ranked.slice(0, 2).map(r => `
<div class="hc-trend-row">
<span class="hc-trend-chair" style="color:${chairColors[r.chair]}">${r.chair}</span>
<div class="hc-trend-bar-bg">
<div class="hc-trend-bar-fill chair-${r.chair}" style="width:${r.pct}%"></div>
</div>
<span class="hc-trend-pct" style="color:${chairColors[r.chair]}">${r.pct.toFixed(0)}%</span>
</div>
`).join('');
return `
<div class="hc-trend-label">${activePlayers}/${players.length} betting now</div>
${bars}
`;
}
// ── Round Result ──
function onRoundResult(data) {
addHistoryRow(data);
// Stats, leaderboard, hot/cold are pushed over WS by the server
}
// ── Bet rank helper ──
function getBetRank(betA, betB, betC, winnerChair) {
const bets = [betA || 0, betB || 0, betC || 0];
const winIdx = winnerChair === 'A' ? 0 : winnerChair === 'B' ? 1 : 2;
const winBet = bets[winIdx];
if (winBet === 0) return null;
const sorted = [...bets].sort((a, b) => a - b);
if (winBet <= sorted[0]) return 'low';
if (winBet >= sorted[2]) return 'high';
return 'mid';
}
function betRankCell(rank) {
if (!rank) return '<td>&mdash;</td>';
return `<td><span class="bet-rank bet-rank-${rank}">${rank}</span></td>`;
}
// ── History ──
function renderHistory(games) {
const body = $('history-body');
body.innerHTML = '';
for (const g of games) addHistoryRowFromGame(g);
}
function buildHandCell(handStr, handTypeId, isWinner, chairBet) {
if (!handStr) return '<td>&mdash;</td>';
const cls = isWinner ? 'history-hand winning-hand' : 'history-hand losing-hand';
const typeName = HAND_TYPES[handTypeId] || '';
let tag = '';
if (isWinner) {
tag = `<span class="hand-tag hand-tag-win">WIN</span>`;
} else if (handTypeId >= 2) {
tag = `<span class="hand-tag hand-tag-type">${typeName}</span>`;
}
const potStr = chairBet ? `<span class="history-pot">${fmt(chairBet)}</span>` : '';
return `<td>${potStr}<span class="${cls}">${colorCard(handStr)}</span>${tag}</td>`;
}
function addHistoryRow(r) {
const body = $('history-body');
const tr = document.createElement('tr');
const w = r.winner_name || CHAIRS[r.winner] || '?';
const cards = r.cards || {};
const htA = cards.A ? cards.A.hand_type_id : 0;
const htB = cards.B ? cards.B.hand_type_id : 0;
const htC = cards.C ? cards.C.hand_type_id : 0;
const bets = r.bets || {};
const rank = getBetRank(bets.A || 0, bets.B || 0, bets.C || 0, w);
tr.innerHTML = `
<td>${r.game_no}</td>
<td class="winner-cell winner-${w}">${w}</td>
<td>${fmt(r.total_pot)}</td>
${betRankCell(rank)}
${buildHandCell(cards.A ? cards.A.hand : null, htA, w === 'A', bets.A)}
${buildHandCell(cards.B ? cards.B.hand : null, htB, w === 'B', bets.B)}
${buildHandCell(cards.C ? cards.C.hand : null, htC, w === 'C', bets.C)}
`;
body.insertBefore(tr, body.firstChild);
while (body.children.length > 30) body.removeChild(body.lastChild);
}
function addHistoryRowFromGame(g) {
const body = $('history-body');
const tr = document.createElement('tr');
const w = CHAIRS[g.winner] || '?';
const rank = getBetRank(g.bet_a, g.bet_b, g.bet_c, w);
tr.innerHTML = `
<td>${g.game_no}</td>
<td class="winner-cell winner-${w}">${w}</td>
<td>${fmt(g.total_pot)}</td>
${betRankCell(rank)}
${buildHandCell(g.hand_a, g.hand_type_a, w === 'A', g.bet_a)}
${buildHandCell(g.hand_b, g.hand_type_b, w === 'B', g.bet_b)}
${buildHandCell(g.hand_c, g.hand_type_c, w === 'C', g.bet_c)}
`;
body.appendChild(tr);
}
// ── Win Distribution ──
function updateWinDist(data) {
const chairs = data.chairs || data; // backwards compat
const a = chairs.A||0, b = chairs.B||0, c = chairs.C||0;
const total = a+b+c||1;
$('dist-a').textContent = a;
$('dist-b').textContent = b;
$('dist-c').textContent = c;
$('dist-a-pct').textContent = (a/total*100).toFixed(1) + '%';
$('dist-b-pct').textContent = (b/total*100).toFixed(1) + '%';
$('dist-c-pct').textContent = (c/total*100).toFixed(1) + '%';
const br = data.bet_rank || {};
const lo = br.low||0, mi = br.mid||0, hi = br.high||0;
const brTotal = lo+mi+hi||1;
$('dist-low').textContent = lo;
$('dist-mid').textContent = mi;
$('dist-high').textContent = hi;
$('dist-low-pct').textContent = (lo/brTotal*100).toFixed(1) + '%';
$('dist-mid-pct').textContent = (mi/brTotal*100).toFixed(1) + '%';
$('dist-high-pct').textContent = (hi/brTotal*100).toFixed(1) + '%';
}
// ── Leaderboard ──
function renderLeaderboard(leaders) {
const el = $('leaderboard');
el.innerHTML = '';
leaders.forEach((l, i) => {
const row = document.createElement('div');
row.className = 'lb-row';
row.onclick = () => openUserModal(l.user_id);
const pnlClass = l.pnl >= 0 ? 'positive' : 'negative';
const pnlSign = l.pnl >= 0 ? '+' : '';
const winRate = l.total_bets > 0 ? Math.round(l.wins / l.total_bets * 100) : 0;
row.innerHTML = `
<span class="lb-rank">${i+1}</span>
<span class="lb-name">${escHtml(l.nick_name)}</span>
<span class="lb-stats">${winRate}%</span>
<span class="lb-pnl ${pnlClass}">${pnlSign}${fmt(l.pnl)}</span>
`;
el.appendChild(row);
});
}
// ── User Profile Modal ──
function openUserModal(userId) {
const overlay = $('modal-overlay');
const content = $('modal-content');
overlay.classList.add('open');
content.innerHTML = '<div style="text-align:center;padding:30px;color:var(--text2)">Loading profile...</div>';
fetch(`/api/user/${userId}`)
.then(r => r.json())
.then(data => {
if (data.error) {
content.innerHTML = `<div style="padding:20px;color:var(--red)">${data.error}</div>`;
return;
}
renderUserModal(data);
})
.catch(e => {
content.innerHTML = `<div style="padding:20px;color:var(--red)">Failed to load</div>`;
});
}
function renderUserModal(u) {
const content = $('modal-content');
const genderLabel = u.gender === 1 ? 'Male' : u.gender === 2 ? 'Female' : 'Unknown';
const portraitUrl = u.portrait
? `https://img.loee.link${u.portrait}`
: '';
const pnl = u.pnl || 0;
const pnlClass = pnl >= 0 ? 'positive' : 'negative';
const pnlSign = pnl >= 0 ? '+' : '';
const winRate = u.total_bets > 0 ? (u.wins / u.total_bets * 100).toFixed(1) : '0';
let betsHtml = '';
if (u.recent_bets && u.recent_bets.length > 0) {
betsHtml = `
<div class="modal-section-title">Recent Bets</div>
<table class="modal-bets-table">
<thead><tr><th>Round</th><th>Chair</th><th>Amount</th><th>Result</th></tr></thead>
<tbody>
${u.recent_bets.map(b => {
const resultClass = b.won === true ? 'bet-won' : b.won === false ? 'bet-lost' : '';
const resultText = b.won === true ? 'Won' : b.won === false ? 'Lost' : '—';
const chairClass = `bet-chair-${b.chair_name}`;
return `<tr>
<td>${b.game_no}</td>
<td class="${chairClass}" style="font-weight:700">${b.chair_name}</td>
<td>${fmtFull(b.bet_amount)}</td>
<td class="${resultClass}" style="font-weight:600">${resultText}</td>
</tr>`;
}).join('')}
</tbody>
</table>
`;
}
content.innerHTML = `
<div class="modal-header">
${portraitUrl
? `<img class="modal-avatar" src="${portraitUrl}" onerror="this.style.display='none'">`
: `<div class="modal-avatar" style="display:flex;align-items:center;justify-content:center;font-size:20px;color:var(--text3)">?</div>`
}
<div>
<div class="modal-name">${escHtml(u.nick_name || String(u.user_id))}</div>
<div class="modal-uid">ID: ${u.user_id}</div>
<div class="modal-badges">
${u.rich_level > 0 ? `<span class="modal-badge badge-rich">Rich Lv ${u.rich_level}</span>` : ''}
${u.is_actor ? `<span class="modal-badge badge-actor">Streamer Lv ${u.actor_level}</span>` : ''}
<span class="modal-badge badge-gender">${genderLabel}</span>
</div>
</div>
</div>
<div class="modal-stats">
<div class="modal-stat">
<div class="modal-stat-label">P&amp;L</div>
<div class="modal-stat-value ${pnlClass}">${pnlSign}${fmtFull(pnl)}</div>
</div>
<div class="modal-stat">
<div class="modal-stat-label">Win Rate</div>
<div class="modal-stat-value">${winRate}%</div>
</div>
<div class="modal-stat">
<div class="modal-stat-label">Rounds</div>
<div class="modal-stat-value">${u.rounds_played || 0}</div>
</div>
<div class="modal-stat">
<div class="modal-stat-label">Total Bets</div>
<div class="modal-stat-value">${u.total_bets || 0}</div>
</div>
<div class="modal-stat">
<div class="modal-stat-label">Wagered</div>
<div class="modal-stat-value">${fmt(u.total_wagered || 0)}</div>
</div>
<div class="modal-stat">
<div class="modal-stat-label">W / L</div>
<div class="modal-stat-value">${u.wins || 0} / ${u.losses || 0}</div>
</div>
</div>
<div class="modal-section-title">Lifetime Spend</div>
<div class="modal-spend">
<div class="modal-spend-item">Consumed: <span>${fmt(u.consume_total || 0)}</span></div>
<div class="modal-spend-item">Earned: <span>${fmt(u.earn_total || 0)}</span></div>
</div>
${betsHtml}
`;
}
function closeModal(event) {
if (event && event.target !== $('modal-overlay')) return;
$('modal-overlay').classList.remove('open');
}
// ── Bet Flow Chart ──
let betChart;
function initBetChart() {
const ctx = $('bet-chart').getContext('2d');
betChart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [
{label:'A', data:[], borderColor:'#3b82f6', backgroundColor:'#3b82f618', fill:true, tension:0.3, pointRadius:0, borderWidth:2},
{label:'B', data:[], borderColor:'#ec4899', backgroundColor:'#ec489918', fill:true, tension:0.3, pointRadius:0, borderWidth:2},
{label:'C', data:[], borderColor:'#f59e0b', backgroundColor:'#f59e0b18', fill:true, tension:0.3, pointRadius:0, borderWidth:2},
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: {duration: 200},
plugins: {
legend: {labels: {color:'#8b8fa3', font:{size:10}, boxWidth:12}},
},
scales: {
x: {display: false},
y: {
ticks: {color:'#5a5f75', callback: v => fmt(v), font:{size:10}},
grid: {color:'#2d314830'},
}
}
}
});
}
function updateBetChart() {
if (!betChart) return;
betChart.data.labels = betChartData.labels;
betChart.data.datasets[0].data = betChartData.A;
betChart.data.datasets[1].data = betChartData.B;
betChart.data.datasets[2].data = betChartData.C;
betChart.update('none');
}
// ── Util ──
function escHtml(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ── Keyboard ──
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeModal();
});
// ── Init ──
initBetChart();
connect();
</script>
</body>
</html>