add pattern analysis feature with web dashboard and CLI
New /patterns page with 9 analyses: chair win bias, bet rank correlations, hand type distributions, pot size buckets, streaks, hourly patterns, and recent-vs-overall comparison. Also adds a standalone analyze.py CLI script for terminal output.
This commit is contained in:
443
static/patterns.html
Normal file
443
static/patterns.html
Normal file
@@ -0,0 +1,443 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Teen Patti Pattern Analysis</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;
|
||||
}
|
||||
.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; }
|
||||
.nav-links { display: flex; gap: 14px; }
|
||||
.nav-link {
|
||||
font-size: 12px; color: var(--accent); text-decoration: none;
|
||||
font-weight: 600; transition: color 0.2s;
|
||||
}
|
||||
.nav-link:hover { color: #a78bfa; }
|
||||
.content { padding: 16px 20px; max-width: 1200px; margin: 0 auto; }
|
||||
.loading {
|
||||
text-align: center; padding: 60px; color: var(--text2); font-size: 14px;
|
||||
}
|
||||
.section {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 14px; margin-bottom: 16px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: 1.2px;
|
||||
color: var(--text2); margin-bottom: 10px; font-weight: 700;
|
||||
}
|
||||
.section-desc {
|
||||
font-size: 11px; color: var(--text3); margin-bottom: 12px;
|
||||
}
|
||||
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; }
|
||||
.three-col { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; margin-bottom: 16px; }
|
||||
|
||||
/* Tables */
|
||||
.ptable { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.ptable th {
|
||||
text-align: left; padding: 6px 8px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
color: var(--text3); font-size: 10px;
|
||||
text-transform: uppercase; letter-spacing: 0.5px; font-weight: 700;
|
||||
}
|
||||
.ptable td { padding: 5px 8px; border-bottom: 1px solid #2d314830; }
|
||||
.ptable tr:hover { background: var(--surface2); }
|
||||
.ptable .num { text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; }
|
||||
.ptable .pct { text-align: right; font-variant-numeric: tabular-nums; color: var(--text2); }
|
||||
.positive { color: var(--green); }
|
||||
.negative { color: var(--red); }
|
||||
.chair-a { color: var(--chair-a); font-weight: 700; }
|
||||
.chair-b { color: var(--chair-b); font-weight: 700; }
|
||||
.chair-c { color: var(--chair-c); font-weight: 700; }
|
||||
|
||||
/* H-bars */
|
||||
.hbar-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; font-size: 13px; }
|
||||
.hbar-label { font-weight: 700; min-width: 80px; font-size: 12px; }
|
||||
.hbar-bg { flex: 1; height: 22px; border-radius: 4px; background: var(--surface3); overflow: hidden; }
|
||||
.hbar-fill { height: 100%; border-radius: 4px; transition: width 0.4s ease; min-width: 2px; }
|
||||
.hbar-value { font-weight: 700; min-width: 50px; text-align: right; font-variant-numeric: tabular-nums; font-size: 12px; }
|
||||
.hbar-pct { font-size: 11px; color: var(--text3); min-width: 45px; text-align: right; }
|
||||
|
||||
/* Chart */
|
||||
.chart-container { position: relative; height: 250px; }
|
||||
|
||||
/* Streak cards */
|
||||
.streak-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
||||
.streak-card {
|
||||
background: var(--surface2); border-radius: 8px; padding: 12px; text-align: center;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.streak-card-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px; color: var(--text3); font-weight: 700; }
|
||||
.streak-card-value { font-size: 28px; font-weight: 800; margin-top: 4px; }
|
||||
.streak-card-sub { font-size: 11px; color: var(--text2); margin-top: 2px; }
|
||||
|
||||
/* Comparison */
|
||||
.compare-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.compare-panel {
|
||||
background: var(--surface2); border-radius: 8px; padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.compare-title {
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px;
|
||||
color: var(--text2); font-weight: 700; margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.two-col, .three-col { grid-template-columns: 1fr; }
|
||||
.streak-cards { grid-template-columns: 1fr; }
|
||||
.compare-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>Pattern Analysis</h1>
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link">Live Dashboard →</a>
|
||||
<a href="/analytics" class="nav-link">Analytics →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="loading" id="loading">Loading pattern analysis...</div>
|
||||
<div id="main" style="display:none">
|
||||
|
||||
<!-- 1. Chair Win Bias -->
|
||||
<div class="two-col">
|
||||
<div class="section">
|
||||
<div class="section-title">Chair Win Bias</div>
|
||||
<div class="section-desc">Win % per chair vs expected 33.3%. Sample: <span id="bias-n">0</span> games.</div>
|
||||
<div id="chair-bias-bars"></div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Chair Win Distribution</div>
|
||||
<div class="chart-container" style="height:200px">
|
||||
<canvas id="chair-pie"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2 & 3. Bet Rank Analysis -->
|
||||
<div class="two-col">
|
||||
<div class="section">
|
||||
<div class="section-title">Winner Bet Rank</div>
|
||||
<div class="section-desc">How often does the winning chair have the highest, mid, or lowest bet?</div>
|
||||
<div id="bet-rank-bars"></div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Per-Chair: Highest Bet Win Rate</div>
|
||||
<div class="section-desc">When chair X has the highest bet, how often does X win?</div>
|
||||
<table class="ptable">
|
||||
<thead><tr><th>Chair</th><th style="text-align:right">Times Highest</th><th style="text-align:right">Wins</th><th style="text-align:right">Win %</th></tr></thead>
|
||||
<tbody id="pcr-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4 & 5. Hand Type Distribution -->
|
||||
<div class="two-col">
|
||||
<div class="section">
|
||||
<div class="section-title">Hand Type Distribution by Chair</div>
|
||||
<div class="section-desc">Are better hands dealt to certain chairs more often?</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="hand-type-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Hand Type Win Rates</div>
|
||||
<div class="section-desc">Which hand types win most often?</div>
|
||||
<div id="hand-type-wins-bars"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 6. Pot Size Buckets -->
|
||||
<div class="section">
|
||||
<div class="section-title">Win Rates by Pot Size</div>
|
||||
<div class="section-desc">Does the pot size affect which chair wins?</div>
|
||||
<table class="ptable">
|
||||
<thead>
|
||||
<tr><th>Bucket</th><th>Range</th><th style="text-align:right">Games</th>
|
||||
<th style="text-align:right">A %</th><th style="text-align:right">B %</th><th style="text-align:right">C %</th></tr>
|
||||
</thead>
|
||||
<tbody id="pot-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 7. Streak Analysis -->
|
||||
<div class="section">
|
||||
<div class="section-title">Streak Analysis</div>
|
||||
<div class="streak-cards" id="streak-cards"></div>
|
||||
</div>
|
||||
|
||||
<!-- 8. Hourly Patterns -->
|
||||
<div class="section">
|
||||
<div class="section-title">Hourly Win Patterns</div>
|
||||
<div class="section-desc">Win rates by hour of day (server time).</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="hourly-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 9. Recent vs Overall -->
|
||||
<div class="section">
|
||||
<div class="section-title">Recent (Last 100) vs All-Time</div>
|
||||
<div class="section-desc">Spot shifts in chair dominance.</div>
|
||||
<div class="compare-grid">
|
||||
<div class="compare-panel">
|
||||
<div class="compare-title">All-Time</div>
|
||||
<div id="compare-all"></div>
|
||||
</div>
|
||||
<div class="compare-panel">
|
||||
<div class="compare-title">Last 100 Games</div>
|
||||
<div id="compare-recent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
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 fmtFull = n => n == null ? '0' : Number(n).toLocaleString();
|
||||
const CHAIR_COLORS = {A:'#3b82f6', B:'#ec4899', C:'#f59e0b'};
|
||||
const HAND_ORDER = ['Trail', 'Straight Flush', 'Straight', 'Flush', 'Pair', 'High Card'];
|
||||
|
||||
function renderHBar(container, items, colorFn) {
|
||||
const total = items.reduce((s, i) => s + i.value, 0) || 1;
|
||||
const max = Math.max(...items.map(i => i.value)) || 1;
|
||||
container.innerHTML = items.map(item => {
|
||||
const pct = (item.value / max * 100).toFixed(0);
|
||||
const totalPct = (item.value / total * 100).toFixed(1);
|
||||
const color = colorFn(item.label);
|
||||
return `<div class="hbar-row">
|
||||
<span class="hbar-label" style="color:${color}">${item.label}</span>
|
||||
<div class="hbar-bg"><div class="hbar-fill" style="width:${pct}%;background:${color}"></div></div>
|
||||
<span class="hbar-value">${fmtFull(item.value)}</span>
|
||||
<span class="hbar-pct">${totalPct}%</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderComparePanel(el, dist, total) {
|
||||
const items = ['A','B','C'].map(ch => ({label: ch, value: dist[ch] || 0}));
|
||||
renderHBar(el, items, l => CHAIR_COLORS[l]);
|
||||
const note = document.createElement('div');
|
||||
note.style.cssText = 'font-size:10px;color:var(--text3);margin-top:6px';
|
||||
note.textContent = `${total} games`;
|
||||
el.appendChild(note);
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
// 1. Chair win bias
|
||||
$('bias-n').textContent = fmtFull(data.chair_bias.total_games);
|
||||
const biasItems = ['A','B','C'].map(ch => ({
|
||||
label: `Chair ${ch}`, value: data.chair_bias[ch].wins,
|
||||
}));
|
||||
renderHBar($('chair-bias-bars'), biasItems, l => CHAIR_COLORS[l.replace('Chair ', '')]);
|
||||
|
||||
// Chair pie
|
||||
new Chart($('chair-pie'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['A','B','C'],
|
||||
datasets: [{
|
||||
data: ['A','B','C'].map(ch => data.chair_bias[ch].wins),
|
||||
backgroundColor: ['#3b82f6','#ec4899','#f59e0b'],
|
||||
borderWidth: 0,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { labels: { color: '#8b8fa3', font: { size: 11 } } },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: ctx => {
|
||||
const v = ctx.raw;
|
||||
const t = data.chair_bias.total_games || 1;
|
||||
return ` ${ctx.label}: ${v} (${(v/t*100).toFixed(1)}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Bet rank
|
||||
const br = data.bet_rank;
|
||||
renderHBar($('bet-rank-bars'), [
|
||||
{label: 'High Bet', value: br.high},
|
||||
{label: 'Mid Bet', value: br.mid},
|
||||
{label: 'Low Bet', value: br.low},
|
||||
], l => l.startsWith('High') ? '#f87171' : l.startsWith('Mid') ? '#fbbf24' : '#34d399');
|
||||
|
||||
// 3. Per-chair rank table
|
||||
const pcrBody = $('pcr-body');
|
||||
pcrBody.innerHTML = ['A','B','C'].map(ch => {
|
||||
const d = data.per_chair_rank[ch] || {};
|
||||
return `<tr>
|
||||
<td class="chair-${ch.toLowerCase()}">${ch}</td>
|
||||
<td class="num">${fmtFull(d.has_highest || 0)}</td>
|
||||
<td class="num">${fmtFull(d.wins || 0)}</td>
|
||||
<td class="num">${(d.win_pct || 0).toFixed(1)}%</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
// 4. Hand type distribution by chair (grouped bar chart)
|
||||
const htByChair = data.hand_types_by_chair;
|
||||
const htLabels = HAND_ORDER.filter(t =>
|
||||
(htByChair.A[t] || 0) + (htByChair.B[t] || 0) + (htByChair.C[t] || 0) > 0
|
||||
);
|
||||
new Chart($('hand-type-chart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: htLabels,
|
||||
datasets: ['A','B','C'].map(ch => ({
|
||||
label: ch,
|
||||
data: htLabels.map(t => htByChair[ch][t] || 0),
|
||||
backgroundColor: CHAIR_COLORS[ch] + '80',
|
||||
borderColor: CHAIR_COLORS[ch],
|
||||
borderWidth: 1,
|
||||
})),
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { labels: { color: '#8b8fa3', font: { size: 10 }, boxWidth: 12 } } },
|
||||
scales: {
|
||||
x: { ticks: { color: '#5a5f75', font: { size: 10 } }, grid: { color: '#2d314820' } },
|
||||
y: { ticks: { color: '#5a5f75', font: { size: 10 } }, grid: { color: '#2d314830' } },
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Hand type wins
|
||||
const htw = data.hand_type_wins;
|
||||
const htwItems = HAND_ORDER.filter(t => htw[t]).map(t => ({label: t, value: htw[t]}));
|
||||
renderHBar($('hand-type-wins-bars'), htwItems, () => '#6c5ce7');
|
||||
|
||||
// 6. Pot size buckets
|
||||
const pb = data.pot_buckets;
|
||||
const ranges = pb._ranges || {};
|
||||
const bucketOrder = ['small','medium','large','whale'];
|
||||
$('pot-body').innerHTML = bucketOrder.map(b => {
|
||||
const d = pb[b];
|
||||
if (!d) return '';
|
||||
const t = d.total || 1;
|
||||
return `<tr>
|
||||
<td style="font-weight:700;text-transform:capitalize">${b}</td>
|
||||
<td style="color:var(--text3)">${ranges[b] || ''}</td>
|
||||
<td class="num">${fmtFull(d.total)}</td>
|
||||
<td class="num" style="color:var(--chair-a)">${(d.A/t*100).toFixed(1)}%</td>
|
||||
<td class="num" style="color:var(--chair-b)">${(d.B/t*100).toFixed(1)}%</td>
|
||||
<td class="num" style="color:var(--chair-c)">${(d.C/t*100).toFixed(1)}%</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
// 7. Streaks
|
||||
const streaks = data.streaks;
|
||||
$('streak-cards').innerHTML = ['A','B','C'].map(ch => {
|
||||
const s = streaks[ch];
|
||||
return `<div class="streak-card">
|
||||
<div class="streak-card-label" style="color:${CHAIR_COLORS[ch]}">Chair ${ch}</div>
|
||||
<div class="streak-card-value" style="color:${CHAIR_COLORS[ch]}">${s.max_streak}</div>
|
||||
<div class="streak-card-sub">Max Streak</div>
|
||||
<div style="font-size:18px;font-weight:700;margin-top:8px;color:${s.current_streak >= 3 ? 'var(--green)' : 'var(--text2)'}">${s.current_streak}</div>
|
||||
<div class="streak-card-sub">Current Streak</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// 8. Hourly patterns
|
||||
const hourly = data.hourly;
|
||||
const hours = Object.keys(hourly).map(Number).sort((a,b) => a - b);
|
||||
new Chart($('hourly-chart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: hours.map(h => String(h).padStart(2, '0') + ':00'),
|
||||
datasets: ['A','B','C'].map(ch => ({
|
||||
label: ch,
|
||||
data: hours.map(h => {
|
||||
const d = hourly[String(h)];
|
||||
return d ? (d[ch] / d.total * 100) : 0;
|
||||
}),
|
||||
backgroundColor: CHAIR_COLORS[ch] + '80',
|
||||
borderColor: CHAIR_COLORS[ch],
|
||||
borderWidth: 1,
|
||||
})),
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { labels: { color: '#8b8fa3', font: { size: 10 }, boxWidth: 12 } },
|
||||
tooltip: { callbacks: { label: ctx => ` ${ctx.dataset.label}: ${ctx.raw.toFixed(1)}%` } },
|
||||
},
|
||||
scales: {
|
||||
x: { stacked: true, ticks: { color: '#5a5f75', font: { size: 9 } }, grid: { color: '#2d314820' } },
|
||||
y: {
|
||||
stacked: true, max: 100,
|
||||
ticks: { color: '#5a5f75', callback: v => v + '%', font: { size: 10 } },
|
||||
grid: { color: '#2d314830' },
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 9. Recent vs Overall
|
||||
const rva = data.recent_vs_all;
|
||||
renderComparePanel($('compare-all'), rva.all.dist, rva.all.total);
|
||||
renderComparePanel($('compare-recent'), rva.recent.dist, rva.recent.total);
|
||||
}
|
||||
|
||||
// Fetch and render
|
||||
fetch('/api/patterns')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
$('loading').textContent = 'Error: ' + data.error;
|
||||
return;
|
||||
}
|
||||
$('loading').style.display = 'none';
|
||||
$('main').style.display = 'block';
|
||||
render(data);
|
||||
})
|
||||
.catch(e => {
|
||||
$('loading').textContent = 'Failed to load: ' + e.message;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user