// 統計: プレイスタイル(タイト/ルーズ × アグレッシブ/パッシブ)と、VPIP・PFR などの数字。
// まとめの画面と、テーブルの種類ごとにすべての数字を出す画面(?v=all)。数字の「?」で段階と説明のシートを開く
(function () {
'use strict';
const { Store, esc, api, toast } = App;
const $ = (id) => document.getElementById(id);
const clamp = Fluid.clamp;
Fluid.navBar($('nav'), $('title'));
let data = null; // { since, modes: { total, chips, friends, cpu } }
let mode = 'total';
// ---------- 段階と色 ----------
// upper: 各段階の上限(整数の %)。最後の段階は上限なし。tone: 段階の色の並び
const STYLE = ['ニット', 'タイト', 'バランス', 'ルーズ', 'フィッシュ'];
const AGGR = ['マウス', 'パッシブ', 'バランス', 'アグレッシブ', 'マニアック'];
const QUALITY = ['低い', '平均', '良い', '高い'];
const QUALITY_TOP = ['低い', '平均', '良い', '優秀'];
const ICON = {
vpip: '✌️',
pfr: '🤞',
ats: '🫳',
threeBet: '☝️',
wtsd: '🤘',
wsd: '🫰',
wwsf: '👌',
fold: '🙅',
afq: '👊',
hands: '✋',
net: '💰',
time: '⏱️',
};
const STATS = {
vpip: {
short: 'VPIP',
title: '自発的な参加率(VPIP)',
upper: [10, 20, 30, 40],
labels: STYLE,
tone: 'style',
sample: (m) => `${m.hands}ハンド`,
desc: 'プリフロップで自分からチップを入れた(コール・レイズした)ハンドの割合です。BB のチェックは含みません。いちばん基本の数字で、たくさんの手で参加するか(ルーズ)、強い手だけで参加するか(タイト)が分かります。',
},
pfr: {
short: 'PFR',
title: 'プリフロップレイズ(PFR)',
upper: [5, 12, 20, 30],
labels: AGGR,
tone: 'style',
sample: (m) => `${m.hands}ハンド`,
desc: 'プリフロップでコールではなくレイズしたハンドの割合です。VPIP と組み合わせると打ち方が分かります。よくレイズする人は相手に圧力をかけ、ハンドの主導権を握ります。',
},
ats: {
short: 'ATS',
title: 'スチールの試み(ATS)',
upper: [15, 25, 40, 55],
labels: AGGR,
tone: 'style',
sample: (m) => `機会 ${m.atsOpp}回`,
desc: '全員が降りて CO・BTN・SB の自分に回ってきたときに、レイズした割合です。後ろの位置の有利を使ってブラインドを奪いにいけているかが分かります。',
},
threeBet: {
short: '3BET',
title: 'スリーベット(3BET)',
upper: [2, 4, 7, 12],
labels: AGGR,
tone: 'style',
sample: (m) => `機会 ${m.threeBetOpp}回`,
desc: 'プリフロップで誰かのレイズに直面したときに、リレイズした割合です。強い手でポットを大きくしたり、ブラフで圧力をかけたりする積極的なプレイで、相手のレイズへの向き合い方が分かります。',
},
wtsd: {
short: 'WTSD',
title: 'ショーダウン到達率(WTSD)',
upper: [20, 24, 28, 32],
labels: STYLE,
tone: 'style',
sample: (m) => `フロップを見た ${m.sawFlop}回`,
desc: 'フロップを見たハンドのうち、ショーダウンまで行った割合です。最後まで手を見に行くか、途中で降りるかの傾向が分かります。高すぎるとコールしすぎ、低すぎると降りすぎの目安です。',
},
wsd: {
short: 'W$SD',
title: 'ショーダウン勝率(W$SD)',
upper: [40, 45, 56],
labels: QUALITY,
tone: 'quality',
sample: (m) => `ショーダウン ${m.showdowns}回`,
desc: 'ショーダウンまで行ったハンドのうち、ポットを取った(山分けを含む)割合です。ショーダウンに持っていく手の質が分かります。高いほど、強い手でバリューを取り、弱い手でのショーダウンを避けられています。',
},
wwsf: {
short: 'WWSF',
title: 'フロップ後の勝率(WWSF)',
upper: [37, 42, 47],
labels: QUALITY_TOP,
tone: 'quality',
sample: (m) => `フロップを見た ${m.sawFlop}回`,
desc: 'フロップを見たハンドのうち、ポットを取った割合です。相手を降ろして取った場合もショーダウンで勝った場合も含みます。フロップ以降の上手さを総合的に表す数字です。',
},
afq: {
short: 'AFq',
title: 'アグレッション頻度(AFq)',
upper: [20, 40, 60, 80],
labels: AGGR,
tone: 'style',
sample: (m) => `${m.hands}ハンド`,
desc: '自分の行動(フォールド・チェック・コール・ベット・レイズ)のうち、ベット・レイズの割合です。積極的に打つと相手に圧力がかかり、ショーダウンなしで取れるポットが増え、強い手でポットを大きくできます。',
},
hands: {
short: 'ハンド',
title: 'ハンドの勝率',
upper: [8, 14, 20],
labels: QUALITY_TOP,
tone: 'quality',
sample: (m) => `${m.hands}ハンド`,
desc: '配られたハンドのうち、ポットを取った(山分けを含む)割合と、プレイしたハンド数・勝ったハンド数です。プレイの量と結果の移り変わりを追えます。',
},
fold: {
short: 'フォールド率',
title: 'フォールド率',
sample: (m) => `${m.hands}ハンド`,
desc: '各ストリート(プリフロップ・フロップ・ターン・リバー)で自分が判断したハンドのうち、降りた割合です。すぐにあきらめすぎていないか、逆にコールしすぎていないかが分かります。ストリートごとのバランスを見てみましょう。',
},
net: {
short: '収支',
title: '収支(BB)',
sample: (m) => `${m.hands}ハンド`,
desc: 'チップの増減をそのハンドの BB の何倍かに直して合計したものです。ブラインドの大きさが違うテーブルの結果もまとめて比べられます。「100ハンドあたり」は勝ちの速さの目安です(BB/100)。',
},
time: {
short: 'プレイ時間',
title: 'プレイ時間',
sample: (m) => `${m.hands}ハンド`,
desc: 'ハンドを遊んでいた時間の合計です。続けて遊んでいるあいだ(前のハンドから3分以内)は、ハンドの間の待ち時間も含めます。',
},
};
const MODE_LABEL = { total: '合計', chips: 'キャッシュ', friends: '友人戦', cpu: 'CPU戦' };
const STREETS = ['プリフロップ', 'フロップ', 'ターン', 'リバー'];
// 値(%)がどの段階か。端数は四捨五入してから比べる
function bandOf(def, v) {
const r = Math.round(v);
const i = def.upper.findIndex((u) => r <= u);
return i < 0 ? def.upper.length : i;
}
const rangeText = (def, i) => {
const lo = i === 0 ? 0 : def.upper[i - 1] + 1;
return i < def.upper.length ? `${lo}–${def.upper[i]}` : `${lo}+`;
};
const toneClass = (def, i) => `tone-${def.tone}-${i}`;
const pctNum = (v) => (v == null ? '—' : String(Math.round(v)));
const bb = (v) => `${v > 0 ? '+' : v < 0 ? '−' : '±'}${Math.abs(Math.round(v * 10) / 10).toLocaleString('ja-JP')}`;
// 段階のバー(カード用): 今の段階まで塗り、今の段階は中の位置まで塗る
function bandBar(def, v) {
const n = def.upper.length + 1;
if (v == null) return `
${''.repeat(n)}
`;
const band = bandOf(def, v);
const segs = [];
for (let i = 0; i < n; i++) {
let fill = 0;
if (i < band) fill = 1;
else if (i === band) {
const lo = i === 0 ? 0 : def.upper[i - 1];
const width = i < def.upper.length ? def.upper[i] - lo : def.upper[i - 1] - (i >= 2 ? def.upper[i - 2] : 0);
fill = v > 0 ? clamp((v - lo) / width, 0.12, 1) : 0;
}
segs.push(``);
}
return `${segs.join('')}
`;
}
// 段階の一覧(説明シート用): 名前・バー・範囲。今の段階だけ塗る
function bandScale(def, v) {
const band = v == null ? -1 : bandOf(def, v);
const cols = def.labels
.map(
(label, i) => `
${label}
${rangeText(def, i)}
`
)
.join('');
return `${cols}
`;
}
// ---------- 図形(SVG) ----------
// 角度は真上を 0 度として時計回り
function arcPath(cx, cy, r, a0, a1) {
const p = (a) => [cx + r * Math.sin((a * Math.PI) / 180), cy - r * Math.cos((a * Math.PI) / 180)];
const [x0, y0] = p(a0);
const [x1, y1] = p(a1);
return `M${x0.toFixed(2)} ${y0.toFixed(2)}A${r} ${r} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`;
}
// 小さなメーター(フォールド率): 270度の弧
function miniGauge(v) {
const track = arcPath(40, 40, 32, -135, 135);
const value = v ? `` : '';
return ``;
}
// 区切りのある輪(アグレッション頻度・勝率)。値の段階の色で塗る
function ring(v, def) {
const SEGS = 6;
const START = -150;
const SPAN = 300;
const GAP = 7;
const size = SPAN / SEGS;
const tone = v == null ? '' : toneClass(def, bandOf(def, v));
let out = '';
for (let i = 0; i < SEGS; i++) {
const a0 = START + i * size + GAP / 2;
const a1 = START + (i + 1) * size - GAP / 2;
out += ``;
if (v == null) continue;
const f = clamp(((v / 100) * SPAN - i * size) / size, 0, 1);
if (f > 0) out += ``;
}
return ``;
}
// ---------- プレイスタイル ----------
// ルーズさ = VPIP(25% でちょうど真ん中)、積極性 = PFR ÷ VPIP(半分でちょうど真ん中)
const TYPES = [
{
id: 'TAG',
chip: 'TAG',
emoji: '🦅',
name: 'TAG',
full: 'タイト・アグレッシブ',
desc: '参加するハンドは少ないものの、参加したら強気にベット・レイズする打ち方です。強い手をじっくり待ち、来たら攻めます。いちばん勝ちやすいスタイルの一つとされますが、手の選び方が硬すぎると相手に読まれやすくなります。',
},
{
id: 'LAG',
chip: 'LAG',
emoji: '🐒',
name: 'LAG',
full: 'ルーズ・アグレッシブ',
desc: '広い範囲のハンドで参加し、大きなベットもためらわない打ち方です。何を持っているか読みにくく、良い手でもタイミングの良いブラフでもポットを取れます。大きく勝てる可能性がある一方、結果の振れ幅が大きく、高い技術が必要です。',
},
{
id: 'ROCK',
chip: 'ロック',
emoji: '🦥',
name: 'ロック',
full: 'タイト・パッシブ',
desc: 'めったに参加せず、ベットよりコールを好むとても慎重な打ち方です。ブラフで降ろすのは難しい相手ですが、強い手で取りきるチャンスを逃しがちです。負けは小さく抑えられますが勝ちも伸びにくく、とてもルーズな卓でいちばん効果を発揮します。',
},
{
id: 'FISH',
chip: 'フィッシュ',
emoji: '🐟',
name: 'フィッシュ',
full: 'ルーズ・パッシブ',
desc: 'たくさんのハンドに参加するものの、自分からベット・レイズすることは少ない打ち方です。微妙な手でもコールし、ドローを追いかけがちです。たまに大きな手を当てますが、長い目で見ると負けやすいスタイルです。ブラフが効きにくいので、相手にすると厄介でもあります。',
},
];
function playStyle(m) {
if (!m || m.vpip == null || m.pfr == null) return null;
const loose = clamp(m.vpip / 50, 0.12, 0.92);
const aggr = clamp(m.vpip > 0 ? m.pfr / m.vpip : 0, 0.12, 0.92);
const id = loose >= 0.5 ? (aggr >= 0.5 ? 'LAG' : 'FISH') : aggr >= 0.5 ? 'TAG' : 'ROCK';
return { loose, aggr, id };
}
// 菱形のレーダー(上: アグレッシブ、右: ルーズ、下: パッシブ、左: タイト)と、四隅のスタイル
function styleCardHtml(m) {
const st = playStyle(m);
const W = 360;
const H = 300;
const cx = W / 2;
const cy = 150;
const R = 92;
const pt = (dir, f) => {
const d = { top: [0, -1], right: [1, 0], bottom: [0, 1], left: [-1, 0] }[dir];
return [cx + d[0] * R * f, cy + d[1] * R * f];
};
const diamond = (f) => ['top', 'right', 'bottom', 'left'].map((d) => pt(d, f).map((x) => x.toFixed(1)).join(',')).join(' ');
const grid = [1 / 3, 2 / 3]
.map((f) => ``)
.concat(``)
.join('');
const axes = ``;
// 四隅のスタイル
const CHIP_W = 74;
const CHIP_H = 34;
const corners = { TAG: [14, 16], LAG: [W - 14 - CHIP_W, 16], ROCK: [14, H - 16 - CHIP_H], FISH: [W - 14 - CHIP_W, H - 16 - CHIP_H] };
const chips = TYPES.map((t) => {
const [x, y] = corners[t.id];
const on = st && st.id === t.id;
return `
${esc(t.chip)}`;
}).join('');
let shape = '';
let link = '';
if (st) {
const poly = [pt('top', st.aggr), pt('right', st.loose), pt('bottom', 1 - st.aggr), pt('left', 1 - st.loose)];
const points = poly.map((p) => p.map((x) => x.toFixed(1)).join(',')).join(' ');
shape = ``;
// 自分のスタイルの隅へ線を引く(菱形の、その隅の側の辺の中点から)
const side = { TAG: [0, 3], LAG: [0, 1], ROCK: [2, 3], FISH: [1, 2] }[st.id];
const from = [(poly[side[0]][0] + poly[side[1]][0]) / 2, (poly[side[0]][1] + poly[side[1]][1]) / 2];
const [x, y] = corners[st.id];
const to = [st.id === 'TAG' || st.id === 'ROCK' ? x + CHIP_W - 8 : x + 8, st.id === 'TAG' || st.id === 'LAG' ? y + CHIP_H - 6 : y + 6];
link = ``;
}
const label = (x, y, cls, a, b) =>
`${a}${b}`;
return `
${st ? '' : 'まだデータがありません
'}`;
}
// ---------- カード ----------
const helpBtn = (key) =>
``;
const head = (key, label = STATS[key].short) =>
`${ICON[key]}${esc(label)}${helpBtn(key)}
`;
const value = (v, unit = '%') => `${pctNum(v)}${v == null ? '' : `${unit}`}
`;
function metricCard(key, m, compact = false) {
const def = STATS[key];
const v = m[key];
return `${head(key)}${value(v)}${bandBar(def, v)}
${v == null ? 'データなし' : def.labels[bandOf(def, v)]}
`;
}
function liquidCard(key, m) {
const v = m[key];
return ``;
}
// ストリートごとのフォールド率(4つのメーター)
const foldMinis = (m) =>
`${m.fold
.map(
(v, i) =>
`
${miniGauge(v)}${pctNum(v)}${v == null ? '' : '%'}
${STREETS[i]}`
)
.join('')}
`;
function foldCard(m) {
return `${head('fold')}${foldMinis(m)}
`;
}
function ringCard(key, m, v, sub, rows) {
const def = STATS[key];
const list = rows.map(([k, val]) => `${k}${val}
`).join('');
return `${head(key)}
${ring(v, def)}
${value(v)}${esc(sub)}
${list}
`;
}
function netCard(m) {
const rows = [
['100ハンドあたり', m.bb100 == null ? '—' : `${bb(m.bb100)} BB`],
['プレイ時間', `${m.hours} 時間`],
];
return `${head('net')}
${m.hands ? bb(m.netBB) : '—'}BB
${rows.map(([k, val]) => `
${k}${val}
`).join('')}
`;
}
// 塗る量・水位は CSSOM で入れる(CSP でインラインスタイルを禁止しているため)。
// 入れた直後は 0 から伸ばす
function paintFills(root) {
const fills = root.querySelectorAll('[data-fill]');
const levels = root.querySelectorAll('[data-level]');
requestAnimationFrame(() =>
requestAnimationFrame(() => {
for (const el of fills) el.style.transform = `scaleX(${el.dataset.fill})`;
for (const el of levels) el.style.setProperty('--level', `${el.dataset.level}%`);
})
);
}
// ---------- まとめ ----------
function renderSummary() {
const m = data.modes.total;
$('style-card').innerHTML = styleCardHtml(m);
const since = data.since ? `${new Date(data.since).toLocaleDateString('ja-JP')} から` : 'まだ記録がありません';
$('summary-tiles').innerHTML = `
${metricCard('vpip', m, true)}
${head('time', 'プレイ時間')}
${m.hours}時間
${since}
${metricCard('pfr', m, true)}
${metricCard('afq', m, true)}`;
paintFills($('summary-tiles'));
}
// ---------- すべての統計 ----------
function renderAll() {
const m = data.modes[mode];
for (const b of $('mode-tabs').querySelectorAll('[data-mode]')) b.classList.toggle('active', b.dataset.mode === mode);
modeSeg.update();
const afqRows = STREETS.map((s, i) => [s, m.afqStreets[i] == null ? '—' : `${pctNum(m.afqStreets[i])}%`]);
$('all-body').innerHTML = `
${m.hands ? '' : `${MODE_LABEL[mode] === '合計' ? 'まだ' : `${MODE_LABEL[mode]}では`}記録がありません。テーブルで遊ぶとハンドごとに記録されます。
`}
${metricCard('vpip', m)}${metricCard('pfr', m)}${metricCard('ats', m)}${metricCard('threeBet', m)}
${liquidCard('wtsd', m)}${liquidCard('wsd', m)}${liquidCard('wwsf', m)}
${foldCard(m)}
${ringCard('afq', m, m.afq, m.afq == null ? 'データなし' : STATS.afq.labels[bandOf(STATS.afq, m.afq)], afqRows)}
${ringCard('hands', m, m.winRate, '勝率', [['プレイ', m.hands.toLocaleString('ja-JP')], ['勝ち', m.won.toLocaleString('ja-JP')]])}
${netCard(m)}`;
paintFills($('all-body'));
}
const modeSeg = Fluid.Segmented($('mode-tabs'));
$('mode-tabs').addEventListener('click', (e) => {
const b = e.target.closest('[data-mode]');
if (!b || b.dataset.mode === mode) return;
mode = b.dataset.mode;
Fluid.haptic(6);
renderAll();
});
// ---------- 説明シート ----------
const infoSheet = Fluid.Sheet($('info-modal'));
let shareText = '';
function openInfo(key) {
if (!data) return;
const def = STATS[key];
const m = data.modes[$('view-all').hidden ? 'total' : mode];
const where = $('view-all').hidden || mode === 'total' ? '' : `(${MODE_LABEL[mode]})`;
let body = '';
let v = null;
if (key === 'fold') {
body = foldMinis(m);
shareText = `Hold'em Night のフォールド率${where}: ${STREETS.map((s, i) => `${s} ${pctNum(m.fold[i])}%`).join(' / ')}`;
} else if (key === 'net') {
body = `${m.hands ? `${bb(m.netBB)} BB` : '—'}
`;
shareText = `Hold'em Night の収支${where}: ${bb(m.netBB)} BB(${m.hands}ハンド)`;
} else if (key === 'time') {
body = `${m.hours} 時間
`;
shareText = `Hold'em Night のプレイ時間: ${m.hours} 時間(${m.hands}ハンド)`;
} else {
v = key === 'hands' ? m.winRate : m[key];
body = `${pctNum(v)}${v == null ? '' : '%'}
${bandScale(def, v)}`;
shareText =
v == null
? `Hold'em Night の ${def.title}${where}: まだデータがありません`
: `Hold'em Night の ${def.title}${where}: ${Math.round(v)}%(${def.labels[bandOf(def, v)]})`;
}
$('info-body').innerHTML = `
${ICON[key]}
${esc(def.title)}
${body}
${esc(def.desc)}
${esc(MODE_LABEL[$('view-all').hidden ? 'total' : mode])} ・ ${esc(def.sample(m))}
`;
paintFills($('info-body'));
infoSheet.open();
}
document.addEventListener('click', (e) => {
const b = e.target.closest('[data-info]');
if (b) openInfo(b.dataset.info);
});
async function share(text) {
if (!text) return;
if (navigator.share) {
try {
await navigator.share({ text });
return;
} catch (err) {
if (err && err.name === 'AbortError') return; // 共有シートを閉じた
}
}
try {
if (await App.copyText(() => Promise.resolve(text))) toast('コピーしました。友達に送れます');
} catch {
toast('コピーできませんでした');
}
}
$('info-share').addEventListener('click', () => share(shareText));
// ---------- プレイスタイルのシート(横に送る) ----------
const typeSheet = Fluid.Sheet($('type-modal'));
$('type-carousel').innerHTML = TYPES.map(
(t) => `
${t.emoji}
${esc(t.name)}
(${esc(t.full)})
${esc(t.desc)}
あなたのいまのスタイル
`
).join('');
$('type-dots').innerHTML = TYPES.map(() => '').join('');
const typeCards = [...$('type-carousel').children];
function currentType() {
const c = $('type-carousel');
return clamp(Math.round(c.scrollLeft / (typeCards[1].offsetLeft - typeCards[0].offsetLeft || 1)), 0, TYPES.length - 1);
}
function syncDots() {
const i = currentType();
[...$('type-dots').children].forEach((d, k) => d.classList.toggle('on', k === i));
}
$('type-carousel').addEventListener('scroll', syncDots, { passive: true });
function openTypes(id) {
const st = data && playStyle(data.modes.total);
for (const el of typeCards) el.querySelector('.st-type-mine').hidden = !(st && st.id === el.dataset.type);
typeSheet.open();
const i = Math.max(0, TYPES.findIndex((t) => t.id === (id || (st && st.id))));
const c = $('type-carousel');
c.scrollLeft = typeCards[i].offsetLeft - (c.clientWidth - typeCards[i].offsetWidth) / 2;
syncDots();
}
$('style-card').addEventListener('click', (e) => {
const chip = e.target.closest && e.target.closest('[data-type]');
openTypes(chip ? chip.dataset.type : null);
});
$('style-card').addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
openTypes(null);
});
$('type-share').addEventListener('click', () => {
const t = TYPES[currentType()];
const st = data && playStyle(data.modes.total);
share(
st && st.id === t.id
? `Hold'em Night での私のプレイスタイルは「${t.name}(${t.full})」${t.emoji} VPIP ${pctNum(data.modes.total.vpip)}% / PFR ${pctNum(data.modes.total.pfr)}%`
: `${t.emoji} ${t.name}(${t.full}): ${t.desc}`
);
});
// ---------- 画面の切り替え(?v=all ですべての統計) ----------
function route() {
const all = new URLSearchParams(location.search).get('v') === 'all';
$('view-summary').hidden = all;
$('view-all').hidden = !all;
$('title').textContent = all ? 'すべての統計' : '統計';
$('back').href = all ? '/stats' : '/';
$('back-label').textContent = all ? '統計' : 'ロビー';
if (!data) return;
if (all) renderAll();
else renderSummary();
}
window.addEventListener('popstate', () => {
route();
window.scrollTo(0, 0);
});
$('see-all').addEventListener('click', () => {
history.pushState({ fromSummary: true }, '', '/stats?v=all');
route();
window.scrollTo(0, 0);
});
$('back').addEventListener('click', (e) => {
if ($('view-all').hidden) return; // ロビーへはそのまま移る
e.preventDefault();
if (history.state && history.state.fromSummary) history.back();
else {
history.replaceState(null, '', '/stats');
route();
}
window.scrollTo(0, 0);
});
// ---------- 読み込み ----------
async function load() {
const btn = $('refresh');
btn.classList.add('spinning');
btn.disabled = true;
try {
data = await api('POST', '/api/stats', { token: Store.token });
route();
} catch (err) {
toast(err.message);
} finally {
btn.disabled = false;
btn.classList.remove('spinning');
}
}
$('refresh').addEventListener('click', load);
route();
load();
})();