1604 lines
56 KiB
HTML
1604 lines
56 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);
|
|
}
|
|
|
|
/* ── Mobile Responsive ── */
|
|
@media (max-width: 768px) {
|
|
.header {
|
|
flex-wrap: wrap; gap: 6px;
|
|
padding: 8px 12px;
|
|
}
|
|
.header h1 { font-size: 14px; }
|
|
.header .round-info { font-size: 12px; }
|
|
|
|
.grid {
|
|
grid-template-columns: 1fr;
|
|
height: auto;
|
|
}
|
|
.panel {
|
|
padding: 10px;
|
|
overflow-y: visible;
|
|
}
|
|
|
|
.chairs { gap: 4px; }
|
|
.chair { padding: 8px 4px; }
|
|
.chair-bet { font-size: 15px; }
|
|
.chair-label { font-size: 11px; }
|
|
|
|
.bets-feed { max-height: 180px; }
|
|
.bet-user { max-width: 90px; }
|
|
|
|
.hc-grid { grid-template-columns: 1fr; }
|
|
|
|
.history-table { font-size: 11px; display: block; overflow-x: auto; white-space: nowrap; }
|
|
.history-table th, .history-table td { padding: 3px 4px; }
|
|
|
|
.modal { width: 95vw; max-height: 90vh; padding: 14px; border-radius: 8px; }
|
|
.modal-stats { grid-template-columns: repeat(2, 1fr); }
|
|
.modal-header { gap: 8px; }
|
|
.modal-name { font-size: 14px; }
|
|
}
|
|
.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); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="header">
|
|
<h1>Teen Patti Live Monitor</h1>
|
|
<div class="round-info">
|
|
Round <span id="round-no">—</span>
|
|
<span id="phase-badge" class="phase-badge phase-NEW">—</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 →</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">—</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&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&L)</div>
|
|
<div class="hc-grid">
|
|
<div class="hc-panel">
|
|
<div class="hc-panel-title hot">▲ 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">▼ 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">—</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">—</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">—</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">—</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">—</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">—</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()">×</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">👑</span>
|
|
<div class="bw-info">
|
|
<div class="bw-name">${escHtml(data.nick_name)}</div>
|
|
<div class="bw-stats">${data.wins}/${data.total_bets} wins · ${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">→</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 ? '→' : '';
|
|
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>—</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>—</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&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>
|