add seat recommendations, bet advisor, and live whale/public trends
Prediction hero now shows ranked TOP PICK and 2ND PICK with EV per unit bet (P(win)*2.9 - 1). Bet Size Advisor panel shows Kelly criterion fraction (capped 25%), best chair with confidence, and historical bet rank insight (how often lowest/highest-bet chair wins). Live Market Sentiment section tracks whale trend (top 5 bettors by amount) and public trend (total pool distribution) in real-time via WebSocket, mirroring the live dashboard. Notes highlight agreement/divergence between model pick and crowd favorite. Historical crowd analysis cards show how often the most-bet, mid-bet, and least-bet chairs actually won across all games. Round result flash now includes whale/public pick accuracy alongside the model prediction result. user_bet WebSocket events are tracked to build per-round bettor profiles for whale analysis.
This commit is contained in:
19
app/db.py
19
app/db.py
@@ -1290,6 +1290,24 @@ def get_prediction_analysis() -> dict:
|
||||
suits = _suit_distribution(cards_data)
|
||||
winning_cards = _winning_card_patterns(cards_data)
|
||||
|
||||
# Bet rank analysis — how often the winning chair had high/mid/low bet
|
||||
rank_result = client.query("""
|
||||
SELECT
|
||||
countIf(winner_bet >= greatest(bet_a, bet_b, bet_c)) AS high,
|
||||
countIf(winner_bet > least(bet_a, bet_b, bet_c)
|
||||
AND winner_bet < greatest(bet_a, bet_b, bet_c)) AS mid,
|
||||
countIf(winner_bet <= least(bet_a, bet_b, bet_c)) AS low
|
||||
FROM (
|
||||
SELECT bet_a, bet_b, bet_c,
|
||||
multiIf(winner = 3, bet_a, winner = 2, bet_b, bet_c) AS winner_bet
|
||||
FROM games WHERE bet_a + bet_b + bet_c > 0
|
||||
)
|
||||
""")
|
||||
bet_rank = {"high": 0, "mid": 0, "low": 0}
|
||||
if rank_result.result_rows:
|
||||
r = rank_result.result_rows[0]
|
||||
bet_rank = {"high": r[0], "mid": r[1], "low": r[2]}
|
||||
|
||||
return {
|
||||
"total_games": len(winners),
|
||||
"last_winners": winners[-10:] if len(winners) >= 10 else winners,
|
||||
@@ -1302,6 +1320,7 @@ def get_prediction_analysis() -> dict:
|
||||
"chi_squared": chi_squared,
|
||||
"runs_test": runs_test,
|
||||
"backtest": backtest,
|
||||
"bet_rank": bet_rank,
|
||||
"card_values": card_values,
|
||||
"face_cards": face_cards,
|
||||
"suits": suits,
|
||||
|
||||
@@ -22,20 +22,13 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
|
||||
}
|
||||
.header h1 { font-size: 15px; font-weight: 700; letter-spacing: -0.3px; }
|
||||
.nav-links { display: flex; gap: 14px; align-items: center; }
|
||||
.nav-link {
|
||||
font-size: 12px; color: var(--accent); text-decoration: none;
|
||||
font-weight: 600; transition: color 0.2s;
|
||||
}
|
||||
.nav-link { font-size: 12px; color: var(--accent); text-decoration: none; font-weight: 600; transition: color 0.2s; }
|
||||
.nav-link:hover { color: #a78bfa; }
|
||||
.ws-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%; background: var(--red);
|
||||
display: inline-block; margin-left: 6px;
|
||||
}
|
||||
.ws-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--red); display: inline-block; margin-left: 6px; }
|
||||
.ws-dot.connected { background: var(--green); }
|
||||
|
||||
.content { padding: 16px 20px; max-width: 1400px; margin: 0 auto; }
|
||||
.loading { text-align: center; padding: 60px; color: var(--text2); font-size: 14px; }
|
||||
|
||||
.section { margin-bottom: 24px; }
|
||||
.section-title {
|
||||
font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px;
|
||||
@@ -45,22 +38,16 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
|
||||
/* Live game bar */
|
||||
.live-bar {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 12px 20px; margin-bottom: 16px; display: flex; align-items: center; gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 20px; margin-bottom: 16px; display: flex; align-items: center; gap: 20px; flex-wrap: wrap;
|
||||
}
|
||||
.live-bar .game-no { font-size: 16px; font-weight: 800; }
|
||||
.live-bar .phase {
|
||||
padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.live-bar .phase { padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 700; text-transform: uppercase; }
|
||||
.phase-BETTING { background: #10b981; color: #fff; }
|
||||
.phase-REVEALING { background: #f59e0b; color: #000; }
|
||||
.phase-ENDED { background: #ef4444; color: #fff; }
|
||||
.phase-NEW { background: #6c5ce7; color: #fff; }
|
||||
.live-bar .bet-pills { display: flex; gap: 12px; }
|
||||
.bet-pill {
|
||||
display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700;
|
||||
}
|
||||
.bet-pill { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700; }
|
||||
.bet-pill .dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.live-bar .timer { font-size: 14px; font-weight: 700; color: var(--green); }
|
||||
.live-bar .pot { font-size: 13px; color: var(--text2); }
|
||||
@@ -69,52 +56,91 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
|
||||
.pred-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 16px; }
|
||||
.pred-card {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 20px; text-align: center; position: relative; transition: all 0.3s;
|
||||
padding: 18px; text-align: center; position: relative; transition: all 0.3s;
|
||||
}
|
||||
.pred-card.recommended {
|
||||
.pred-card.best {
|
||||
border-color: var(--green);
|
||||
box-shadow: 0 0 20px rgba(16, 185, 129, 0.3), 0 0 40px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
.pred-card.second {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 20px rgba(108, 92, 231, 0.3), 0 0 40px rgba(108, 92, 231, 0.1);
|
||||
box-shadow: 0 0 12px rgba(108, 92, 231, 0.2);
|
||||
}
|
||||
.pred-card .chair-label { font-size: 14px; font-weight: 700; margin-bottom: 6px; }
|
||||
.pred-card .prob { font-size: 38px; font-weight: 800; letter-spacing: -2px; }
|
||||
.pred-card .badge {
|
||||
display: inline-block; margin-top: 6px; padding: 3px 10px; border-radius: 12px;
|
||||
font-size: 11px; font-weight: 600; background: var(--accent); color: #fff;
|
||||
.pred-card .chair-label { font-size: 14px; font-weight: 700; margin-bottom: 4px; }
|
||||
.pred-card .prob { font-size: 36px; font-weight: 800; letter-spacing: -2px; }
|
||||
.pred-card .rank-badge {
|
||||
display: inline-block; margin-top: 4px; padding: 3px 10px; border-radius: 12px;
|
||||
font-size: 11px; font-weight: 700;
|
||||
}
|
||||
.rank-badge.best-badge { background: var(--green); color: #fff; }
|
||||
.rank-badge.second-badge { background: var(--accent); color: #fff; }
|
||||
.pred-card .bet-info {
|
||||
margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border);
|
||||
font-size: 12px; color: var(--text2);
|
||||
}
|
||||
.pred-card .bet-val { font-size: 18px; font-weight: 700; color: var(--text); }
|
||||
.pred-card .ev-line { font-size: 11px; margin-top: 6px; }
|
||||
.ev-positive { color: var(--green); }
|
||||
.ev-negative { color: var(--red); }
|
||||
|
||||
/* Bet advisor */
|
||||
.bet-advisor {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 18px; margin-bottom: 16px;
|
||||
}
|
||||
.advisor-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; }
|
||||
.advisor-item { text-align: center; }
|
||||
.advisor-item .adv-label { font-size: 11px; color: var(--text3); font-weight: 600; text-transform: uppercase; margin-bottom: 4px; }
|
||||
.advisor-item .adv-value { font-size: 20px; font-weight: 800; }
|
||||
.advisor-item .adv-note { font-size: 10px; color: var(--text3); margin-top: 2px; }
|
||||
|
||||
/* Trend panels */
|
||||
.trends-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; }
|
||||
.trend-panel {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 14px;
|
||||
}
|
||||
.trend-panel .panel-title { font-size: 12px; font-weight: 700; color: var(--text2); margin-bottom: 10px; text-transform: uppercase; }
|
||||
.trend-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; font-size: 13px; }
|
||||
.trend-rank { font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 3px; letter-spacing: 0.3px; }
|
||||
.trend-rank-1 { background: #fbbf2430; color: #fbbf24; }
|
||||
.trend-rank-2 { background: #94a3b830; color: #94a3b8; }
|
||||
.trend-chair { font-weight: 900; font-size: 16px; min-width: 22px; text-align: center; }
|
||||
.trend-bar-bg { flex: 1; height: 18px; border-radius: 4px; background: var(--surface3); overflow: hidden; }
|
||||
.trend-bar-fill { height: 100%; border-radius: 4px; transition: width 0.4s ease; min-width: 2px; }
|
||||
.trend-bar-fill.chair-A { background: linear-gradient(90deg, #3b82f680, #3b82f6); }
|
||||
.trend-bar-fill.chair-B { background: linear-gradient(90deg, #ec489980, #ec4899); }
|
||||
.trend-bar-fill.chair-C { background: linear-gradient(90deg, #f59e0b80, #f59e0b); }
|
||||
.trend-pct { font-weight: 800; min-width: 40px; text-align: right; font-variant-numeric: tabular-nums; font-size: 12px; }
|
||||
.trend-amt { font-size: 10px; color: var(--text3); min-width: 50px; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.trend-note { font-size: 10px; color: var(--text3); margin-top: 4px; font-style: italic; }
|
||||
.trend-empty { font-size: 11px; color: var(--text3); padding: 10px 0; }
|
||||
|
||||
/* Historical crowd analysis */
|
||||
.crowd-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
||||
.crowd-card {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 14px; text-align: center;
|
||||
}
|
||||
.crowd-card .cc-label { font-size: 11px; color: var(--text3); font-weight: 600; text-transform: uppercase; margin-bottom: 4px; }
|
||||
.crowd-card .cc-value { font-size: 22px; font-weight: 800; }
|
||||
.crowd-card .cc-note { font-size: 10px; color: var(--text3); margin-top: 2px; }
|
||||
|
||||
.signal-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.signal-table th, .signal-table td {
|
||||
padding: 8px 12px; text-align: center; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.signal-table th, .signal-table td { padding: 8px 12px; text-align: center; border-bottom: 1px solid var(--border); }
|
||||
.signal-table th { color: var(--text2); font-weight: 600; text-transform: uppercase; font-size: 11px; }
|
||||
|
||||
/* Two-column layout */
|
||||
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.panel {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px;
|
||||
}
|
||||
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
|
||||
.panel-title { font-size: 12px; font-weight: 700; color: var(--text2); margin-bottom: 12px; text-transform: uppercase; }
|
||||
|
||||
/* Heatmap tables */
|
||||
.heatmap { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.heatmap th, .heatmap td { padding: 6px 8px; text-align: center; border: 1px solid var(--border); }
|
||||
.heatmap th { background: var(--surface2); color: var(--text2); font-weight: 600; font-size: 11px; }
|
||||
|
||||
/* Stat cards */
|
||||
.stat-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
||||
.stat-card {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 20px; text-align: center;
|
||||
}
|
||||
.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 20px; text-align: center; }
|
||||
.stat-card .label { font-size: 12px; color: var(--text2); font-weight: 600; margin-bottom: 4px; }
|
||||
.stat-card .value { font-size: 28px; font-weight: 800; }
|
||||
|
||||
/* Test result panels */
|
||||
.test-result { margin-bottom: 8px; }
|
||||
.test-result .test-label { font-size: 11px; color: var(--text3); font-weight: 600; text-transform: uppercase; }
|
||||
.test-result .test-value { font-size: 16px; font-weight: 700; }
|
||||
@@ -122,38 +148,28 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
|
||||
.significant { color: var(--red); }
|
||||
.not-significant { color: var(--green); }
|
||||
|
||||
/* Chart containers */
|
||||
.chart-container { position: relative; height: 300px; }
|
||||
.chart-container-sm { position: relative; height: 250px; }
|
||||
.chart-container-tall { position: relative; height: 480px; }
|
||||
|
||||
/* Backtest */
|
||||
.backtest-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||
.bt-card {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 14px; text-align: center;
|
||||
}
|
||||
.bt-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 14px; text-align: center; }
|
||||
.bt-card .bt-name { font-size: 11px; color: var(--text2); font-weight: 600; text-transform: uppercase; margin-bottom: 4px; }
|
||||
.bt-card .bt-acc { font-size: 24px; font-weight: 800; }
|
||||
.bt-card .bt-acc.above { color: var(--green); }
|
||||
.bt-card .bt-acc.below { color: var(--red); }
|
||||
|
||||
/* Last 20 predictions table */
|
||||
.pred-history { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.pred-history th, .pred-history td {
|
||||
padding: 7px 10px; text-align: center; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.pred-history th, .pred-history td { padding: 7px 10px; text-align: center; border-bottom: 1px solid var(--border); }
|
||||
.pred-history th { color: var(--text2); font-weight: 600; text-transform: uppercase; font-size: 11px; background: var(--surface2); position: sticky; top: 0; }
|
||||
.pred-history .correct { color: var(--green); }
|
||||
.pred-history .wrong { color: var(--red); }
|
||||
.pred-history .winner-cell { font-weight: 800; }
|
||||
.prob-bar { display: inline-block; height: 6px; border-radius: 3px; vertical-align: middle; }
|
||||
|
||||
/* Result flash */
|
||||
.result-flash {
|
||||
display: none; padding: 10px 16px; border-radius: 8px; margin-bottom: 16px;
|
||||
font-weight: 700; font-size: 14px; text-align: center;
|
||||
animation: flashIn 0.3s ease-out;
|
||||
font-weight: 700; font-size: 14px; text-align: center; animation: flashIn 0.3s ease-out;
|
||||
}
|
||||
.result-flash.show { display: block; }
|
||||
.result-flash.win { background: rgba(16,185,129,0.15); border: 1px solid var(--green); color: var(--green); }
|
||||
@@ -161,7 +177,8 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
|
||||
@keyframes flashIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pred-cards, .two-col, .stat-cards { grid-template-columns: 1fr; }
|
||||
.pred-cards, .two-col, .stat-cards, .trends-grid, .crowd-stats { grid-template-columns: 1fr; }
|
||||
.advisor-grid { grid-template-columns: 1fr; gap: 10px; }
|
||||
.backtest-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.pred-card .prob { font-size: 28px; }
|
||||
.live-bar { gap: 10px; }
|
||||
@@ -185,8 +202,8 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
|
||||
|
||||
<!-- Live Game Bar -->
|
||||
<div id="live-bar" class="live-bar" style="display:none">
|
||||
<div class="game-no">Round <span id="game-no">—</span></div>
|
||||
<span id="phase" class="phase phase-NEW">—</span>
|
||||
<div class="game-no">Round <span id="game-no">—</span></div>
|
||||
<span id="phase" class="phase phase-NEW">—</span>
|
||||
<span id="timer" class="timer"></span>
|
||||
<div class="bet-pills">
|
||||
<div class="bet-pill"><div class="dot" style="background:var(--chair-a)"></div>A: <span id="live-bet-a">0</span></div>
|
||||
@@ -201,8 +218,15 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
|
||||
|
||||
<!-- Bayesian Prediction Hero -->
|
||||
<div class="section">
|
||||
<div class="section-title">Bayesian Next-Chair Prediction <span id="pred-game-label" style="font-weight:400;text-transform:none;color:var(--text3)"></span></div>
|
||||
<div class="section-title">Recommendation <span id="pred-game-label" style="font-weight:400;text-transform:none;color:var(--text3)"></span></div>
|
||||
<div id="pred-cards" class="pred-cards"></div>
|
||||
|
||||
<!-- Bet Size Advisor -->
|
||||
<div id="bet-advisor" class="bet-advisor">
|
||||
<div class="panel-title">Bet Size Advisor</div>
|
||||
<div id="advisor-content" class="advisor-grid"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-top:8px">
|
||||
<div class="panel-title">Signal Breakdown</div>
|
||||
<table class="signal-table" id="signal-table">
|
||||
@@ -212,6 +236,25 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Trends: Whale + Public -->
|
||||
<div class="section">
|
||||
<div class="section-title">Live Market Sentiment</div>
|
||||
<div class="trends-grid">
|
||||
<div class="trend-panel">
|
||||
<div class="panel-title">Whale Trend (Top 5 Bettors)</div>
|
||||
<div id="whale-trend"><div class="trend-empty">Waiting for bets...</div></div>
|
||||
<div id="whale-note" class="trend-note"></div>
|
||||
</div>
|
||||
<div class="trend-panel">
|
||||
<div class="panel-title">Public Trend (Total Pool)</div>
|
||||
<div id="public-trend"><div class="trend-empty">Waiting for bets...</div></div>
|
||||
<div id="public-note" class="trend-note"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Historical: did crowd pick the winner? -->
|
||||
<div id="crowd-stats" class="crowd-stats"></div>
|
||||
</div>
|
||||
|
||||
<!-- Last 20 Predictions vs Actual -->
|
||||
<div class="section">
|
||||
<div class="section-title">Last 20 Predictions vs Actual</div>
|
||||
@@ -309,6 +352,7 @@ const $ = id => document.getElementById(id);
|
||||
const CHAIRS = ['A', 'B', 'C'];
|
||||
const CHAIR_COLORS = {A:'#3b82f6', B:'#ec4899', C:'#f59e0b'};
|
||||
const CHAIR_MAP = {1:'C', 2:'B', 3:'A'};
|
||||
const PAYOUT = 2.9; // winner gets 2.9x
|
||||
const fmt = n => {
|
||||
if (n == null) return '0';
|
||||
const abs = Math.abs(n);
|
||||
@@ -316,6 +360,7 @@ const fmt = n => {
|
||||
if (abs >= 1e3) return (n/1e3).toFixed(1) + 'K';
|
||||
return String(n);
|
||||
};
|
||||
const fmtFull = n => Number(n).toLocaleString();
|
||||
|
||||
const CHART_DEFAULTS = {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
@@ -330,8 +375,10 @@ const CHART_DEFAULTS = {
|
||||
let predictionData = null;
|
||||
let liveBets = {A: 0, B: 0, C: 0};
|
||||
let liveGameNo = null;
|
||||
let livePhase = '';
|
||||
let currentPrediction = null; // what we predicted for the current round
|
||||
let currentPrediction = null;
|
||||
let roundBettors = {};
|
||||
// Track whale-pick wins and public-pick wins from round results we witness
|
||||
let crowdTracker = {whaleCorrect: 0, publicCorrect: 0, roundsSeen: 0};
|
||||
|
||||
function heatColor(val) {
|
||||
const diff = val - 1/3;
|
||||
@@ -346,23 +393,36 @@ function heatColor(val) {
|
||||
|
||||
function pct(v) { return (v * 100).toFixed(1) + '%'; }
|
||||
|
||||
// ── Recommendation with EV + ranked picks ──
|
||||
function renderPrediction(data) {
|
||||
const container = $('pred-cards');
|
||||
const best = CHAIRS.reduce((a, b) => data.prediction[a] > data.prediction[b] ? a : b);
|
||||
const ranked = CHAIRS.slice().sort((a, b) => data.prediction[b] - data.prediction[a]);
|
||||
const best = ranked[0], second = ranked[1];
|
||||
currentPrediction = best;
|
||||
const gameLabel = $('pred-game-label');
|
||||
gameLabel.textContent = data.total_games ? `(based on ${data.total_games} games)` : '';
|
||||
$('pred-game-label').textContent = data.total_games ? `(based on ${data.total_games} games)` : '';
|
||||
|
||||
const pot = liveBets.A + liveBets.B + liveBets.C;
|
||||
|
||||
container.innerHTML = CHAIRS.map(c => {
|
||||
const p = data.prediction[c];
|
||||
const isBest = c === best;
|
||||
const isSecond = c === second;
|
||||
const bet = liveBets[c] || 0;
|
||||
return `<div class="pred-card ${isBest ? 'recommended' : ''}">
|
||||
// EV per unit bet: P(win) * 2.9 - 1 (you risk 1, get 2.9 back if win)
|
||||
const ev = p * PAYOUT - 1;
|
||||
const evClass = ev >= 0 ? 'ev-positive' : 'ev-negative';
|
||||
const evSign = ev >= 0 ? '+' : '';
|
||||
let cls = '';
|
||||
let badge = '';
|
||||
if (isBest) { cls = 'best'; badge = `<div class="rank-badge best-badge">TOP PICK</div>`; }
|
||||
else if (isSecond) { cls = 'second'; badge = `<div class="rank-badge second-badge">2ND PICK</div>`; }
|
||||
return `<div class="pred-card ${cls}">
|
||||
<div class="chair-label" style="color:${CHAIR_COLORS[c]}">Chair ${c}</div>
|
||||
<div class="prob" style="color:${CHAIR_COLORS[c]}">${pct(p)}</div>
|
||||
${isBest ? '<div class="badge">Recommended</div>' : ''}
|
||||
${badge}
|
||||
<div class="ev-line ${evClass}">EV: ${evSign}${(ev * 100).toFixed(1)}% per bet</div>
|
||||
<div class="bet-info">
|
||||
<div style="color:var(--text3)">Current Bet</div>
|
||||
<div style="color:var(--text3)">Current Bets</div>
|
||||
<div class="bet-val" style="color:${CHAIR_COLORS[c]}">${fmt(bet)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -377,6 +437,146 @@ function renderPrediction(data) {
|
||||
).join('') +
|
||||
`<tr style="font-weight:700;border-top:2px solid var(--border)"><td style="text-align:left">Combined</td><td>100%</td>` +
|
||||
CHAIRS.map(c => `<td style="color:${CHAIR_COLORS[c]}">${pct(data.prediction[c])}</td>`).join('') + '</tr>';
|
||||
|
||||
// Bet advisor
|
||||
renderBetAdvisor(data, pot);
|
||||
}
|
||||
|
||||
function renderBetAdvisor(data, pot) {
|
||||
const el = $('advisor-content');
|
||||
const ranked = CHAIRS.slice().sort((a, b) => data.prediction[b] - data.prediction[a]);
|
||||
const best = ranked[0];
|
||||
const pBest = data.prediction[best];
|
||||
const ev = pBest * PAYOUT - 1;
|
||||
|
||||
// Kelly criterion: f* = (bp - q) / b where b = payout-1, p = win prob, q = 1-p
|
||||
const b = PAYOUT - 1; // net payout ratio (1.9)
|
||||
const kellyFraction = Math.max(0, (b * pBest - (1 - pBest)) / b);
|
||||
// Cap Kelly at 25% for safety
|
||||
const safeFraction = Math.min(kellyFraction, 0.25);
|
||||
|
||||
// Bet rank insight from historical data
|
||||
const br = predictionData?.bet_rank || {};
|
||||
const brTotal = (br.high || 0) + (br.mid || 0) + (br.low || 0);
|
||||
const lowWinPct = brTotal > 0 ? ((br.low || 0) / brTotal * 100).toFixed(0) : '?';
|
||||
const highWinPct = brTotal > 0 ? ((br.high || 0) / brTotal * 100).toFixed(0) : '?';
|
||||
|
||||
const evClass = ev >= 0 ? 'ev-positive' : 'ev-negative';
|
||||
el.innerHTML = `
|
||||
<div class="advisor-item">
|
||||
<div class="adv-label">Best Chair</div>
|
||||
<div class="adv-value" style="color:${CHAIR_COLORS[best]}">${best} (${pct(pBest)})</div>
|
||||
<div class="adv-note ${evClass}">EV: ${ev >= 0 ? '+' : ''}${(ev * 100).toFixed(1)}% per unit</div>
|
||||
</div>
|
||||
<div class="advisor-item">
|
||||
<div class="adv-label">Kelly Fraction</div>
|
||||
<div class="adv-value">${(safeFraction * 100).toFixed(1)}%</div>
|
||||
<div class="adv-note">of bankroll per bet${kellyFraction > 0.25 ? ' (capped 25%)' : ''}</div>
|
||||
</div>
|
||||
<div class="advisor-item">
|
||||
<div class="adv-label">Winner Bet Rank</div>
|
||||
<div class="adv-value">${lowWinPct}% low</div>
|
||||
<div class="adv-note">Lowest-bet chair wins ${lowWinPct}% · Highest wins ${highWinPct}%</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Whale & Public Trends ──
|
||||
function renderWhaleTrend() {
|
||||
const el = $('whale-trend');
|
||||
const note = $('whale-note');
|
||||
const bettors = Object.values(roundBettors);
|
||||
if (bettors.length < 2) {
|
||||
el.innerHTML = '<div class="trend-empty">Waiting for bets...</div>';
|
||||
note.textContent = '';
|
||||
return;
|
||||
}
|
||||
const whales = bettors.sort((a, b) => b.total - a.total).slice(0, 5);
|
||||
const chairTotals = {A: 0, B: 0, C: 0};
|
||||
for (const w of whales) { for (const c of CHAIRS) chairTotals[c] += w.chairs[c] || 0; }
|
||||
const total = chairTotals.A + chairTotals.B + chairTotals.C;
|
||||
if (total === 0) { el.innerHTML = '<div class="trend-empty">No whale bets yet</div>'; note.textContent = ''; return; }
|
||||
|
||||
const ranked = CHAIRS.map(c => ({chair: c, amount: chairTotals[c], pct: chairTotals[c] / total * 100}))
|
||||
.sort((a, b) => b.amount - a.amount);
|
||||
|
||||
el.innerHTML = renderTrendBars(ranked.slice(0, 2));
|
||||
|
||||
const topChair = ranked[0].chair;
|
||||
const whaleCount = whales.length;
|
||||
const towardTop = whales.filter(w => {
|
||||
const maxCh = CHAIRS.reduce((a, b) => (w.chairs[a]||0) >= (w.chairs[b]||0) ? a : b);
|
||||
return maxCh === topChair;
|
||||
}).length;
|
||||
if (ranked[0].pct >= 70) note.textContent = `${towardTop}/${whaleCount} whales heavy on ${topChair} (strong lean)`;
|
||||
else if (ranked[0].pct >= 50) note.textContent = `${towardTop}/${whaleCount} whales favor ${topChair}, split on ${ranked[1].chair}`;
|
||||
else note.textContent = `Whales split across ${ranked[0].chair} & ${ranked[1].chair}`;
|
||||
}
|
||||
|
||||
function renderPublicTrend() {
|
||||
const el = $('public-trend');
|
||||
const note = $('public-note');
|
||||
const a = liveBets.A, b = liveBets.B, c = liveBets.C;
|
||||
const total = a + b + c;
|
||||
if (total === 0) { el.innerHTML = '<div class="trend-empty">No bets yet</div>'; note.textContent = ''; return; }
|
||||
|
||||
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);
|
||||
|
||||
el.innerHTML = renderTrendBars(ranked.slice(0, 2));
|
||||
|
||||
// Check if public favorite matches our prediction
|
||||
if (currentPrediction) {
|
||||
const pubFav = ranked[0].chair;
|
||||
if (pubFav === currentPrediction) {
|
||||
note.textContent = `Public agrees with model pick (${pubFav})`;
|
||||
} else {
|
||||
note.textContent = `Public favors ${pubFav}, model picks ${currentPrediction} \u2014 contrarian opportunity?`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderTrendBars(items) {
|
||||
return items.map((r, i) => `
|
||||
<div class="trend-row">
|
||||
<span class="trend-rank trend-rank-${i+1}">${i===0?'1ST':'2ND'}</span>
|
||||
<span class="trend-chair" style="color:${CHAIR_COLORS[r.chair]}">${r.chair}</span>
|
||||
<div class="trend-bar-bg">
|
||||
<div class="trend-bar-fill chair-${r.chair}" style="width:${r.pct}%"></div>
|
||||
</div>
|
||||
<span class="trend-pct" style="color:${CHAIR_COLORS[r.chair]}">${r.pct.toFixed(0)}%</span>
|
||||
<span class="trend-amt">${fmt(r.amount)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderCrowdStats(data) {
|
||||
const br = data.bet_rank || {};
|
||||
const brTotal = (br.high || 0) + (br.mid || 0) + (br.low || 0);
|
||||
if (brTotal === 0) { $('crowd-stats').innerHTML = ''; return; }
|
||||
const highPct = (br.high / brTotal * 100).toFixed(1);
|
||||
const midPct = (br.mid / brTotal * 100).toFixed(1);
|
||||
const lowPct = (br.low / brTotal * 100).toFixed(1);
|
||||
$('crowd-stats').innerHTML = `
|
||||
<div class="crowd-card">
|
||||
<div class="cc-label">Most-Bet Chair Wins</div>
|
||||
<div class="cc-value" style="color:var(--green)">${highPct}%</div>
|
||||
<div class="cc-note">${fmtFull(br.high)} / ${fmtFull(brTotal)} games</div>
|
||||
</div>
|
||||
<div class="crowd-card">
|
||||
<div class="cc-label">Mid-Bet Chair Wins</div>
|
||||
<div class="cc-value" style="color:var(--accent)">${midPct}%</div>
|
||||
<div class="cc-note">${fmtFull(br.mid)} / ${fmtFull(brTotal)} games</div>
|
||||
</div>
|
||||
<div class="crowd-card">
|
||||
<div class="cc-label">Least-Bet Chair Wins</div>
|
||||
<div class="cc-value" style="color:var(--red)">${lowPct}%</div>
|
||||
<div class="cc-note">${fmtFull(br.low)} / ${fmtFull(brTotal)} games</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderLast20(predictions) {
|
||||
@@ -412,10 +612,7 @@ function renderMarkov1(m) {
|
||||
let html = '<tr><th>From \\ To</th>' + CHAIRS.map(c => `<th style="color:${CHAIR_COLORS[c]}">\u2192${c}</th>`).join('') + '</tr>';
|
||||
for (const src of CHAIRS) {
|
||||
html += `<tr><th style="color:${CHAIR_COLORS[src]}">${src}</th>`;
|
||||
for (const dst of CHAIRS) {
|
||||
const v = m.matrix[src]?.[dst] || 0;
|
||||
html += `<td style="background:${heatColor(v)}">${pct(v)}</td>`;
|
||||
}
|
||||
for (const dst of CHAIRS) { const v = m.matrix[src]?.[dst] || 0; html += `<td style="background:${heatColor(v)}">${pct(v)}</td>`; }
|
||||
html += '</tr>';
|
||||
}
|
||||
t.innerHTML = html;
|
||||
@@ -428,10 +625,7 @@ function renderMarkov2(m) {
|
||||
let html = '<tr><th>From \\ To</th>' + CHAIRS.map(c => `<th style="color:${CHAIR_COLORS[c]}">\u2192${c}</th>`).join('') + '</tr>';
|
||||
for (const key of keys) {
|
||||
html += `<tr><th>${key}</th>`;
|
||||
for (const dst of CHAIRS) {
|
||||
const v = m.matrix[key]?.[dst] || 0;
|
||||
html += `<td style="background:${heatColor(v)}">${pct(v)}</td>`;
|
||||
}
|
||||
for (const dst of CHAIRS) { const v = m.matrix[key]?.[dst] || 0; html += `<td style="background:${heatColor(v)}">${pct(v)}</td>`; }
|
||||
html += '</tr>';
|
||||
}
|
||||
t.innerHTML = html;
|
||||
@@ -444,36 +638,22 @@ function renderAutocorrelation(ac) {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: ac.map(a => `Lag ${a.lag}`),
|
||||
datasets: [{
|
||||
label: 'Autocorrelation',
|
||||
data: ac.map(a => a.r),
|
||||
backgroundColor: ac.map(a => a.significant ? '#ef4444' : '#6c5ce7'),
|
||||
borderRadius: 4,
|
||||
}]
|
||||
datasets: [{ label: 'Autocorrelation', data: ac.map(a => a.r),
|
||||
backgroundColor: ac.map(a => a.significant ? '#ef4444' : '#6c5ce7'), borderRadius: 4 }]
|
||||
},
|
||||
options: {
|
||||
...CHART_DEFAULTS,
|
||||
plugins: { ...CHART_DEFAULTS.plugins, legend: { display: false } },
|
||||
scales: {
|
||||
...CHART_DEFAULTS.scales,
|
||||
y: { ...CHART_DEFAULTS.scales.y, suggestedMin: -0.3, suggestedMax: 0.3 }
|
||||
}
|
||||
scales: { ...CHART_DEFAULTS.scales, y: { ...CHART_DEFAULTS.scales.y, suggestedMin: -0.3, suggestedMax: 0.3 } }
|
||||
},
|
||||
plugins: [{
|
||||
id: 'thresholdLines',
|
||||
afterDraw(chart) {
|
||||
const {ctx, chartArea, scales} = chart;
|
||||
const yScale = scales.y;
|
||||
ctx.save();
|
||||
ctx.setLineDash([5, 5]);
|
||||
ctx.strokeStyle = '#ef444480';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.save(); ctx.setLineDash([5, 5]); ctx.strokeStyle = '#ef444480'; ctx.lineWidth = 1;
|
||||
for (const val of [threshold, -threshold]) {
|
||||
const y = yScale.getPixelForValue(val);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(chartArea.left, y);
|
||||
ctx.lineTo(chartArea.right, y);
|
||||
ctx.stroke();
|
||||
const y = scales.y.getPixelForValue(val);
|
||||
ctx.beginPath(); ctx.moveTo(chartArea.left, y); ctx.lineTo(chartArea.right, y); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -483,14 +663,10 @@ function renderAutocorrelation(ac) {
|
||||
|
||||
function renderChiSquared(chi) {
|
||||
$('chi-results').innerHTML = `
|
||||
<div class="test-result"><div class="test-label">Chi-Squared Statistic</div>
|
||||
<div class="test-value">${chi.chi2}</div></div>
|
||||
<div class="test-result"><div class="test-label">p-value</div>
|
||||
<div class="test-value ${chi.significant ? 'significant' : 'not-significant'}">${chi.p_value}</div></div>
|
||||
<div class="test-result"><div class="test-label">Expected Count (each chair)</div>
|
||||
<div class="test-value">${chi.expected}</div></div>
|
||||
<div class="test-result"><div class="test-label">Observed</div>
|
||||
<div class="test-value">A: ${chi.counts.A} B: ${chi.counts.B} C: ${chi.counts.C}</div></div>
|
||||
<div class="test-result"><div class="test-label">Chi-Squared Statistic</div><div class="test-value">${chi.chi2}</div></div>
|
||||
<div class="test-result"><div class="test-label">p-value</div><div class="test-value ${chi.significant ? 'significant' : 'not-significant'}">${chi.p_value}</div></div>
|
||||
<div class="test-result"><div class="test-label">Expected Count (each chair)</div><div class="test-value">${chi.expected}</div></div>
|
||||
<div class="test-result"><div class="test-label">Observed</div><div class="test-value">A: ${chi.counts.A} B: ${chi.counts.B} C: ${chi.counts.C}</div></div>
|
||||
<div class="test-result"><div class="test-label">Interpretation</div>
|
||||
<div class="test-interpret ${chi.significant ? 'significant' : 'not-significant'}">
|
||||
${chi.significant ? 'Distribution is significantly non-uniform (p < 0.05)' : 'Distribution is consistent with uniform (p >= 0.05)'}
|
||||
@@ -499,84 +675,54 @@ function renderChiSquared(chi) {
|
||||
|
||||
function renderRunsTest(runs) {
|
||||
$('runs-results').innerHTML = `
|
||||
<div class="test-result"><div class="test-label">Observed Runs</div>
|
||||
<div class="test-value">${runs.runs}</div></div>
|
||||
<div class="test-result"><div class="test-label">Expected Runs</div>
|
||||
<div class="test-value">${runs.expected_runs || '\u2014'}</div></div>
|
||||
<div class="test-result"><div class="test-label">Z-Score</div>
|
||||
<div class="test-value">${runs.z_score}</div></div>
|
||||
<div class="test-result"><div class="test-label">p-value</div>
|
||||
<div class="test-value ${runs.significant ? 'significant' : 'not-significant'}">${runs.p_value}</div></div>
|
||||
<div class="test-result"><div class="test-label">Observed Runs</div><div class="test-value">${runs.runs}</div></div>
|
||||
<div class="test-result"><div class="test-label">Expected Runs</div><div class="test-value">${runs.expected_runs || '\u2014'}</div></div>
|
||||
<div class="test-result"><div class="test-label">Z-Score</div><div class="test-value">${runs.z_score}</div></div>
|
||||
<div class="test-result"><div class="test-label">p-value</div><div class="test-value ${runs.significant ? 'significant' : 'not-significant'}">${runs.p_value}</div></div>
|
||||
<div class="test-result"><div class="test-label">Interpretation</div>
|
||||
<div class="test-interpret ${runs.significant ? 'significant' : 'not-significant'}">${runs.interpretation}</div></div>`;
|
||||
}
|
||||
|
||||
function renderBacktest(bt) {
|
||||
if (bt.error) {
|
||||
$('backtest-cards').innerHTML = `<div style="color:var(--text2)">${bt.error}</div>`;
|
||||
return;
|
||||
}
|
||||
if (bt.error) { $('backtest-cards').innerHTML = `<div style="color:var(--text2)">${bt.error}</div>`; return; }
|
||||
const names = {base_rate:'Base Rate',markov_1:'Markov-1',markov_2:'Markov-2',recent_20:'Recent 20',streak:'Streak',combined:'Combined'};
|
||||
$('backtest-cards').innerHTML = Object.entries(bt.accuracy).map(([key, acc]) =>
|
||||
`<div class="bt-card"><div class="bt-name">${names[key]||key}</div>
|
||||
<div class="bt-acc ${acc > bt.random_baseline ? 'above' : 'below'}">${acc}%</div>
|
||||
<div style="font-size:10px;color:var(--text3);margin-top:4px">vs ${bt.random_baseline}% random</div>
|
||||
</div>`
|
||||
<div style="font-size:10px;color:var(--text3);margin-top:4px">vs ${bt.random_baseline}% random</div></div>`
|
||||
).join('');
|
||||
|
||||
if (bt.rolling_accuracy) {
|
||||
const ctx = $('backtest-chart').getContext('2d');
|
||||
const colors = {base_rate:'#8b8fa3',markov_1:'#3b82f6',markov_2:'#ec4899',recent_20:'#f59e0b',streak:'#10b981',combined:'#6c5ce7'};
|
||||
const datasets = Object.entries(bt.rolling_accuracy).map(([key, data]) => ({
|
||||
label: names[key]||key, data,
|
||||
borderColor: colors[key]||'#fff', backgroundColor: 'transparent',
|
||||
borderWidth: key === 'combined' ? 3 : 1.5,
|
||||
pointRadius: 0, tension: 0.3,
|
||||
label: names[key]||key, data, borderColor: colors[key]||'#fff', backgroundColor: 'transparent',
|
||||
borderWidth: key === 'combined' ? 3 : 1.5, pointRadius: 0, tension: 0.3,
|
||||
}));
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: { labels: bt.rolling_accuracy.combined.map((_, i) => i + 1), datasets },
|
||||
options: {
|
||||
...CHART_DEFAULTS,
|
||||
scales: {
|
||||
options: { ...CHART_DEFAULTS, scales: {
|
||||
...CHART_DEFAULTS.scales,
|
||||
y: { ...CHART_DEFAULTS.scales.y, title: { display: true, text: 'Accuracy %', color: '#5a5f75' } },
|
||||
x: { ...CHART_DEFAULTS.scales.x, title: { display: true, text: 'Game', color: '#5a5f75' },
|
||||
ticks: { ...CHART_DEFAULTS.scales.x.ticks, maxTicksLimit: 10 } }
|
||||
}
|
||||
},
|
||||
plugins: [{
|
||||
id: 'baselineLine',
|
||||
afterDraw(chart) {
|
||||
x: { ...CHART_DEFAULTS.scales.x, title: { display: true, text: 'Game', color: '#5a5f75' }, ticks: { ...CHART_DEFAULTS.scales.x.ticks, maxTicksLimit: 10 } }
|
||||
}},
|
||||
plugins: [{ id: 'bl', afterDraw(chart) {
|
||||
const {ctx, chartArea, scales} = chart;
|
||||
const y = scales.y.getPixelForValue(bt.random_baseline);
|
||||
ctx.save();
|
||||
ctx.setLineDash([5, 5]);
|
||||
ctx.strokeStyle = '#ef444480';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(chartArea.left, y);
|
||||
ctx.lineTo(chartArea.right, y);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}]
|
||||
ctx.save(); ctx.setLineDash([5,5]); ctx.strokeStyle='#ef444480'; ctx.lineWidth=1;
|
||||
ctx.beginPath(); ctx.moveTo(chartArea.left,y); ctx.lineTo(chartArea.right,y); ctx.stroke(); ctx.restore();
|
||||
}}]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderCardValues(cv) {
|
||||
const ctx = $('card-value-chart').getContext('2d');
|
||||
new Chart(ctx, {
|
||||
new Chart($('card-value-chart').getContext('2d'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: cv.labels,
|
||||
datasets: CHAIRS.map(c => ({
|
||||
data: { labels: cv.labels, datasets: CHAIRS.map(c => ({
|
||||
label: `Chair ${c}`, data: cv.labels.map(v => cv.chairs[c]?.[v] || 0),
|
||||
backgroundColor: CHAIR_COLORS[c] + '99', borderColor: CHAIR_COLORS[c],
|
||||
borderWidth: 1, borderRadius: 3,
|
||||
}))
|
||||
},
|
||||
backgroundColor: CHAIR_COLORS[c] + '99', borderColor: CHAIR_COLORS[c], borderWidth: 1, borderRadius: 3,
|
||||
}))},
|
||||
options: CHART_DEFAULTS
|
||||
});
|
||||
}
|
||||
@@ -584,28 +730,20 @@ function renderCardValues(cv) {
|
||||
function renderFaceCards(fc) {
|
||||
$('face-cards').innerHTML = CHAIRS.map(c => {
|
||||
const d = fc[c];
|
||||
return `<div class="stat-card">
|
||||
<div class="label" style="color:${CHAIR_COLORS[c]}">Chair ${c}</div>
|
||||
return `<div class="stat-card"><div class="label" style="color:${CHAIR_COLORS[c]}">Chair ${c}</div>
|
||||
<div class="value" style="color:${CHAIR_COLORS[c]}">${d.pct}%</div>
|
||||
<div style="font-size:11px;color:var(--text3);margin-top:4px">${d.face_cards} / ${d.total_cards} cards</div>
|
||||
</div>`;
|
||||
<div style="font-size:11px;color:var(--text3);margin-top:4px">${d.face_cards} / ${d.total_cards} cards</div></div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderSuits(s) {
|
||||
const ctx = $('suit-chart').getContext('2d');
|
||||
const suitColors = {'\u2660':'#e4e6f0','\u2665':'#ef4444','\u2663':'#10b981','\u2666':'#3b82f6'};
|
||||
new Chart(ctx, {
|
||||
new Chart($('suit-chart').getContext('2d'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: CHAIRS.map(c => `Chair ${c}`),
|
||||
datasets: s.labels.map(suit => ({
|
||||
data: { labels: CHAIRS.map(c => `Chair ${c}`), datasets: s.labels.map(suit => ({
|
||||
label: suit, data: CHAIRS.map(c => s.chairs[c]?.[suit] || 0),
|
||||
backgroundColor: (suitColors[suit] || '#8b8fa3') + '99',
|
||||
borderColor: suitColors[suit] || '#8b8fa3',
|
||||
borderWidth: 1, borderRadius: 3,
|
||||
}))
|
||||
},
|
||||
backgroundColor: (suitColors[suit]||'#8b8fa3') + '99', borderColor: suitColors[suit]||'#8b8fa3', borderWidth: 1, borderRadius: 3,
|
||||
}))},
|
||||
options: CHART_DEFAULTS
|
||||
});
|
||||
}
|
||||
@@ -615,31 +753,18 @@ function renderWinningCards(wc) {
|
||||
$('winning-cards-chart').parentElement.innerHTML = '<div style="color:var(--text3);text-align:center;padding:40px">No card data available</div>';
|
||||
return;
|
||||
}
|
||||
const ctx = $('winning-cards-chart').getContext('2d');
|
||||
const colors = wc.map(c => {
|
||||
const suit = c.card.slice(-1);
|
||||
return (suit === '\u2665' || suit === '\u2666') ? '#ef4444' : '#e4e6f0';
|
||||
});
|
||||
new Chart(ctx, {
|
||||
new Chart($('winning-cards-chart').getContext('2d'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: wc.map(c => c.card),
|
||||
datasets: [{
|
||||
label: 'Appearances',
|
||||
data: wc.map(c => c.count),
|
||||
backgroundColor: colors.map(c => c + '80'),
|
||||
borderColor: colors,
|
||||
borderWidth: 1, borderRadius: 3,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
indexAxis: 'y',
|
||||
data: { labels: wc.map(c => c.card), datasets: [{ label: 'Appearances', data: wc.map(c => c.count),
|
||||
backgroundColor: colors.map(c => c + '80'), borderColor: colors, borderWidth: 1, borderRadius: 3 }]},
|
||||
options: { responsive: true, maintainAspectRatio: false, indexAxis: 'y',
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { color: '#5a5f75', font: { size: 10 } }, grid: { color: '#2d3148' } },
|
||||
y: { ticks: { color: '#e4e6f0', font: { size: 12 } }, grid: { display: false } }
|
||||
}
|
||||
scales: { x: { ticks: { color: '#5a5f75', font: { size: 10 } }, grid: { color: '#2d3148' } },
|
||||
y: { ticks: { color: '#e4e6f0', font: { size: 12 } }, grid: { display: false } } }
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -647,6 +772,7 @@ function renderWinningCards(wc) {
|
||||
function render(data) {
|
||||
predictionData = data;
|
||||
renderPrediction(data);
|
||||
renderCrowdStats(data);
|
||||
renderLast20(data.last_20_predictions);
|
||||
renderMarkov1(data.markov1);
|
||||
renderMarkov2(data.markov2);
|
||||
@@ -662,56 +788,39 @@ function render(data) {
|
||||
$('main').style.display = 'block';
|
||||
}
|
||||
|
||||
// ── WebSocket for real-time data ──
|
||||
// ── WebSocket ──
|
||||
let ws, reconnectDelay = 1000;
|
||||
let lastGameNo = null;
|
||||
|
||||
function connect() {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
ws = new WebSocket(`${proto}://${location.host}/ws`);
|
||||
ws.onopen = () => {
|
||||
$('ws-dot').classList.add('connected');
|
||||
reconnectDelay = 1000;
|
||||
};
|
||||
ws.onclose = () => {
|
||||
$('ws-dot').classList.remove('connected');
|
||||
setTimeout(connect, reconnectDelay);
|
||||
reconnectDelay = Math.min(reconnectDelay * 1.5, 30000);
|
||||
};
|
||||
ws.onmessage = e => {
|
||||
const msg = JSON.parse(e.data);
|
||||
handleEvent(msg.type, msg.data);
|
||||
};
|
||||
ws.onopen = () => { $('ws-dot').classList.add('connected'); reconnectDelay = 1000; };
|
||||
ws.onclose = () => { $('ws-dot').classList.remove('connected'); 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': trackBet(data); break;
|
||||
case 'round_result': onRoundResult(data); break;
|
||||
}
|
||||
}
|
||||
|
||||
function updateGameState(s) {
|
||||
const bar = $('live-bar');
|
||||
bar.style.display = 'flex';
|
||||
$('live-bar').style.display = 'flex';
|
||||
$('game-no').textContent = '#' + s.game_no;
|
||||
const phase = $('phase');
|
||||
phase.textContent = s.status_name;
|
||||
phase.className = 'phase phase-' + s.status_name;
|
||||
|
||||
const timer = $('timer');
|
||||
if (s.status === 1 && s.remaining_s > 0) {
|
||||
timer.textContent = Math.ceil(s.remaining_s) + 's';
|
||||
} else {
|
||||
timer.textContent = '';
|
||||
}
|
||||
timer.textContent = (s.status === 1 && s.remaining_s > 0) ? Math.ceil(s.remaining_s) + 's' : '';
|
||||
|
||||
// New round — refresh predictions
|
||||
if (s.game_no !== lastGameNo) {
|
||||
lastGameNo = s.game_no;
|
||||
if (s.game_no !== liveGameNo) {
|
||||
liveGameNo = s.game_no;
|
||||
liveBets = {A: 0, B: 0, C: 0};
|
||||
// Hide result flash after a few seconds
|
||||
setTimeout(() => { const f = $('result-flash'); f.classList.remove('show'); }, 5000);
|
||||
roundBettors = {};
|
||||
setTimeout(() => { $('result-flash').classList.remove('show'); }, 5000);
|
||||
}
|
||||
|
||||
const a = s.bets?.A || 0, b = s.bets?.B || 0, c = s.bets?.C || 0;
|
||||
@@ -721,51 +830,81 @@ function updateGameState(s) {
|
||||
$('live-bet-c').textContent = fmt(c);
|
||||
$('live-pot').textContent = fmt(s.total_pot || (a + b + c));
|
||||
|
||||
// Update bet values on prediction cards if they exist
|
||||
updatePredCardBets();
|
||||
renderPublicTrend();
|
||||
if (predictionData) renderBetAdvisor(predictionData, a + b + c);
|
||||
}
|
||||
|
||||
function trackBet(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];
|
||||
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;
|
||||
renderWhaleTrend();
|
||||
}
|
||||
|
||||
function updatePredCardBets() {
|
||||
const cards = document.querySelectorAll('.pred-card');
|
||||
cards.forEach((card, i) => {
|
||||
document.querySelectorAll('.pred-card').forEach((card, i) => {
|
||||
const c = CHAIRS[i];
|
||||
const betEl = card.querySelector('.bet-val');
|
||||
if (betEl) betEl.textContent = fmt(liveBets[c] || 0);
|
||||
});
|
||||
}
|
||||
|
||||
function getWhaleTopChair() {
|
||||
const bettors = Object.values(roundBettors);
|
||||
if (bettors.length < 2) return null;
|
||||
const whales = bettors.sort((a, b) => b.total - a.total).slice(0, 5);
|
||||
const ct = {A:0, B:0, C:0};
|
||||
for (const w of whales) for (const c of CHAIRS) ct[c] += w.chairs[c] || 0;
|
||||
const total = ct.A + ct.B + ct.C;
|
||||
if (total === 0) return null;
|
||||
return CHAIRS.reduce((a, b) => ct[a] >= ct[b] ? a : b);
|
||||
}
|
||||
|
||||
function getPublicTopChair() {
|
||||
const total = liveBets.A + liveBets.B + liveBets.C;
|
||||
if (total === 0) return null;
|
||||
return CHAIRS.reduce((a, b) => liveBets[a] >= liveBets[b] ? a : b);
|
||||
}
|
||||
|
||||
function onRoundResult(data) {
|
||||
const winner = data.winner_name || CHAIR_MAP[data.winner] || '?';
|
||||
const flash = $('result-flash');
|
||||
|
||||
// Check whale / public picks vs winner
|
||||
const whalePick = getWhaleTopChair();
|
||||
const pubPick = getPublicTopChair();
|
||||
let extra = [];
|
||||
if (whalePick) extra.push(whalePick === winner ? `Whales picked ${whalePick} \u2714` : `Whales picked ${whalePick} \u2718`);
|
||||
if (pubPick) extra.push(pubPick === winner ? `Public picked ${pubPick} \u2714` : `Public picked ${pubPick} \u2718`);
|
||||
|
||||
if (currentPrediction) {
|
||||
const hit = currentPrediction === winner;
|
||||
flash.className = `result-flash show ${hit ? 'win' : 'loss'}`;
|
||||
flash.textContent = hit
|
||||
? `Round #${data.game_no}: Predicted ${currentPrediction} \u2014 ${winner} won. HIT!`
|
||||
: `Round #${data.game_no}: Predicted ${currentPrediction} \u2014 ${winner} won. MISS`;
|
||||
flash.innerHTML = `Round #${data.game_no}: Predicted <b>${currentPrediction}</b> \u2014 <b>${winner}</b> won. ${hit ? 'HIT!' : 'MISS'}` +
|
||||
(extra.length ? ` \u00b7 ${extra.join(' \u00b7 ')}` : '');
|
||||
}
|
||||
|
||||
// Refresh prediction data after a short delay to let the DB update
|
||||
setTimeout(refreshPredictions, 2000);
|
||||
}
|
||||
|
||||
function refreshPredictions() {
|
||||
fetch('/api/predictions')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
fetch('/api/predictions').then(r => r.json()).then(data => {
|
||||
predictionData = data;
|
||||
renderPrediction(data);
|
||||
renderCrowdStats(data);
|
||||
renderLast20(data.last_20_predictions);
|
||||
})
|
||||
.catch(() => {});
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// ── Init ──
|
||||
fetch('/api/predictions')
|
||||
.then(r => r.json())
|
||||
.then(render)
|
||||
fetch('/api/predictions').then(r => r.json()).then(render)
|
||||
.catch(err => { $('loading').textContent = 'Failed to load: ' + err.message; });
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user