Loading prediction data...
+
+
@@ -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 `
`;
}).join('');
@@ -269,9 +379,37 @@ function renderPrediction(data) {
CHAIRS.map(c => `${pct(data.prediction[c])} `).join('') + '';
}
+function renderLast20(predictions) {
+ const tbody = $('pred-history').querySelector('tbody');
+ if (!predictions || predictions.length === 0) {
+ tbody.innerHTML = 'Not enough data ';
+ 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 `
+ #${p.game_no}
+ ${p.predicted}
+ ${CHAIRS.map(c => {
+ const w = p.probs[c] / maxProb * 40;
+ return ` ${pct(p.probs[c])} `;
+ }).join('')}
+ ${p.actual}
+ ${p.correct ? 'HIT' : 'MISS'}
+ `;
+ }).join('') +
+ `
+ Accuracy (last ${predictions.length})
+
+ ${(correctCount/predictions.length*100).toFixed(1)}% (${correctCount}/${predictions.length})
+
+ `;
+}
+
function renderMarkov1(m) {
const t = $('markov1-table');
- let html = 'From \\ To ' + CHAIRS.map(c => `→${c} `).join('') + ' ';
+ let html = 'From \\ To ' + CHAIRS.map(c => `\u2192${c} `).join('') + ' ';
for (const src of CHAIRS) {
html += `${src} `;
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 = 'From \\ To ' + CHAIRS.map(c => `→${c} `).join('') + ' ';
+ let html = 'From \\ To ' + CHAIRS.map(c => `\u2192${c} `).join('') + ' ';
for (const key of keys) {
html += `${key} `;
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) {
+
+
+
+
+
+
Round —
+ —
+
+
+
+ A: 0
+ B: 0
+ C: 0
+ Pot: 0
+
-
+
+ Bayesian Next-Chair Prediction
+ Bayesian Next-Chair Prediction
Signal Breakdown
@@ -137,6 +212,17 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
+
+
Last 20 Predictions vs Actual
+
+
+
+
+ | Game | Predicted | P(A) | P(B) | P(C) | Actual | Result |
|---|
Markov Transition Matrices
@@ -211,7 +297,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
Top 20 Cards in Winning Hands
-
+
Chair ${c}
${pct(p)}
${isBest ? 'Recommended
' : ''}
+
+
Current Bet
+ ${fmt(bet)}
+ Observed Runs
${runs.runs}
Expected Runs
- ${runs.expected_runs || '—'}
${runs.expected_runs || '\u2014'}
Z-Score
${runs.z_score}
p-value
@@ -477,8 +611,11 @@ function renderSuits(s) {
}
function renderWinningCards(wc) {
+ if (!wc || wc.length === 0) {
+ $('winning-cards-chart').parentElement.innerHTML = 'No card data available
';
+ 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();