Add player stats, in-table ranking, host seating and review return

- Stats page (/stats): play style radar (TAG/LAG/rock/fish), VPIP, PFR,
  ATS, 3bet, WTSD, W$SD, WWSF, per-street fold rate, aggression
  frequency, hand win rate and net in BB, per table type, each with a
  bands-and-explanation sheet. Hands are counted per human player when
  they finish and stored as totals under data/stats/, keyed by a hash
  of the player ID (sent in a POST body, never in the URL).
- Ranking sheet at the table: everyone seated, ordered by stack or by
  net (stack minus buy-ins), updating live.
- The host can reorder seats by dragging (with a mini table preview and
  shuffle). Takes effect immediately between hands, otherwise at the
  start of the next hand; the dealer button stays with the same player.
- The full-session review opens in the same tab and its back button
  returns to the table it was opened from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
agomizi1cmandClaude Opus 5.5 committed 2026-10-22 16:04:33 +09:00
1 parent 49203f202c
commit dd39c043c4
18 files changed
+2699 -18

No files matched your search

+612
View File
@@ -0,0 +1,612 @@
// 統計: プレイスタイル(タイト/ルーズ × アグレッシブ/パッシブ)と、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 `<div class="st-bar">${'<span class="st-seg"></span>'.repeat(n)}</div>`;
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(`<span class="st-seg"><span class="st-seg-fill ${toneClass(def, i)}" data-fill="${fill.toFixed(3)}"></span></span>`);
}
return `<div class="st-bar">${segs.join('')}</div>`;
}
// 段階の一覧(説明シート用): 名前・バー・範囲。今の段階だけ塗る
function bandScale(def, v) {
const band = v == null ? -1 : bandOf(def, v);
const cols = def.labels
.map(
(label, i) => `
<div class="st-scale-col${i === band ? ' on' : ''}">
<span class="st-scale-label">${label}</span>
<span class="st-seg"><span class="st-seg-fill ${toneClass(def, i)}" data-fill="${i === band ? 1 : 0}"></span></span>
<span class="st-scale-range">${rangeText(def, i)}</span>
</div>`
)
.join('');
return `<div class="st-scale st-scale-${def.labels.length}">${cols}</div>`;
}
// ---------- 図形(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 ? `<path class="st-gauge-val" d="${arcPath(40, 40, 32, -135, -135 + (270 * clamp(v, 0, 100)) / 100)}"/>` : '';
return `<svg class="st-gauge" viewBox="0 0 80 80" aria-hidden="true"><path class="st-gauge-track" d="${track}"/>${value}</svg>`;
}
// 区切りのある輪(アグレッション頻度・勝率)。値の段階の色で塗る
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 += `<path class="st-ring-track" d="${arcPath(60, 60, 50, a0, a1)}"/>`;
if (v == null) continue;
const f = clamp(((v / 100) * SPAN - i * size) / size, 0, 1);
if (f > 0) out += `<path class="st-ring-val ${tone}" d="${arcPath(60, 60, 50, a0, a0 + (a1 - a0) * f)}"/>`;
}
return `<svg class="st-ring" viewBox="0 0 120 120" aria-hidden="true">${out}</svg>`;
}
// ---------- プレイスタイル ----------
// ルーズさ = 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) => `<polygon class="st-radar-ring" points="${diamond(f)}"/>`)
.concat(`<polygon class="st-radar-outer" points="${diamond(1)}"/>`)
.join('');
const axes = `<path class="st-radar-axis" d="M${cx} ${cy - R}V${cy + R}M${cx - R} ${cy}H${cx + R}"/>`;
// 四隅のスタイル
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 `<g class="st-chip${on ? ' on' : ''}" data-type="${t.id}">
<rect x="${x}" y="${y}" width="${CHIP_W}" height="${CHIP_H}" rx="12"/>
<text x="${x + CHIP_W / 2}" y="${y + CHIP_H / 2 + 5}" text-anchor="middle">${esc(t.chip)}</text></g>`;
}).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 = `<polygon class="st-radar-shape" points="${points}" fill="url(#st-grad-v)"/><polygon class="st-radar-shape" points="${points}" fill="url(#st-grad-h)"/>`;
// 自分のスタイルの隅へ線を引く(菱形の、その隅の側の辺の中点から)
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 = `<path class="st-radar-link" d="M${from[0].toFixed(1)} ${from[1].toFixed(1)}L${to[0]} ${to[1]}"/>`;
}
const label = (x, y, cls, a, b) =>
`<text class="st-axis ${cls}" x="${x}" y="${y}" text-anchor="middle">${a}</text><text class="st-axis-sub" x="${x}" y="${y + 15}" text-anchor="middle">${b}</text>`;
return `
<svg class="st-radar" viewBox="0 0 ${W} ${H}" role="img" aria-label="${st ? `プレイスタイル: ${esc(TYPES.find((t) => t.id === st.id).full)}` : 'プレイスタイル: データなし'}">
<defs>
<linearGradient id="st-grad-v" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ff4f9a"/><stop offset="1" stop-color="#3fc8f4"/>
</linearGradient>
<linearGradient id="st-grad-h" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#b45cf5" stop-opacity="0.95"/><stop offset="0.5" stop-color="#b45cf5" stop-opacity="0"/>
<stop offset="0.5" stop-color="#ffab3d" stop-opacity="0"/><stop offset="1" stop-color="#ffab3d" stop-opacity="0.95"/>
</linearGradient>
</defs>
${grid}${axes}${link}${shape}${chips}
${label(cx, 36, 'aggr', 'アグレッシブ', 'ベット・レイズが多い')}
${label(cx, H - 22, 'passive', 'パッシブ', 'コールが多い')}
${label(46, cy - 2, 'tight', 'タイト', '参加が少ない')}
${label(W - 46, cy - 2, 'loose', 'ルーズ', '参加が多い')}
</svg>
${st ? '' : '<div class="st-empty-note">まだデータがありません</div>'}`;
}
// ---------- カード ----------
const helpBtn = (key) =>
`<button type="button" class="st-help" data-info="${key}" aria-label="${esc(STATS[key].title)}の説明"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.4a2.5 2.5 0 1 1 3.4 2.3c-.6.3-1 .8-1 1.5v.3M12 16.6v.01"/></svg></button>`;
const head = (key, label = STATS[key].short) =>
`<div class="st-head"><span class="st-icon" aria-hidden="true">${ICON[key]}</span><span class="st-name">${esc(label)}</span>${helpBtn(key)}</div>`;
const value = (v, unit = '%') => `<div class="st-value">${pctNum(v)}${v == null ? '' : `<small>${unit}</small>`}</div>`;
function metricCard(key, m, compact = false) {
const def = STATS[key];
const v = m[key];
return `<div class="st-card st-metric${compact ? ' compact' : ''}">${head(key)}${value(v)}${bandBar(def, v)}
<div class="st-band">${v == null ? 'データなし' : def.labels[bandOf(def, v)]}</div></div>`;
}
function liquidCard(key, m) {
const v = m[key];
return `<div class="st-card st-liquid-card"><div class="st-liquid" data-level="${v == null ? 0 : clamp(v, 0, 100)}"></div>${head(key)}${value(v)}</div>`;
}
// ストリートごとのフォールド率(4つのメーター)
const foldMinis = (m) =>
`<div class="st-minis">${m.fold
.map(
(v, i) =>
`<div class="st-mini"><div class="st-mini-gauge">${miniGauge(v)}<span><span>${pctNum(v)}<small>${v == null ? '' : '%'}</small></span></span></div><span class="st-mini-label">${STREETS[i]}</span></div>`
)
.join('')}</div>`;
function foldCard(m) {
return `<div class="st-card">${head('fold')}${foldMinis(m)}</div>`;
}
function ringCard(key, m, v, sub, rows) {
const def = STATS[key];
const list = rows.map(([k, val]) => `<div class="st-row"><span>${k}</span><b>${val}</b></div>`).join('');
return `<div class="st-card">${head(key)}
<div class="st-split">
<div class="st-ring-box">${ring(v, def)}<div class="st-ring-text">${value(v)}<span>${esc(sub)}</span></div></div>
<div class="st-rows">${list}</div>
</div></div>`;
}
function netCard(m) {
const rows = [
['100ハンドあたり', m.bb100 == null ? '—' : `${bb(m.bb100)} BB`],
['プレイ時間', `${m.hours} 時間`],
];
return `<div class="st-card">${head('net')}
<div class="st-split">
<div class="st-net"><b class="${m.netBB > 0 ? 'plus' : m.netBB < 0 ? 'minus' : ''}">${m.hands ? bb(m.netBB) : '—'}</b><span>BB</span></div>
<div class="st-rows">${rows.map(([k, val]) => `<div class="st-row"><span>${k}</span><b>${val}</b></div>`).join('')}</div>
</div></div>`;
}
// 塗る量・水位は 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)}
<div class="st-card st-metric compact">${head('time', 'プレイ時間')}<div class="st-value">${m.hours}<small>時間</small></div><div class="st-band">${since}</div></div>
${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 ? '' : `<div class="st-card st-empty">${MODE_LABEL[mode] === '合計' ? 'まだ' : `${MODE_LABEL[mode]}では`}記録がありません。テーブルで遊ぶとハンドごとに記録されます。</div>`}
<div class="st-grid st-grid-2">
${metricCard('vpip', m)}${metricCard('pfr', m)}${metricCard('ats', m)}${metricCard('threeBet', m)}
</div>
<div class="st-grid st-grid-3">${liquidCard('wtsd', m)}${liquidCard('wsd', m)}${liquidCard('wwsf', m)}</div>
${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 = `<div class="st-info-value">${m.hands ? `${bb(m.netBB)} BB` : '—'}</div>`;
shareText = `Hold'em Night の収支${where}: ${bb(m.netBB)} BB(${m.hands}ハンド)`;
} else if (key === 'time') {
body = `<div class="st-info-value">${m.hours} 時間</div>`;
shareText = `Hold'em Night のプレイ時間: ${m.hours} 時間(${m.hands}ハンド)`;
} else {
v = key === 'hands' ? m.winRate : m[key];
body = `<div class="st-info-value">${pctNum(v)}${v == null ? '' : '<small>%</small>'}</div>${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 = `
<div class="st-info-icon" aria-hidden="true">${ICON[key]}</div>
<h2 class="st-info-title" id="info-title">${esc(def.title)}</h2>
${body}
<p class="st-info-desc">${esc(def.desc)}</p>
<p class="st-info-sample">${esc(MODE_LABEL[$('view-all').hidden ? 'total' : mode])} ・ ${esc(def.sample(m))}</p>`;
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) => `
<article class="st-type" data-type="${t.id}">
<div class="st-type-emoji" aria-hidden="true">${t.emoji}</div>
<h2 class="st-type-name">${esc(t.name)}</h2>
<div class="st-type-full">(${esc(t.full)})</div>
<p class="st-type-desc">${esc(t.desc)}</p>
<p class="st-type-mine" hidden>あなたのいまのスタイル</p>
</article>`
).join('');
$('type-dots').innerHTML = TYPES.map(() => '<span></span>').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();
})();
+877
View File
@@ -3016,6 +3016,876 @@ body.page-table {
}
}
/* ---------- 席順の変更(ホスト) ---------- */
.glass-btn:disabled {
opacity: 0.4;
cursor: default;
}
/* 次のハンドから反映する席順があるときの印 */
.glass-dot {
position: absolute;
top: 5px;
right: 5px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--orange);
box-shadow: 0 0 0 2px var(--material-thick);
}
/* 小さなテーブル(自分が一番下で時計回り) */
.seating-preview {
position: relative;
width: min(100%, 300px);
aspect-ratio: 1.45;
margin: 2px auto 6px;
}
.seating-felt {
position: absolute;
inset: 16% 14%;
border-radius: 999px;
background: radial-gradient(120% 120% at 50% 30%, var(--felt-hi), var(--felt) 55%, var(--felt-lo));
box-shadow: 0 0 0 5px #2a2a2c, var(--shadow-s);
}
.seating-dot {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
width: 64px;
transform: translate(-50%, -50%);
transition: left 0.5s var(--ease-spring), top 0.5s var(--ease-spring);
}
.seating-dot .rank-avatar {
width: 30px;
height: 30px;
font-size: 0.875rem;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.25);
}
.seating-dot .rank-avatar.bot {
border-radius: 9px;
}
.seating-dot .rank-avatar svg {
width: 17px;
height: 17px;
}
.seating-dot.me .rank-avatar {
box-shadow: 0 0 0 2px var(--sheet-bg), 0 0 0 4px var(--tint);
}
.seating-name {
max-width: 100%;
padding: 0 5px;
border-radius: 6px;
background: var(--material-thick);
font-size: 0.6875rem;
font-weight: 600;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.seating-tools {
display: flex;
align-items: center;
gap: 12px;
margin: 4px 0 10px;
}
.seating-tools .hint {
flex: 1;
margin: 0;
}
.seating-tools .btn {
flex: none;
}
.seating-list {
margin: 0;
padding: 0;
list-style: none;
overflow: visible;
}
.seat-row {
gap: 12px;
min-height: 60px;
background: var(--bg-2);
will-change: transform;
}
.seat-row:first-child {
border-radius: 14px 14px 0 0;
}
.seat-row:last-child {
border-radius: 0 0 14px 14px;
}
.seat-row:only-child {
border-radius: 14px;
}
.seat-row.me {
background: var(--tint-soft);
}
.cell.seat-row + .cell::before {
left: 64px;
}
/* つかんでいる行は持ち上げて手前に */
.seat-row.dragging {
z-index: 2;
border-radius: 14px;
box-shadow: var(--shadow-l);
}
.seat-row.dragging::before,
.seat-row.dragging + .cell::before {
opacity: 0;
}
.seat-row .cell-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.seat-pin {
flex: none;
padding: 2px 9px;
border-radius: 999px;
background: var(--fill-3);
color: var(--label-2);
font-size: 0.75rem;
font-weight: 600;
}
.seat-handle {
-webkit-appearance: none;
appearance: none;
flex: none;
width: 44px;
height: 44px;
margin: -8px -10px -8px 0;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 10px;
background: transparent;
color: var(--label-3);
cursor: grab;
touch-action: none;
}
.seat-row.dragging .seat-handle {
color: var(--label);
cursor: grabbing;
}
.seating-pending {
padding: 10px 16px 0;
}
/* ---------- テーブル内のランキング ---------- */
.ranking-list {
margin: 12px 0 0;
padding: 0;
list-style: none;
}
.rank-row {
gap: 12px;
background: var(--bg-2);
will-change: transform;
}
.rank-row.me {
background: var(--tint-soft);
}
.cell.rank-row:has(.rank-avatar) + .cell::before {
left: 98px;
}
.rank-no {
flex: none;
width: 26px;
height: 26px;
display: grid;
place-items: center;
border-radius: 50%;
font-family: var(--font-num);
font-size: 0.9375rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--label-2);
}
.rank-no.top-1 {
background: linear-gradient(180deg, #ffd96a, #e7a913);
color: #4a3200;
}
.rank-no.top-2 {
background: linear-gradient(180deg, #e9ecf0, #aeb5bf);
color: #2f343b;
}
.rank-no.top-3 {
background: linear-gradient(180deg, #f0b98a, #c47a3e);
color: #3d1f06;
}
.rank-avatar {
--hue: 210;
flex: none;
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 50%;
background: linear-gradient(180deg, hsl(var(--hue) 48% 64%), hsl(var(--hue) 46% 42%));
color: #fff;
font-size: 1rem;
font-weight: 600;
line-height: 1;
}
.rank-avatar.bot {
border-radius: 10px;
}
.rank-avatar svg {
width: 20px;
height: 20px;
fill: #fff;
}
.rank-row .cell-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.rank-value {
flex: none;
font-family: var(--font-num);
font-size: 1.0625rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.ranking-note {
padding: 8px 16px 0;
}
/* ---------- 統計 ---------- */
:root {
/* レーダーの軸の色と、自分のスタイルの印 */
--st-aggr: #e8378a;
--st-passive: #1a9fd0;
--st-tight: #9a45dd;
--st-loose: #e08700;
--st-pick: #f0896a;
--st-on-pick: #2b1206;
}
@media (prefers-color-scheme: dark) {
:root {
--st-aggr: #ff4f9a;
--st-passive: #3fc8f4;
--st-tight: #c278ff;
--st-loose: #ffab3d;
}
}
/* 段階の色: スタイル(参加・積極性)は緑 → ピンク、成績(勝率など)は赤 → 緑 */
.tone-style-0 { --tone: #5fd38f; --tone-2: #85e0aa; }
.tone-style-1 { --tone: #8fcfa3; --tone-2: #aac9b0; }
.tone-style-2 { --tone: #b8b0b9; --tone-2: #c9a3b9; }
.tone-style-3 { --tone: #d48db5; --tone-2: #e07fb1; }
.tone-style-4 { --tone: #e5649f; --tone-2: #f04a95; }
.tone-quality-0 { --tone: #ef5f73; --tone-2: #e7998f; }
.tone-quality-1 { --tone: #e5a564; --tone-2: #e2c17c; }
.tone-quality-2 { --tone: #a9cf74; --tone-2: #96d98c; }
.tone-quality-3 { --tone: #4fcd88; --tone-2: #3cc38a; }
.nav-action {
-webkit-appearance: none;
appearance: none;
grid-column: 3;
justify-self: end;
width: 44px;
height: 44px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 10px;
background: transparent;
color: var(--tint);
cursor: pointer;
transition: opacity 0.25s;
}
.nav-action:active {
opacity: 0.4;
transition: none;
}
.nav-action.spinning .i {
animation: spin 0.8s linear infinite;
}
.st-card {
position: relative;
padding: 14px 16px 16px;
border-radius: 18px;
background: var(--bg-2);
box-shadow: inset 0 0 0 1px var(--fill-3);
}
.st-grid {
display: grid;
gap: 12px;
}
.st-grid-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.st-grid-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
#all-body > * {
margin-top: 12px;
}
.st-tabs {
margin-bottom: 4px;
}
.st-empty {
color: var(--label-2);
font-size: 0.9375rem;
text-align: center;
}
.st-foot {
margin: 20px 4px 0;
}
/* 見出し(アイコン・名前・?) */
.st-head {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
color: var(--label-2);
font-size: 0.875rem;
font-weight: 500;
}
.st-icon {
font-size: 0.9375rem;
line-height: 1;
filter: grayscale(1);
opacity: 0.85;
}
.st-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.st-help {
-webkit-appearance: none;
appearance: none;
flex: none;
width: 30px;
height: 30px;
margin: -6px -8px -6px 0;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--label-3);
cursor: pointer;
}
.st-help .i {
width: 19px;
height: 19px;
stroke-width: 1.7;
}
.st-help:active {
background: var(--fill-3);
}
.st-value {
position: relative;
z-index: 1;
margin-top: 6px;
font-family: var(--font-num);
font-size: 2.75rem;
font-weight: 500;
line-height: 1.05;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.st-value small {
margin-left: 2px;
font-size: 1.0625rem;
font-weight: 500;
letter-spacing: 0;
}
.st-metric.compact .st-value {
font-size: 1.75rem;
}
.st-metric.compact .st-value small {
font-size: 0.9375rem;
}
.st-band {
margin-top: 8px;
font-size: 0.9375rem;
font-weight: 500;
}
.st-metric.compact .st-band {
margin-top: 6px;
font-size: 0.8125rem;
color: var(--label-2);
}
/* 段階のバー */
.st-bar {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: 4px;
margin-top: 12px;
}
.st-seg {
display: block;
height: 6px;
border-radius: 3px;
background: var(--fill-2);
overflow: hidden;
}
.st-seg-fill {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--tone), var(--tone-2));
transform: scaleX(0);
transform-origin: left center;
transition: transform 0.7s var(--ease-spring);
}
/* 水位で見せるタイル(WTSD・W$SD・WWSF) */
.st-liquid-card {
min-height: 118px;
overflow: hidden;
padding: 12px 12px 14px;
}
/* 3つ並ぶので見出しを詰める(WTSD などが省略されないように) */
.st-liquid-card .st-head {
gap: 4px;
font-size: 0.8125rem;
}
.st-liquid-card .st-icon {
font-size: 0.8125rem;
}
.st-liquid-card .st-name {
overflow: visible;
text-overflow: clip;
}
.st-liquid-card .st-help {
width: 24px;
height: 24px;
margin: -4px -6px -4px 0;
}
.st-liquid-card .st-help .i {
width: 17px;
height: 17px;
}
.st-liquid-card .st-value {
font-size: 2rem;
}
.st-liquid-card .st-value small {
font-size: 0.875rem;
}
.st-liquid {
--level: 0%;
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--level);
background: var(--fill-2);
transition: height 1s var(--ease-spring);
}
.st-liquid::before {
content: "";
position: absolute;
left: 0;
bottom: 100%;
width: 200%;
height: 8px;
background: inherit;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 8' preserveAspectRatio='none'%3E%3Cpath d='M0 4C15 0 15 0 30 4S45 8 60 4 75 0 90 4 105 8 120 4V8H0z'/%3E%3C/svg%3E") repeat-x;
-webkit-mask-size: 50% 100%;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 8' preserveAspectRatio='none'%3E%3Cpath d='M0 4C15 0 15 0 30 4S45 8 60 4 75 0 90 4 105 8 120 4V8H0z'/%3E%3C/svg%3E") repeat-x;
mask-size: 50% 100%;
animation: st-wave 6s linear infinite;
}
@keyframes st-wave {
to {
transform: translateX(-50%);
}
}
/* 小さなメーター(フォールド率) */
.st-minis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 4px;
margin-top: 10px;
text-align: center;
}
.st-mini-gauge {
position: relative;
width: 100%;
max-width: 76px;
margin: 0 auto;
aspect-ratio: 1;
}
.st-gauge {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.st-gauge path {
fill: none;
stroke-width: 6;
stroke-linecap: round;
}
.st-gauge-track {
stroke: var(--fill);
}
.st-gauge-val {
stroke: var(--label);
}
.st-mini-gauge > span {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-family: var(--font-num);
font-size: 1.0625rem;
font-variant-numeric: tabular-nums;
}
.st-mini-gauge small {
font-size: 0.6875rem;
}
.st-mini-label {
display: block;
margin-top: -2px;
font-size: 0.8125rem;
color: var(--label-2);
}
/* 輪と一覧(アグレッション頻度・ハンド・収支) */
.st-split {
display: grid;
grid-template-columns: minmax(0, 132px) minmax(0, 1fr);
gap: 16px;
align-items: center;
margin-top: 8px;
}
.st-ring-box {
position: relative;
aspect-ratio: 1;
}
.st-ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.st-ring path {
fill: none;
stroke-width: 8;
stroke-linecap: round;
}
.st-ring-track {
stroke: var(--fill-2);
}
.st-ring-val {
stroke: var(--tone);
}
.st-ring-text {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.st-ring-text .st-value {
margin: 0;
font-size: 2rem;
}
.st-ring-text .st-value small {
font-size: 0.875rem;
}
.st-ring-text > span {
font-size: 0.8125rem;
color: var(--label-2);
}
.st-rows {
align-self: stretch;
display: flex;
flex-direction: column;
justify-content: center;
padding-left: 14px;
border-left: 0.5px solid var(--separator);
}
.st-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 9px 0;
font-size: 0.9375rem;
color: var(--label-2);
}
.st-row + .st-row {
border-top: 0.5px solid var(--separator);
}
.st-row b {
color: var(--label);
font-family: var(--font-num);
font-size: 1.0625rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.st-net {
text-align: center;
}
.st-net b {
display: block;
font-family: var(--font-num);
font-size: 2rem;
font-weight: 500;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.st-net span {
font-size: 0.8125rem;
color: var(--label-2);
}
/* プレイスタイルのレーダー */
.st-style {
padding: 6px;
cursor: pointer;
transition: transform 0.45s var(--ease-spring);
}
.st-style:active {
transform: scale(0.985);
transition: transform 0.1s ease-out;
}
.st-radar {
display: block;
width: 100%;
height: auto;
overflow: visible;
}
.st-radar-ring {
fill: none;
stroke: var(--label-3);
stroke-width: 1;
stroke-dasharray: 4 4;
}
.st-radar-outer {
fill: none;
stroke: var(--label-3);
stroke-width: 1.5;
}
.st-radar-axis {
stroke: var(--label-3);
stroke-width: 1;
stroke-dasharray: 2 4;
}
.st-radar-shape {
stroke: none;
}
.st-radar-link {
stroke: var(--st-pick);
stroke-width: 2;
}
.st-chip rect {
fill: transparent;
stroke: var(--separator);
stroke-width: 1;
}
.st-chip text {
fill: var(--label-2);
font-size: 14px;
font-weight: 500;
}
.st-chip.on rect {
fill: var(--st-pick);
stroke: none;
}
.st-chip.on text {
fill: var(--st-on-pick);
font-weight: 600;
}
.st-axis {
font-size: 17px;
font-weight: 600;
}
.st-axis.aggr {
fill: var(--st-aggr);
}
.st-axis.passive {
fill: var(--st-passive);
}
.st-axis.tight {
fill: var(--st-tight);
}
.st-axis.loose {
fill: var(--st-loose);
}
.st-axis-sub {
fill: var(--label-2);
font-size: 11.5px;
}
.st-empty-note {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--label-2);
font-size: 0.9375rem;
pointer-events: none;
}
/* 説明シート */
.st-info-icon {
margin-top: 4px;
font-size: 3.25rem;
line-height: 1.1;
text-align: center;
}
.st-info-title {
margin: 8px 0 14px;
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.015em;
text-align: center;
}
.st-info-value {
margin-bottom: 16px;
font-family: var(--font-num);
font-size: 2.25rem;
font-weight: 500;
line-height: 1.1;
text-align: center;
font-variant-numeric: tabular-nums;
}
.st-info-value small {
font-size: 1rem;
}
.st-scale {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: 4px;
text-align: center;
}
.st-scale-col {
display: grid;
gap: 6px;
min-width: 0;
}
.st-scale-col .st-seg {
height: 10px;
border-radius: 5px;
}
.st-scale-label,
.st-scale-range {
font-size: 0.75rem;
line-height: 1.2;
color: var(--label-2);
white-space: nowrap;
}
.st-scale-col.on .st-scale-label,
.st-scale-col.on .st-scale-range {
color: var(--label);
font-weight: 600;
}
.st-info-desc {
margin-top: 20px;
color: var(--label-2);
font-size: 1rem;
line-height: 1.65;
text-align: center;
}
.st-info-sample {
margin: 10px 0 4px;
color: var(--label-3);
font-size: 0.8125rem;
text-align: center;
}
.btn.st-share {
background: var(--label);
color: var(--bg-2);
}
/* プレイスタイルのシート(横に送る) */
.st-carousel {
display: flex;
gap: 12px;
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x mandatory;
padding: 4px 9% 2px;
scrollbar-width: none;
}
.st-carousel::-webkit-scrollbar {
display: none;
}
.st-type {
flex: 0 0 82%;
scroll-snap-align: center;
padding: 26px 20px 24px;
border-radius: 22px;
background: var(--bg-2);
box-shadow: inset 0 0 0 1px var(--fill-2);
text-align: center;
}
.st-type-emoji {
font-size: 4rem;
line-height: 1.1;
}
.st-type-name {
margin-top: 10px;
font-size: 1.625rem;
font-weight: 600;
letter-spacing: -0.015em;
}
.st-type-full {
color: var(--label-2);
}
.st-type-desc {
margin-top: 14px;
color: var(--label-2);
font-size: 0.9375rem;
line-height: 1.65;
}
.st-type-mine {
display: inline-block;
margin-top: 14px;
padding: 4px 12px;
border-radius: 999px;
background: var(--st-pick);
color: var(--st-on-pick);
font-size: 0.8125rem;
font-weight: 600;
}
.st-dots {
display: flex;
justify-content: center;
gap: 8px;
padding: 14px 0 2px;
}
.st-dots span {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--label-3);
transition: background-color 0.2s;
}
.st-dots span.on {
background: var(--label);
}
/* ---------- 視差効果を減らす: 動きは短いクロスフェードに ---------- */
@media (prefers-reduced-motion: reduce) {
.pcard.deal,
@@ -3054,6 +3924,13 @@ body.page-table {
.nav-title {
transform: none;
}
.st-liquid::before {
animation: none;
}
.st-seg-fill,
.st-liquid {
transition: none;
}
}
@keyframes fade-in {
from {
+368 -1
View File
@@ -30,6 +30,8 @@
let reviewKey = '';
const reviewSheet = Fluid.Sheet($('review-modal'));
const rankingSheet = Fluid.Sheet($('ranking-modal'));
const seatingSheet = Fluid.Sheet($('seating-modal'));
const nameSheet = Fluid.Sheet($('name-modal'), { dismissible: false, focus: '#name-input' });
// 払って閉じた・背景を押した・Esc のときも「戻る」と同じ
const betSheet = Fluid.Sheet($('bet-modal'), {
@@ -117,6 +119,8 @@
$('main').hidden = true;
$('action-bar').hidden = true;
reviewSheet.close();
rankingSheet.close();
seatingSheet.close();
raising = false;
betSheet.close();
$('not-found').hidden = false;
@@ -244,6 +248,10 @@
$('pause').title = s.paused ? '再開' : '一時停止';
$('pause').setAttribute('aria-label', $('pause').title);
$('pause').classList.toggle('paused', s.paused);
const seated = s.seats.filter(Boolean).length;
$('seating-open').disabled = seated < 3; // 2人までは並びが1通り
$('seating-open').title = seated < 3 ? '3人以上で席順を変えられます' : '席順を変更';
$('seating-dot').hidden = !s.pendingSeating;
// 自分
$('action-bar').hidden = !me;
@@ -259,6 +267,8 @@
if (me) renderActionBar(s, me);
else betSheet.close();
renderReview(s);
if (rankingSheet.isOpen) renderRanking(s);
if (seatingSheet.isOpen) syncSeating(s);
renderTimer();
// 自分の番になったら知らせる
@@ -579,7 +589,9 @@
reviewSheet.close();
return;
}
$('review-link').href = r.sessionId ? `/review?s=${encodeURIComponent(r.sessionId)}` : '/review';
// 同じタブで開き、レビューの画面の「テーブル」で戻ってこられるようにテーブルIDを渡す
const back = `t=${encodeURIComponent(tableId)}`;
$('review-link').href = r.sessionId ? `/review?s=${encodeURIComponent(r.sessionId)}&${back}` : `/review?${back}`;
$('review-modal-next').hidden = !r.waiting;
const key = JSON.stringify(r);
if (key !== reviewKey) {
@@ -616,6 +628,361 @@
$('review-next').addEventListener('click', nextHand);
$('review-modal-next').addEventListener('click', nextHand);
// ---------- ランキング(スタック順・収支順を切り替える) ----------
// スタックはまだ降りていない人がこのハンドで出した額を含む(ベットのたびに順位が揺れないように)。
// 収支は持っているチップ − バイインの合計(リバイを含む)。同じ値は同じ順位
const RANK_STORE = 'poker.rankBy';
let rankBy = 'stack';
try {
if (localStorage.getItem(RANK_STORE) === 'net') rankBy = 'net';
} catch { /* プライベートモード等 */ }
const rankRows = new Map(); // 席番号 → 行。並びが変わっても行は使い回し、元の位置から滑らせる
const RANK_NOTE = {
stack: 'ハンド中は、まだ降りていない人がこのハンドで出したチップも含めています。',
net: '収支 = 持っているチップ − バイインの合計(リバイを含む)',
};
function rankingEntries(s) {
const key = rankBy === 'net' ? 'net' : 'held';
const list = s.seats.filter(Boolean).sort((a, b) => b[key] - a[key] || a.seat - b.seat);
let rank = 0;
return list.map((p, i) => {
if (i === 0 || p[key] !== list[i - 1][key]) rank = i + 1;
return { p, rank, value: p[key] };
});
}
// 並べ替えられる行(縦の位置をばねで動かす)
function movableRow(className) {
const el = document.createElement('li');
el.className = className;
el.y = new Fluid.Spring(0, { precision: 0.3 });
el.y.onUpdate = (v) => {
el.style.transform = v ? `translate3d(0, ${v.toFixed(2)}px, 0)` : '';
};
return el;
}
const rowTops = (rows) => new Map([...rows].map((el) => [el, el.getBoundingClientRect().top]));
// 並べ替えた行を、画面上の元の位置(before: 行 → 並べ替える前の top)からばねで新しい位置へ
function slideFrom(before) {
for (const [el, top] of before) {
if (!el.isConnected) continue;
const offset = top - (el.getBoundingClientRect().top - el.y.value);
if (Math.abs(offset - el.y.value) < 0.5) continue;
el.y.set(offset);
el.y.to(0, { damping: 0.86, response: 0.42 });
}
}
function rankRow(seat) {
let el = rankRows.get(seat);
if (el) return el;
el = movableRow('cell rank-row');
rankRows.set(seat, el);
return el;
}
function renderRanking(s) {
const listEl = $('ranking-list');
const empty = listEl.querySelector('.empty');
if (empty) empty.remove();
const before = rowTops(rankRows.values());
const live = new Set();
rankingEntries(s).forEach(({ p, rank, value }, i) => {
live.add(p.seat);
const el = rankRow(p.seat);
const sub = [p.seat === s.mySeat && 'あなた', p.bot && 'CPU', p.sittingOut && '離席中', !p.connected && 'オフライン']
.filter(Boolean)
.join(' ・ ');
const net = rankBy === 'net';
patch(
el,
`<span class="rank-no${rank <= 3 ? ` top-${rank}` : ''}">${rank}</span>` +
`<span class="rank-avatar${p.bot ? ' bot' : ''}">${p.bot ? BOT_SVG : esc(initialOf(p.name))}</span>` +
`<span class="cell-title">${esc(p.name)}${sub ? `<span class="cell-sub">${sub}</span>` : ''}</span>` +
`<span class="rank-value${net ? (value > 0 ? ' plus' : value < 0 ? ' minus' : '') : ''}">${net ? signed(value) : chips(value)}</span>`
);
el.style.setProperty('--hue', hueOf(p.name));
el.classList.toggle('me', p.seat === s.mySeat);
if (listEl.children[i] !== el) listEl.insertBefore(el, listEl.children[i] || null);
});
for (const [seat, el] of rankRows) {
if (live.has(seat)) continue;
el.remove();
rankRows.delete(seat);
}
slideFrom(before); // 順位が変わった行は元の位置から滑らせる
$('ranking-note').textContent = RANK_NOTE[rankBy];
if (!s.seats.some(Boolean)) listEl.innerHTML = '<li class="empty">まだ誰も座っていません</li>';
}
Fluid.Segmented($('ranking-tabs'));
function syncRankTabs() {
for (const b of $('ranking-tabs').querySelectorAll('[data-rank]')) {
const on = b.dataset.rank === rankBy;
b.classList.toggle('active', on);
b.setAttribute('aria-pressed', String(on));
}
}
syncRankTabs();
$('ranking-tabs').addEventListener('click', (e) => {
const b = e.target.closest('[data-rank]');
if (!b || b.dataset.rank === rankBy) return;
rankBy = b.dataset.rank;
try {
localStorage.setItem(RANK_STORE, rankBy);
} catch { /* プライベートモード等 */ }
syncRankTabs();
Fluid.haptic(6);
if (state) renderRanking(state);
});
$('ranking-open').addEventListener('click', () => {
if (!state) return;
renderRanking(state); // 中身を入れてから開く(シートの高さを測るため)
rankingSheet.open();
});
$('ranking-close').addEventListener('click', () => rankingSheet.close());
// ---------- 席順の変更(ホストのみ) ----------
// 席の並びは輪なので、自分を一番上に固定し、その下に左隣から時計回りの順で並べる(テーブルの画面と同じ向き)。
// 行は右端のつまみをつかむと指に付いてきて、ほかの行がよける。キーボードではつまみにフォーカスして ↑↓。
// ハンド中に決めた席順は次のハンドの始めにサーバが並べ替える
const HANDLE_SVG = '<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 8h14M5 12h14M5 16h14"/></svg>';
let seatMe = -1; // 自分の席(観戦中のホストは -1)
let seatOrder = []; // 自分以外の席番号(左隣から時計回り)
let seatNames = new Map(); // 席番号 → 名前(開いている間に席番号が振り直されたか確かめる)
let seatDrag = null;
const seatRows = new Map(); // 席番号 → 行
const previewDots = new Map(); // 席番号 → プレビューの点
const occupiedSeats = (s) => s.seats.filter(Boolean).map((p) => p.seat);
const seatingList = () => (seatMe >= 0 ? [seatMe] : []).concat(seatOrder);
// いまの並び(次のハンドからの席順が決めてあればそれ)を、自分から時計回りに読み込む
function loadSeating(s) {
const occupied = occupiedSeats(s);
const pending = s.pendingSeating;
const base = pending
? pending.filter((i) => occupied.includes(i)).concat(occupied.filter((i) => !pending.includes(i)))
: occupied;
const k = base.indexOf(s.mySeat);
seatMe = k >= 0 ? s.mySeat : -1;
seatOrder = k >= 0 ? base.slice(k + 1).concat(base.slice(0, k)) : base;
seatNames = new Map(occupied.map((i) => [i, s.seats[i].name]));
}
// 開いている間に座った人は最後に加え、いなくなった人は外す(並べた順は崩さない)。
// 次のハンドで席番号が振り直されたら読み込み直す
function syncSeating(s) {
if (!s.isHost) return seatingSheet.close();
if (seatDrag) return;
const occupied = occupiedSeats(s);
const renumbered =
(seatMe >= 0 && s.mySeat !== seatMe) || [...seatNames].some(([i, name]) => s.seats[i] && s.seats[i].name !== name);
if (renumbered) {
loadSeating(s);
toast('席が並べ替わったので、いまの席順を読み込み直しました');
} else {
const kept = seatOrder.filter((i) => occupied.includes(i));
const added = occupied.filter((i) => i !== seatMe && !kept.includes(i));
seatOrder = kept.concat(added);
for (const i of added) seatNames.set(i, s.seats[i].name);
}
renderSeating(rowTops(seatRows.values()));
}
function seatRow(seat) {
let el = seatRows.get(seat);
if (el) return el;
el = movableRow('cell seat-row');
el.dataset.seat = seat;
seatRows.set(seat, el);
return el;
}
function whereText(seat) {
if (seat === seatMe) return 'あなた';
const k = seatOrder.indexOf(seat);
const n = seatOrder.length;
if (seatMe < 0) return `${k + 1}番目`;
if (n === 1) return 'あなたの隣';
if (k === 0) return 'あなたの左隣';
if (k === n - 1) return 'あなたの右隣';
return `あなたから時計回りに${k + 1}人目`;
}
// before: 並べ替える前の行の位置(渡すと元の位置から滑らせる)
function renderSeating(before) {
const s = state;
const list = $('seating-list');
const seats = seatingList().filter((i) => s.seats[i]);
seats.forEach((seat, i) => {
const p = s.seats[seat];
const mine = seat === seatMe;
const sub = [whereText(seat), p.bot && 'CPU', p.sittingOut && '離席中'].filter(Boolean).join(' ・ ');
const el = seatRow(seat);
patch(
el,
`<span class="rank-avatar${p.bot ? ' bot' : ''}">${p.bot ? BOT_SVG : esc(initialOf(p.name))}</span>` +
`<span class="cell-title">${esc(p.name)}<span class="cell-sub">${sub}</span></span>` +
(mine
? '<span class="seat-pin">固定</span>'
: `<button type="button" class="seat-handle" aria-label="${esc(p.name)}の席を移動(↑↓キーで並べ替え)">${HANDLE_SVG}</button>`)
);
el.style.setProperty('--hue', hueOf(p.name));
el.classList.toggle('me', mine);
if (list.children[i] !== el) list.insertBefore(el, list.children[i] || null);
});
for (const [seat, el] of seatRows) {
if (seats.includes(seat)) continue;
el.remove();
seatRows.delete(seat);
}
if (before) slideFrom(before);
const note = $('seating-pending');
note.hidden = !s.hand && !s.pendingSeating;
note.textContent = s.hand
? 'ハンドの途中なので、次のハンドの始めに並べ替えます。'
: 'まだ反映していない席順があります。';
renderSeatingPreview(seats);
}
// 小さなテーブル: 自分が一番下で時計回り(テーブルの画面と同じ)。点は新しい位置へ滑る
function renderSeatingPreview(seats) {
const box = $('seating-preview');
const n = seats.length;
seats.forEach((seat, idx) => {
const p = state.seats[seat];
let dot = previewDots.get(seat);
if (!dot) {
dot = document.createElement('span');
dot.className = 'seating-dot';
dot.innerHTML = '<span class="rank-avatar"></span><span class="seating-name"></span>';
previewDots.set(seat, dot);
box.append(dot);
}
const angle = Math.PI / 2 + (idx * 2 * Math.PI) / Math.max(n, 1);
dot.style.left = `${(50 + 40 * Math.cos(angle)).toFixed(2)}%`;
dot.style.top = `${(50 + 36 * Math.sin(angle)).toFixed(2)}%`;
dot.style.setProperty('--hue', hueOf(p.name));
dot.classList.toggle('me', seat === seatMe);
const face = dot.firstChild;
face.classList.toggle('bot', p.bot);
patch(face, p.bot ? BOT_SVG : esc(initialOf(p.name)));
dot.lastChild.textContent = seat === seatMe ? 'あなた' : p.name;
});
for (const [seat, dot] of previewDots) {
if (seats.includes(seat)) continue;
dot.remove();
previewDots.delete(seat);
}
}
function moveSeat(from, to) {
const before = rowTops(seatRows.values());
if (from !== to) {
const [seat] = seatOrder.splice(from, 1);
seatOrder.splice(to, 0, seat);
}
renderSeating(before);
}
const seatingListEl = $('seating-list');
seatingListEl.addEventListener('pointerdown', (e) => {
const handle = e.target.closest('.seat-handle');
if (!handle || seatDrag || (e.pointerType === 'mouse' && e.button !== 0)) return;
const row = handle.closest('[data-seat]');
const seat = Number(row.dataset.seat);
e.preventDefault();
handle.setPointerCapture(e.pointerId);
row.y.stop();
const from = seatOrder.indexOf(seat);
seatDrag = { id: e.pointerId, seat, row, from, to: from, y0: e.clientY - row.y.value, h: row.offsetHeight };
row.classList.add('dragging');
Fluid.haptic(8);
});
seatingListEl.addEventListener('pointermove', (e) => {
const d = seatDrag;
if (!d || e.pointerId !== d.id) return;
const n = seatOrder.length;
// 端より先へはゴムのように抵抗する
const min = -d.from * d.h;
const max = (n - 1 - d.from) * d.h;
let dy = e.clientY - d.y0;
if (dy < min) dy = min - Fluid.rubberband(min - dy, d.h * 2);
else if (dy > max) dy = max + Fluid.rubberband(dy - max, d.h * 2);
d.row.y.set(dy);
const to = Fluid.clamp(Math.round(d.from + dy / d.h), 0, n - 1);
if (to !== d.to) {
d.to = to;
Fluid.haptic(6);
}
// 間の行は1行ぶんよける
seatOrder.forEach((seat, j) => {
if (seat === d.seat) return;
const shift = d.from < j && j <= d.to ? -d.h : d.to <= j && j < d.from ? d.h : 0;
const el = seatRows.get(seat);
if (el && el.y.target !== shift) el.y.to(shift, { damping: 1, response: 0.28 });
});
});
const dropSeat = (e) => {
const d = seatDrag;
if (!d || e.pointerId !== d.id) return;
seatDrag = null;
d.row.classList.remove('dragging');
moveSeat(d.from, e.type === 'pointercancel' ? d.from : d.to);
};
seatingListEl.addEventListener('pointerup', dropSeat);
seatingListEl.addEventListener('pointercancel', dropSeat);
seatingListEl.addEventListener('keydown', (e) => {
const handle = e.target.closest('.seat-handle');
const dir = e.key === 'ArrowUp' ? -1 : e.key === 'ArrowDown' ? 1 : 0;
if (!handle || !dir) return;
e.preventDefault();
const seat = Number(handle.closest('[data-seat]').dataset.seat);
const from = seatOrder.indexOf(seat);
if (from + dir < 0 || from + dir >= seatOrder.length) return;
moveSeat(from, from + dir);
const again = seatRows.get(seat).querySelector('.seat-handle');
if (again) again.focus();
});
$('seating-shuffle').addEventListener('click', () => {
const before = rowTops(seatRows.values());
const r = new Uint32Array(seatOrder.length);
crypto.getRandomValues(r);
for (let i = seatOrder.length - 1; i > 0; i--) {
const j = r[i] % (i + 1);
[seatOrder[i], seatOrder[j]] = [seatOrder[j], seatOrder[i]];
}
Fluid.haptic(10);
renderSeating(before);
});
$('seating-open').addEventListener('click', () => {
if (!state || !state.isHost) return;
loadSeating(state);
renderSeating(null); // 中身を入れてから開く(シートの高さを測るため)
seatingSheet.open();
});
$('seating-cancel').addEventListener('click', () => seatingSheet.close());
$('seating-save').addEventListener('click', () => {
if (!socket || !state) return;
const later = Boolean(state.hand); // ハンドがある間(結果の表示中を含む)は次のハンドから
const btn = $('seating-save');
btn.disabled = true;
socket.emit('table:seating', { order: seatingList() }, (res) => {
btn.disabled = false;
if (!res || !res.ok) {
toast((res && res.error) || '席順を変更できませんでした');
if (state) syncSeating(state);
return;
}
seatingSheet.close();
toast(later ? '次のハンドから新しい席順になります' : '席順を変更しました');
});
});
// ---------- 勝者選択 ----------
function renderAwardPanel(pots) {
const key = `${state.handNumber}:${JSON.stringify(pots)}`;