add real-time game data, prediction history, and fix winning cards chart
- WebSocket connection shows live game state (round #, phase, bets per chair, pot) in a persistent bar at the top of predictions page - Prediction cards now display current bet amounts per chair - Round results flash HIT/MISS against the Bayesian prediction - New "Last 20 Predictions vs Actual" table with per-game probabilities, predicted vs actual winner, and running accuracy - Predictions auto-refresh after each round ends - Fix winning cards chart: use taller container (480px) and dedicated scales config for horizontal bar rendering - Add _last_n_predictions() helper to db.py for detailed per-game prediction history with game numbers
This commit is contained in:
39
app/db.py
39
app/db.py
@@ -1217,14 +1217,39 @@ def _backtest_theories(winners):
|
||||
}
|
||||
|
||||
|
||||
def _last_n_predictions(winners, n=20):
|
||||
"""Get detailed prediction vs actual for the last N games."""
|
||||
warmup = 30
|
||||
if len(winners) <= warmup:
|
||||
return []
|
||||
start = max(warmup, len(winners) - n)
|
||||
results = []
|
||||
for i in range(start, len(winners)):
|
||||
history = winners[:i]
|
||||
actual = winners[i]
|
||||
m1, _ = _markov_matrix_1(history)
|
||||
m2, _ = _markov_matrix_2(history)
|
||||
pred, _ = _bayesian_prediction(history, m1, m2)
|
||||
predicted = max(CHAIR_LABELS, key=lambda c: pred[c])
|
||||
results.append({
|
||||
"index": i,
|
||||
"predicted": predicted,
|
||||
"actual": actual,
|
||||
"correct": predicted == actual,
|
||||
"probs": {c: round(pred[c], 4) for c in CHAIR_LABELS},
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
@_with_lock
|
||||
def get_prediction_analysis() -> dict:
|
||||
"""Run all prediction/game-theory analysis and return results."""
|
||||
client = get_client()
|
||||
|
||||
# Query 1: Full winner sequence
|
||||
result = client.query("SELECT winner FROM games ORDER BY game_no ASC")
|
||||
winners = [config.CHAIRS.get(r[0], "?") for r in result.result_rows]
|
||||
# Query 1: Full winner sequence with game numbers
|
||||
result = client.query("SELECT game_no, winner FROM games ORDER BY game_no ASC")
|
||||
game_nos = [r[0] for r in result.result_rows if config.CHAIRS.get(r[1], "?") in CHAIR_LABELS]
|
||||
winners = [config.CHAIRS.get(r[1], "?") for r in result.result_rows]
|
||||
winners = [w for w in winners if w in CHAIR_LABELS] # filter unknowns
|
||||
|
||||
# Query 2: Card data for last 500 games
|
||||
@@ -1252,6 +1277,13 @@ def get_prediction_analysis() -> dict:
|
||||
# Backtesting
|
||||
backtest = _backtest_theories(winners)
|
||||
|
||||
# Last 20 prediction vs actual
|
||||
last_20_raw = _last_n_predictions(winners, 20)
|
||||
# Attach game_nos to last_20
|
||||
for entry in last_20_raw:
|
||||
idx = entry["index"]
|
||||
entry["game_no"] = game_nos[idx] if idx < len(game_nos) else 0
|
||||
|
||||
# Card analysis
|
||||
card_values = _card_value_distribution(cards_data)
|
||||
face_cards = _face_card_frequency(cards_data)
|
||||
@@ -1261,6 +1293,7 @@ def get_prediction_analysis() -> dict:
|
||||
return {
|
||||
"total_games": len(winners),
|
||||
"last_winners": winners[-10:] if len(winners) >= 10 else winners,
|
||||
"last_20_predictions": last_20_raw,
|
||||
"prediction": prediction,
|
||||
"signals": signals,
|
||||
"markov1": {"matrix": markov1, "counts": {k: dict(v) for k, v in markov1_counts.items()}},
|
||||
|
||||
@@ -21,12 +21,17 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
|
||||
padding: 10px 20px; background: var(--surface); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.header h1 { font-size: 15px; font-weight: 700; letter-spacing: -0.3px; }
|
||||
.nav-links { display: flex; gap: 14px; }
|
||||
.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:hover { color: #a78bfa; }
|
||||
.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; }
|
||||
@@ -37,22 +42,50 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
|
||||
color: var(--text2); margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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 .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); }
|
||||
|
||||
/* Hero prediction cards */
|
||||
.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: 24px; text-align: center; position: relative; transition: all 0.3s;
|
||||
padding: 20px; text-align: center; position: relative; transition: all 0.3s;
|
||||
}
|
||||
.pred-card.recommended {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 20px rgba(108, 92, 231, 0.3), 0 0 40px rgba(108, 92, 231, 0.1);
|
||||
}
|
||||
.pred-card .chair-label { font-size: 14px; font-weight: 700; margin-bottom: 8px; }
|
||||
.pred-card .prob { font-size: 42px; font-weight: 800; letter-spacing: -2px; }
|
||||
.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: 8px; padding: 3px 10px; border-radius: 12px;
|
||||
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 .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); }
|
||||
|
||||
.signal-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.signal-table th, .signal-table td {
|
||||
@@ -92,6 +125,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
|
||||
/* 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; }
|
||||
@@ -104,10 +138,34 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
|
||||
.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 { 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;
|
||||
}
|
||||
.result-flash.show { display: block; }
|
||||
.result-flash.win { background: rgba(16,185,129,0.15); border: 1px solid var(--green); color: var(--green); }
|
||||
.result-flash.loss { background: rgba(239,68,68,0.15); border: 1px solid var(--red); color: var(--red); }
|
||||
@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; }
|
||||
.backtest-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.pred-card .prob { font-size: 32px; }
|
||||
.pred-card .prob { font-size: 28px; }
|
||||
.live-bar { gap: 10px; }
|
||||
.live-bar .bet-pills { width: 100%; justify-content: space-between; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -118,15 +176,32 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
|
||||
<a href="/" class="nav-link">Live Dashboard →</a>
|
||||
<a href="/analytics" class="nav-link">Analytics →</a>
|
||||
<a href="/patterns" class="nav-link">Patterns →</a>
|
||||
<div id="ws-dot" class="ws-dot" title="WebSocket"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div id="loading" class="loading">Loading prediction data...</div>
|
||||
<div id="main" style="display:none">
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
<div class="bet-pill"><div class="dot" style="background:var(--chair-b)"></div>B: <span id="live-bet-b">0</span></div>
|
||||
<div class="bet-pill"><div class="dot" style="background:var(--chair-c)"></div>C: <span id="live-bet-c">0</span></div>
|
||||
</div>
|
||||
<div class="pot">Pot: <span id="live-pot">0</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Result Flash -->
|
||||
<div id="result-flash" class="result-flash"></div>
|
||||
|
||||
<!-- Bayesian Prediction Hero -->
|
||||
<div class="section">
|
||||
<div class="section-title">Bayesian Next-Chair Prediction</div>
|
||||
<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 id="pred-cards" class="pred-cards"></div>
|
||||
<div class="panel" style="margin-top:8px">
|
||||
<div class="panel-title">Signal Breakdown</div>
|
||||
@@ -137,6 +212,17 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last 20 Predictions vs Actual -->
|
||||
<div class="section">
|
||||
<div class="section-title">Last 20 Predictions vs Actual</div>
|
||||
<div class="panel" style="max-height:500px;overflow-y:auto">
|
||||
<table class="pred-history" id="pred-history">
|
||||
<thead><tr><th>Game</th><th>Predicted</th><th>P(A)</th><th>P(B)</th><th>P(C)</th><th>Actual</th><th>Result</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Markov Matrices -->
|
||||
<div class="section">
|
||||
<div class="section-title">Markov Transition Matrices</div>
|
||||
@@ -211,7 +297,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
|
||||
<div class="section">
|
||||
<div class="section-title">Top 20 Cards in Winning Hands</div>
|
||||
<div class="panel">
|
||||
<div class="chart-container"><canvas id="winning-cards-chart"></canvas></div>
|
||||
<div class="chart-container-tall"><canvas id="winning-cards-chart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -222,6 +308,15 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
|
||||
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 fmt = n => {
|
||||
if (n == null) 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 CHART_DEFAULTS = {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { labels: { color: '#8b8fa3', font: { size: 11 } } } },
|
||||
@@ -231,15 +326,21 @@ const CHART_DEFAULTS = {
|
||||
}
|
||||
};
|
||||
|
||||
// ── State ──
|
||||
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
|
||||
|
||||
function heatColor(val) {
|
||||
// val is probability 0-1, center at 0.333
|
||||
const diff = val - 1/3;
|
||||
if (diff > 0) {
|
||||
const intensity = Math.min(diff * 6, 1);
|
||||
return `rgba(16, 185, 129, ${0.15 + intensity * 0.5})`; // green
|
||||
return `rgba(16, 185, 129, ${0.15 + intensity * 0.5})`;
|
||||
} else {
|
||||
const intensity = Math.min(Math.abs(diff) * 6, 1);
|
||||
return `rgba(239, 68, 68, ${0.15 + intensity * 0.5})`; // red
|
||||
return `rgba(239, 68, 68, ${0.15 + intensity * 0.5})`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,13 +349,22 @@ function pct(v) { return (v * 100).toFixed(1) + '%'; }
|
||||
function renderPrediction(data) {
|
||||
const container = $('pred-cards');
|
||||
const best = CHAIRS.reduce((a, b) => data.prediction[a] > data.prediction[b] ? a : b);
|
||||
currentPrediction = best;
|
||||
const gameLabel = $('pred-game-label');
|
||||
gameLabel.textContent = data.total_games ? `(based on ${data.total_games} games)` : '';
|
||||
|
||||
container.innerHTML = CHAIRS.map(c => {
|
||||
const p = data.prediction[c];
|
||||
const isBest = c === best;
|
||||
const bet = liveBets[c] || 0;
|
||||
return `<div class="pred-card ${isBest ? 'recommended' : ''}">
|
||||
<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>' : ''}
|
||||
<div class="bet-info">
|
||||
<div style="color:var(--text3)">Current Bet</div>
|
||||
<div class="bet-val" style="color:${CHAIR_COLORS[c]}">${fmt(bet)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
@@ -269,9 +379,37 @@ function renderPrediction(data) {
|
||||
CHAIRS.map(c => `<td style="color:${CHAIR_COLORS[c]}">${pct(data.prediction[c])}</td>`).join('') + '</tr>';
|
||||
}
|
||||
|
||||
function renderLast20(predictions) {
|
||||
const tbody = $('pred-history').querySelector('tbody');
|
||||
if (!predictions || predictions.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="color:var(--text3)">Not enough data</td></tr>';
|
||||
return;
|
||||
}
|
||||
const correctCount = predictions.filter(p => p.correct).length;
|
||||
tbody.innerHTML = predictions.slice().reverse().map(p => {
|
||||
const maxProb = Math.max(p.probs.A, p.probs.B, p.probs.C);
|
||||
return `<tr>
|
||||
<td style="font-weight:700">#${p.game_no}</td>
|
||||
<td class="winner-cell" style="color:${CHAIR_COLORS[p.predicted]}">${p.predicted}</td>
|
||||
${CHAIRS.map(c => {
|
||||
const w = p.probs[c] / maxProb * 40;
|
||||
return `<td><span class="prob-bar" style="width:${w}px;background:${CHAIR_COLORS[c]}"></span> ${pct(p.probs[c])}</td>`;
|
||||
}).join('')}
|
||||
<td class="winner-cell" style="color:${CHAIR_COLORS[p.actual]}">${p.actual}</td>
|
||||
<td class="${p.correct ? 'correct' : 'wrong'}" style="font-weight:700">${p.correct ? 'HIT' : 'MISS'}</td>
|
||||
</tr>`;
|
||||
}).join('') +
|
||||
`<tr style="border-top:2px solid var(--border);font-weight:700">
|
||||
<td colspan="5" style="text-align:right;color:var(--text2)">Accuracy (last ${predictions.length})</td>
|
||||
<td colspan="2" style="color:${correctCount/predictions.length > 1/3 ? 'var(--green)' : 'var(--red)'}">
|
||||
${(correctCount/predictions.length*100).toFixed(1)}% (${correctCount}/${predictions.length})
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function renderMarkov1(m) {
|
||||
const t = $('markov1-table');
|
||||
let html = '<tr><th>From \\ To</th>' + CHAIRS.map(c => `<th style="color:${CHAIR_COLORS[c]}">→${c}</th>`).join('') + '</tr>';
|
||||
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) {
|
||||
@@ -287,7 +425,7 @@ function renderMarkov2(m) {
|
||||
const t = $('markov2-table');
|
||||
const keys = [];
|
||||
for (const a of CHAIRS) for (const b of CHAIRS) keys.push(a+b);
|
||||
let html = '<tr><th>From \\ To</th>' + CHAIRS.map(c => `<th style="color:${CHAIR_COLORS[c]}">→${c}</th>`).join('') + '</tr>';
|
||||
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) {
|
||||
@@ -315,11 +453,7 @@ function renderAutocorrelation(ac) {
|
||||
},
|
||||
options: {
|
||||
...CHART_DEFAULTS,
|
||||
plugins: {
|
||||
...CHART_DEFAULTS.plugins,
|
||||
annotation: undefined,
|
||||
legend: { display: false },
|
||||
},
|
||||
plugins: { ...CHART_DEFAULTS.plugins, legend: { display: false } },
|
||||
scales: {
|
||||
...CHART_DEFAULTS.scales,
|
||||
y: { ...CHART_DEFAULTS.scales.y, suggestedMin: -0.3, suggestedMax: 0.3 }
|
||||
@@ -368,7 +502,7 @@ function renderRunsTest(runs) {
|
||||
<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 || '—'}</div></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>
|
||||
@@ -477,8 +611,11 @@ function renderSuits(s) {
|
||||
}
|
||||
|
||||
function renderWinningCards(wc) {
|
||||
if (!wc || wc.length === 0) {
|
||||
$('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');
|
||||
// Color hearts/diamonds red, spades/clubs white
|
||||
const colors = wc.map(c => {
|
||||
const suit = c.card.slice(-1);
|
||||
return (suit === '\u2665' || suit === '\u2666') ? '#ef4444' : '#e4e6f0';
|
||||
@@ -488,7 +625,7 @@ function renderWinningCards(wc) {
|
||||
data: {
|
||||
labels: wc.map(c => c.card),
|
||||
datasets: [{
|
||||
label: 'Appearances in Winning Hands',
|
||||
label: 'Appearances',
|
||||
data: wc.map(c => c.count),
|
||||
backgroundColor: colors.map(c => c + '80'),
|
||||
borderColor: colors,
|
||||
@@ -496,15 +633,21 @@ function renderWinningCards(wc) {
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
...CHART_DEFAULTS,
|
||||
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 } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
predictionData = data;
|
||||
renderPrediction(data);
|
||||
renderLast20(data.last_20_predictions);
|
||||
renderMarkov1(data.markov1);
|
||||
renderMarkov2(data.markov2);
|
||||
renderAutocorrelation(data.autocorrelation);
|
||||
@@ -519,10 +662,111 @@ function render(data) {
|
||||
$('main').style.display = 'block';
|
||||
}
|
||||
|
||||
// ── WebSocket for real-time data ──
|
||||
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);
|
||||
};
|
||||
}
|
||||
|
||||
function handleEvent(type, data) {
|
||||
switch(type) {
|
||||
case 'game_state': updateGameState(data); break;
|
||||
case 'round_result': onRoundResult(data); break;
|
||||
}
|
||||
}
|
||||
|
||||
function updateGameState(s) {
|
||||
const bar = $('live-bar');
|
||||
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 = '';
|
||||
}
|
||||
|
||||
// New round — refresh predictions
|
||||
if (s.game_no !== lastGameNo) {
|
||||
lastGameNo = 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);
|
||||
}
|
||||
|
||||
const a = s.bets?.A || 0, b = s.bets?.B || 0, c = s.bets?.C || 0;
|
||||
liveBets = {A: a, B: b, C: c};
|
||||
$('live-bet-a').textContent = fmt(a);
|
||||
$('live-bet-b').textContent = fmt(b);
|
||||
$('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();
|
||||
}
|
||||
|
||||
function updatePredCardBets() {
|
||||
const cards = document.querySelectorAll('.pred-card');
|
||||
cards.forEach((card, i) => {
|
||||
const c = CHAIRS[i];
|
||||
const betEl = card.querySelector('.bet-val');
|
||||
if (betEl) betEl.textContent = fmt(liveBets[c] || 0);
|
||||
});
|
||||
}
|
||||
|
||||
function onRoundResult(data) {
|
||||
const winner = data.winner_name || CHAIR_MAP[data.winner] || '?';
|
||||
const flash = $('result-flash');
|
||||
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`;
|
||||
}
|
||||
|
||||
// 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 => {
|
||||
predictionData = data;
|
||||
renderPrediction(data);
|
||||
renderLast20(data.last_20_predictions);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// ── Init ──
|
||||
fetch('/api/predictions')
|
||||
.then(r => r.json())
|
||||
.then(render)
|
||||
.catch(err => { $('loading').textContent = 'Failed to load: ' + err.message; });
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user