poolcoach / app /static /index.html
masterdanh's picture
deploy: snapshot for HF Space
78738de
Raw
History Blame Contribute Delete
25.6 kB
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PoolCoach — Gợi ý cú đánh 9-ball</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<!-- badge phiên bàn (QR, bàn giao 12) nằm trong header: hiện được cả
desktop lẫn mobile mà không bao giờ đè lên canvas. Class RIÊNG
.session-badge — không mượn class có event binding (bài học .btn-play). -->
<h1>🎱 PoolCoach <span id="session-badge" class="session-badge hidden"></span></h1>
<p>Kéo-thả bi theo bàn thật (mét) → <b>Gợi ý cú đánh</b><b>Đánh cú này</b> để chơi tiếp</p>
</header>
<main>
<div id="table-wrap">
<canvas id="table"></canvas>
<div id="tooltip" class="hidden"></div>
</div>
<aside id="panel">
<button id="sheet-close" class="sheet-close" aria-label="Đóng"></button>
<div id="status" class="banner hidden"></div>
<!-- A2b (feedback Danh 13/08 #2): Analyzer là tab ĐẦU + MẶC ĐỊNH khi mở
app — tính năng chính của luận văn; tab Gợi ý lùi sau nhưng đầy đủ
chức năng. HTML tĩnh phản ánh đúng trạng thái boot (Analyzer active,
#analyzer hiện, .controls/.legend ẩn) khớp S.anMode=true trong
app.js — MỘT nguồn sự thật cho trạng thái đầu, không có flash tab.
Deep-link ?qr= (phiên bàn) vẫn vào thẳng tab Gợi ý — app.js xử lý.
Tab Bài tập (F3) + Analyzer là LỚP THÊM: thoát tab là bàn + luồng
gợi ý trả lại nguyên trạng (app.js chụp/khôi phục S.balls). -->
<div class="tabs">
<button id="tab-analyzer" class="tab active">Analyzer</button>
<button id="tab-suggest" class="tab">Gợi ý</button>
<button id="tab-drills" class="tab">Bài tập</button>
</div>
<!-- Broadcast Analyzer (bàn giao 24, đa cú từ A1): upload video 1..N cú
→ click 4 pocket trên frame đầu (overlay #an-overlay dưới cùng
trang) → máy tự cắt cú → bảng danh sách cú. Mọi id/class .an-* là
MỚI — không mượn class có event binding (bài học .btn-play 04/08).
Luồng 1-cú GỠ ở A2b (feedback Danh 13/08): video 1 cú là trường
hợp con của luồng đa cú — MỘT đường vào duy nhất. -->
<section id="analyzer">
<div class="an-intro">Tải lên video 9-ball (<b>1 hoặc nhiều cú</b>,
một góc máy full-table ổn định, ≤ 20 phút, MP4/MOV) — máy tự tách
từng cú đánh, đo quỹ đạo, V0, hướng φ, spin và các va chạm.</div>
<!-- .an-cta chứ KHÔNG PHẢI .btn-go: .btn-go có binding recommend toàn
cục + syncButtons ép label — mượn là nút này gọi ké engine và
mọc nút "Đánh cú này" giữa tab Analyzer (bug smoke BG25). -->
<button id="anv-pick" class="an-cta">🎬 Phân tích video (1 hoặc nhiều cú)</button>
<div id="anv-result" class="hidden">
<!-- Danh sách cú (A1) — lát A2 bọc vào #anv-list để trang chi tiết
cú (#and-detail, §3.2 design) thế chỗ khi click một hàng.
Mọi id/class .and-* là MỚI (bài học .btn-play). -->
<div id="anv-list">
<div id="anv-progressline" class="anv-progressline"></div>
<div id="anv-warnings"></div>
<!-- Xuất toàn danh sách cú (A2 phần 5): CSV UTF-8 BOM (Excel
không vỡ tiếng Việt) + JSON — cùng nguồn với bảng. Href do
JS điền theo video đang xem. -->
<div class="btn-row anv-export hidden">
<a id="anv-csv" class="btn-mini and-link" download>⬇ Xuất CSV</a>
<a id="anv-json" class="btn-mini and-link" download>⬇ Xuất JSON</a>
</div>
<div class="anv-tablewrap">
<table id="anv-table" class="anv-table">
<thead><tr>
<th>#</th><th></th><th>V0</th><th>φ</th>
<th>Spin (a/b)</th><th>Tin cậy</th><th>RMSE</th>
<th>Trạng thái</th>
</tr></thead>
<tbody id="anv-tbody"></tbody>
</table>
</div>
<div class="an-note">Click một hàng <b>đã xong</b> để mở trang
chi tiết cú (overlay + resim chồng + bảng thông số; JSON thô
có link trong đó). V0/φ/spin lấy từ cột model (ShotNet) khi có
— nhãn "bi" là tốc độ bi đo giải tích, hai thước khác nhau.
Cột RMSE = khớp resim pooltool ↔ track quan sát (mm, bộ params
model ShotNet trừ khi ghi khác). Cú hàng xám là cú máy không
phân tích được, kèm lý do. Cú xám
"⚑ nghi không phải cú đánh" là van hiển thị từ tín hiệu cú tự
khai (V0 / motion_start / coverage) — không bị lọc bỏ, vẫn
click xem được.</div>
</div>
<!-- Trang chi tiết cú (lát A2 phần 4, §3.2 design): overlay.mp4 +
bàn metric quan sát (nét liền, vẽ trên canvas bàn) vs resim
(nét đứt) + bảng 2 cột + nút "Đánh lại cú" (animateShot() sẵn
có — KHÔNG animation mới). -->
<div id="and-detail" class="hidden">
<div class="btn-row">
<button id="and-back" class="btn-mini">← Danh sách cú</button>
<a id="and-json" class="btn-mini and-link" target="_blank">JSON thô</a>
<a id="and-download" class="btn-mini and-link hidden">⬇ Tải mp4</a>
</div>
<h3 id="and-title"></h3>
<video id="and-video" class="and-video hidden" controls playsinline></video>
<div id="and-video-missing" class="an-note hidden">Cú này chưa có
overlay.mp4 — kết quả thuộc lần chạy trước A2 (clip đã xoá,
không render hồi tố được) hoặc render lỗi (xem cảnh báo).</div>
<!-- A2b phần 4 (Danh chốt 13/08 tối): "Đánh lại cú" animate theo
quỹ đạo QUAN SÁT — cú thật sống lại, luôn đúng bất kể model. -->
<button id="and-replay" class="an-cta" disabled>▶ Đánh lại cú (quỹ đạo quan sát)</button>
<!-- Widget thông tin cú khi đánh lại (§3.2): bi cái + chấm điểm
đầu cơ (a/b) + thanh lực V0 — tái dụng MARKUP + CSS style-only
(.viz*) của #shotviz tab gợi ý; id + class tương tác RIÊNG
(.and-spin-dot / .and-power-bar) vì .spin-dot/.power-bar bị
initPowerBar + updateShotViz quét toàn cục (bài học .btn-play).
Số từ bộ SHOTNET — analytic không có a/b; nhãn bộ ghi thẳng. -->
<div id="and-shotviz" class="viz hidden">
<div class="viz-spin">
<svg id="and-spin-ball" viewBox="0 0 100 100" aria-label="điểm chạm bi cái (model)">
<defs>
<radialGradient id="and-cuegrad" cx="35%" cy="30%" r="80%">
<stop offset="0%" stop-color="#ffffff"/>
<stop offset="100%" stop-color="#c9c9c9"/>
</radialGradient>
</defs>
<circle cx="50" cy="50" r="46" fill="url(#and-cuegrad)" stroke="#1c1c1c" stroke-width="1.6"/>
<line x1="50" y1="7" x2="50" y2="93" stroke="#9a9a9a" stroke-width="1" stroke-dasharray="3 4"/>
<line x1="7" y1="50" x2="93" y2="50" stroke="#9a9a9a" stroke-width="1" stroke-dasharray="3 4"/>
<circle class="and-spin-dot" cx="50" cy="50" r="6.5" fill="#e02020" stroke="#7a0000" stroke-width="1.3"/>
</svg>
<div class="viz-cap" id="and-spin-cap"></div>
</div>
<div class="viz-power">
<div class="viz-lbl">Lực <b id="and-power-val"></b></div>
<div id="and-power-bar" class="and-power-bar"></div>
<div class="viz-scale"><span>0.5</span><span>m/s</span><span>8.0</span></div>
<div class="viz-cap" id="and-viz-label"></div>
</div>
</div>
<div id="and-suspect" class="an-warn hidden"></div>
<!-- Lớp resim thành toggle, MẶC ĐỊNH TẮT (path quan sát ↔ resim
đang lệch V0-thang, hiện cả hai thì rối — feedback #3). Bật
mặc định lại = một hằng AND_RESIM_DEFAULT_ON trong app.js. -->
<label id="and-resim-row" class="and-toggle hidden">
<input type="checkbox" id="and-resim-toggle">
So với mô phỏng (vẽ nét đứt resim + RMSE)
</label>
<div id="and-resim" class="an-note hidden"></div>
<div id="and-warnings"></div>
<table id="and-metrics" class="an-metrics"></table>
<div id="and-caveat" class="an-caveat hidden">Model train thuần mô
phỏng (P2), chưa tinh chỉnh trên video thật.</div>
<div class="an-note">Bàn bên trái: <b>nét liền tô màu theo thời
gian</b> = track quan sát; bi có số/màu theo BallID khi kết quả
có nhận diện (kết quả cũ hiện bi xám). "Đánh lại cú" chạy lại
chính quỹ đạo quan sát đó bằng animation của app gợi ý. Bật
"So với mô phỏng" mới vẽ <b>nét đứt trắng</b> = quỹ đạo resim
từ params suy được (bộ ghi ở dòng RMSE).</div>
</div>
</div>
</section>
<section id="drills" class="hidden">
<!-- khu "Tiến bộ" (bàn giao 12): chỉ hiện khi có phiên bàn + DB —
tổng hợp pass/fail theo drill của ĐÚNG phiên này. Không phiên /
503 → ẩn khu, hiện dòng nhắc. Class riêng .drill-progress. -->
<div id="drill-progress" class="drill-progress hidden">
<b>Tiến bộ phiên này</b>
<div id="drill-progress-body"></div>
</div>
<div id="drill-progress-hint" class="drill-note hidden">Vào bàn bằng
mã QR để lưu và xem tiến bộ tập.</div>
<div id="drill-list"></div>
<div id="drill-detail" class="hidden">
<div class="btn-row">
<button id="drill-back" class="btn-mini">← Danh sách</button>
<button id="drill-reset" class="btn-mini">↺ Xếp lại bi</button>
</div>
<h3 id="drill-title"></h3>
<div id="drill-goal"></div>
<div class="row drill-round">
<span class="lbl">Vòng tập</span>
<b id="drill-count"></b>
</div>
<div class="btn-row">
<!-- class RIÊNG .btn-pass (không phải .btn-play): init cũ bind
playShot cho mọi .btn-play — dùng ké class đó là bấm "Đạt"
kích luôn animation cú gợi ý (bug bắt được trong smoke 04/08) -->
<button id="drill-pass" class="btn-pass">✔ Đạt</button>
<button id="drill-fail" class="btn-fail">✘ Trượt</button>
</div>
<div id="drill-summary" class="hidden"></div>
<button id="drill-retry" class="btn-mini hidden">↻ Tập vòng mới</button>
<div id="drill-note" class="drill-note hidden">Chế độ demo — tiến bộ
không được lưu (server chưa có cơ sở dữ liệu).</div>
<button id="drill-suggest" class="btn-go" disabled>Xem gợi ý</button>
</div>
</section>
<section class="controls hidden">
<!-- Bộ chọn engine GỠ 31/07/2026 (BRIEF việc B): app chỉ còn MỘT đường
chọn cú — ZonePlanner V2 (tiêu chí design §8). Server tự chạy nó,
không có gì để chọn. Thời gian thật của từng lần gợi ý vẫn hiện ở
#searchinfo, đo bởi chính server. -->
<div class="row">
<span class="lbl">Bi trên bàn</span>
<span id="panel-target" class="target-badge">→ bi 1</span>
</div>
<div class="row" id="ball-chips">
<button class="chip" data-bid="1">1</button>
<button class="chip" data-bid="2">2</button>
<button class="chip" data-bid="3">3</button>
<button class="chip" data-bid="4">4</button>
<button class="chip" data-bid="5">5</button>
<button class="chip" data-bid="6">6</button>
<button class="chip" data-bid="7">7</button>
<button class="chip" data-bid="8">8</button>
<button class="chip" data-bid="9">9</button>
</div>
<div class="row btn-row">
<button id="btn-preset" class="btn-mini">Bàn mẫu</button>
<button id="btn-undo" class="btn-mini" disabled>↩ Undo</button>
<button id="btn-tv" class="btn-mini">📺 Ghép TV</button>
</div>
<!-- Scan ảnh (F2 MVP, bàn giao 18): id riêng + class style-only —
không mượn class có event binding (bài học .btn-play 04/08) -->
<div class="row btn-row">
<button id="btn-scan" class="btn-mini" disabled>📷 Scan ảnh bàn</button>
</div>
<!-- form Ghép TV (bàn giao 15): class RIÊNG .tv-pair — không mượn
class có event binding toàn cục (bài học .btn-play 04/08) -->
<div id="tv-pair" class="tv-pair hidden">
<input id="tv-code" inputmode="numeric" autocomplete="one-time-code"
maxlength="6" placeholder="Mã 6 số trên TV">
<button id="tv-pair-go">Ghép</button>
</div>
<button id="btn-go" class="btn-go" disabled>Gợi ý cú đánh</button>
<!-- auto-play để NGOÀI #result: #result ẩn tới khi bấm Gợi ý lần đầu,
mà "tự chơi hết ván" lại là nút muốn bấm ngay lúc mở trang -->
<button id="btn-auto" class="btn-mini btn-auto" disabled>▶ Tự chơi hết ván</button>
</section>
<section id="result" class="hidden">
<div id="shotviz" class="viz hidden">
<div class="viz-spin">
<svg id="spin-ball" viewBox="0 0 100 100" aria-label="điểm chạm bi cái">
<defs>
<radialGradient id="cuegrad" cx="35%" cy="30%" r="80%">
<stop offset="0%" stop-color="#ffffff"/>
<stop offset="100%" stop-color="#c9c9c9"/>
</radialGradient>
</defs>
<circle cx="50" cy="50" r="46" fill="url(#cuegrad)" stroke="#1c1c1c" stroke-width="1.6"/>
<line x1="50" y1="7" x2="50" y2="93" stroke="#9a9a9a" stroke-width="1" stroke-dasharray="3 4"/>
<line x1="7" y1="50" x2="93" y2="50" stroke="#9a9a9a" stroke-width="1" stroke-dasharray="3 4"/>
<circle class="spin-dot" cx="50" cy="50" r="6.5" fill="#e02020" stroke="#7a0000" stroke-width="1.3"/>
</svg>
<div class="viz-cap" id="spin-cap"></div>
</div>
<div class="viz-power">
<div class="viz-lbl">Lực <b id="power-val"></b></div>
<div id="power-bar" class="power-bar"></div>
<div class="viz-scale"><span>0.5</span><span>m/s</span><span>4.0</span></div>
</div>
</div>
<pre id="describe"></pre>
<div class="btn-row play-row">
<button id="btn-play" class="btn-play" disabled>Đánh cú này</button>
</div>
<div id="alts"></div>
<div id="searchinfo"></div>
</section>
<section class="legend hidden">
<span><i class="ring target"></i> bi mục tiêu (số nhỏ nhất)</span>
<span><i class="ring"></i> lỗ đích</span>
<span><i class="ring lowconf"></i> bi scan chưa chắc — kiểm lại</span>
<span>Tắt bi bằng ô số 1-9 để dựng bàn giữa ván</span>
</section>
</aside>
</main>
<div id="backdrop"></div>
<!-- Scan ảnh (F2 MVP, bàn giao 18): overlay riêng, mọi class .scan-* là
class MỚI (bài học .btn-play — không mượn class có event binding).
Ảnh đã resize hiện trên canvas riêng; người dùng chấm 4 GÓC MẶT NỈ theo
thứ tự #scan-hint chỉ dẫn từng bước; góc lưu sessionStorage — ảnh sau
chụp cùng khung hình thì khỏi chấm lại (vẫn có nút Chấm lại góc). -->
<div id="scan-overlay" class="scan-overlay hidden">
<div class="scan-box">
<div class="scan-head">
<b>📷 Scan ảnh bàn</b>
<button id="scan-close" class="scan-x" aria-label="Đóng"></button>
</div>
<div id="scan-hint" class="scan-hint"></div>
<canvas id="scan-canvas"></canvas>
<div class="btn-row scan-actions">
<button id="scan-redo" class="btn-mini">↺ Chấm lại góc</button>
<!-- .an-cta chứ KHÔNG PHẢI .btn-go (BG26, cùng bug 2 nút Analyzer
9fa4c18): .btn-go có binding recommend() toàn cục + syncButtons
ép label/disabled — mượn là "Quét thế bi" gọi ké engine chạy nền
và giành label với syncScanUi. -->
<button id="scan-go" class="an-cta scan-go" disabled>Quét thế bi</button>
</div>
</div>
</div>
<input type="file" id="scan-file" accept="image/*" class="hidden">
<!-- Analyzer (bàn giao 24): overlay chấm 4 POCKET GÓC trên frame ĐẦU của
chính clip vừa chọn (FE tự trích bằng <video> — không round-trip server).
Container mượn class STYLE-ONLY .scan-overlay/.scan-box (không có event
binding nào bind theo các class đó — bài học .btn-play chỉ cấm mượn class
CÓ binding); mọi phần tử tương tác mang id .an-* riêng. -->
<div id="an-overlay" class="scan-overlay hidden">
<div class="scan-box">
<div class="scan-head">
<b>🎬 Phân tích video — 4 pocket góc</b>
<button id="an-close" class="scan-x" aria-label="Đóng"></button>
</div>
<div id="an-hint" class="scan-hint"></div>
<!-- BG31: chuẩn ĐỘ CAO điểm click — bù độ cao tâm bi (worker) tính cho
mặt phẳng VẢI; click cao hơn (mép gỗ/miệng lỗ, z_c ≈ +21mm phiên
pilot) làm bù thành quá tay — BG30 đã đo tác hại. Note TĨNH, class
.an-note MỚI style-only (bài học .btn-play: không mượn class có
binding). Hình = mặt cắt ngang băng: gỗ + miệng lỗ trên cao, băng
cao su nghiêng xuống, mũi nose chạm mặt vải là điểm ĐÚNG. -->
<div id="an-note" class="an-note">
<svg viewBox="0 0 168 62" aria-hidden="true">
<rect x="2" y="46" width="164" height="8" rx="1" fill="#2e7d46"/>
<polygon points="2,16 46,16 76,46 2,46" fill="#1f5c33"/>
<rect x="2" y="6" width="44" height="10" rx="2" fill="#8a5a2b"/>
<ellipse cx="15" cy="11" rx="8" ry="4.5" fill="#141414"/>
<text x="28" y="14" fill="#ff6b6b" font-size="10.5">✗ miệng lỗ</text>
<circle cx="76" cy="46" r="5.5" fill="none" stroke="#39d353"
stroke-width="2"/>
<text x="86" y="50" fill="#39d353" font-size="10.5">✓ mép nose vải</text>
</svg>
<span>Chấm đúng <b>mép nose vải</b> — nơi mũi băng cao su gặp mặt vải;
<b>KHÔNG</b> chấm miệng lỗ trên mặt gỗ.</span>
</div>
<canvas id="an-canvas"></canvas>
<!-- Chỉ hiện ở bản deploy CPU (health.mode == "local") — JS bật/tắt. -->
<div id="an-cpu-warn" class="an-note hidden"><b>Server này phân tích
bằng CPU</b> (bản demo online, không GPU) nên chậm — đo được:
<b>clip 5,5 giây mất ~26 giây</b>, một rack 5 phút ước
<b>10–12 phút</b>. Bảng cú <b>hiện dần từng cú</b> nên xem được ngay
khi cú đầu xong; đóng tab cũng không mất kết quả, mở lại link là còn.
Muốn xem ngay thì bảng đang có sẵn <b>video mẫu đã phân tích</b>.</div>
<div class="btn-row scan-actions">
<button id="an-redo" class="btn-mini">↺ Chấm lại</button>
<!-- Xoay nhãn (BRIEF 14/08 việc A): máy đoán CẶP BĂNG NGẮN bằng
heuristic bất đối xứng độ dài (poolcoach_cv/autocorner) — đoán sai
thì xoay ①②③④ một nhịp là xong, khỏi chấm lại 4 điểm. -->
<button id="an-rotate" class="btn-mini" disabled>⟳ Xoay nhãn</button>
<!-- .an-cta thay .btn-go — cùng lý do #anv-pick (binding recommend +
syncButtons ép label/disabled đè lên syncAnUi). -->
<button id="an-go" class="an-cta scan-go" disabled>Phân tích</button>
</div>
</div>
</div>
<input type="file" id="an-file" accept="video/mp4,video/quicktime,.mp4,.mov" class="hidden">
<!-- quick bar mobile: options + spin/lực tóm tắt + nút Gợi ý, không cần scroll.
Quickbar nằm NGOÀI #panel (container 3 tab) — cố ý, vì nó phải nổi cố định
đáy màn hình kể cả khi sheet đóng. Hệ quả: setTab() KHÔNG ẩn nó theo tab,
nên phần thuộc tab Gợi ý trong đây phải được ẩn TAY (bug Danh 14/08: mở
app trên điện thoại vào thẳng tab Analyzer mà đáy vẫn là UI gợi ý).
Chia đôi rạch ròi:
.qb-settings = ĐIỀU HƯỚNG, luôn hiện — trên mobile #panel là
display:none tới khi "Chi tiết ▴" mở sheet, ẩn hàng này
là nhốt người dùng trong tab hiện tại, không còn lối ra;
#qb-target + .qb-main = UI tab GỢI Ý (badge bi mục tiêu, chấm đầu cơ,
thanh lực, nút Gợi ý/Đánh) — ẩn khi ở tab Analyzer.
Trạng thái tĩnh dưới đây khớp boot (Analyzer active), đúng nếp
.controls/.legend ở trên: MỘT nguồn sự thật, không flash tab. -->
<nav id="quickbar">
<div class="qb-row qb-settings">
<span id="qb-target" class="target-badge hidden">→ bi 1</span>
<!-- lối vào tab Bài tập trên mobile (BRIEF 04/08: chỉ thêm lối vào,
không đụng gì khác của quickbar) -->
<button id="qb-drills">🎯 Bài tập</button>
<button id="qb-detail">Chi tiết ▴</button>
</div>
<div class="qb-row qb-main hidden">
<svg id="qb-spin" viewBox="0 0 100 100" aria-label="điểm chạm bi cái">
<defs>
<radialGradient id="qb-cuegrad" cx="35%" cy="30%" r="80%">
<stop offset="0%" stop-color="#ffffff"/>
<stop offset="100%" stop-color="#c9c9c9"/>
</radialGradient>
</defs>
<circle cx="50" cy="50" r="46" fill="url(#qb-cuegrad)" stroke="#1c1c1c" stroke-width="1.6"/>
<line x1="50" y1="7" x2="50" y2="93" stroke="#9a9a9a" stroke-width="1" stroke-dasharray="3 4"/>
<line x1="7" y1="50" x2="93" y2="50" stroke="#9a9a9a" stroke-width="1" stroke-dasharray="3 4"/>
<circle class="spin-dot" cx="50" cy="50" r="6.5" fill="#e02020" stroke="#7a0000" stroke-width="1.3"/>
</svg>
<div class="qb-mid">
<div class="power-bar qb-power"></div>
<div id="qb-info">Đặt bi rồi bấm Gợi ý</div>
</div>
<button id="qb-go" class="btn-go" disabled>Gợi ý</button>
<button id="qb-play" class="btn-play" disabled>Đánh</button>
</div>
<!-- Hàng quickbar của tab ANALYZER (14/08, Danh yêu cầu) — đối xứng với
.qb-main ở trên: thông số cú đang mở + nút đánh lại, ngay đáy màn hình.
Vì sao cần: trên mobile #panel là bottom sheet CHE BÀN, nên bấm "Đánh
lại cú" trong sheet là animate sau lưng người dùng; hàng này cho thấy
số trong lúc bi chạy trên bàn (đúng cách tab Gợi ý vẫn làm).
Chỉ hiện khi ĐANG mở một cú (ANV.detail) ở tab Analyzer — ở màn danh
sách thì không có gì để hiện.
Class tương tác RIÊNG .qban-*; chấm spin + thanh lực CỐ Ý mang class
.and-spin-dot/.and-power-bar của widget trang chi tiết để initPowerBar
và fillAndShotviz nuôi CẢ HAI từ một nguồn số — không mượn
.spin-dot/.power-bar/.btn-play/.btn-go (bị updateShotViz + syncButtons
quét toàn cục, bài học .btn-play 04/08). -->
<div class="qb-row qb-an hidden">
<svg id="qban-spin" viewBox="0 0 100 100" aria-label="điểm chạm bi cái (model)">
<defs>
<radialGradient id="qban-cuegrad" cx="35%" cy="30%" r="80%">
<stop offset="0%" stop-color="#ffffff"/>
<stop offset="100%" stop-color="#c9c9c9"/>
</radialGradient>
</defs>
<circle cx="50" cy="50" r="46" fill="url(#qban-cuegrad)" stroke="#1c1c1c" stroke-width="1.6"/>
<line x1="50" y1="7" x2="50" y2="93" stroke="#9a9a9a" stroke-width="1" stroke-dasharray="3 4"/>
<line x1="7" y1="50" x2="93" y2="50" stroke="#9a9a9a" stroke-width="1" stroke-dasharray="3 4"/>
<circle class="and-spin-dot" cx="50" cy="50" r="6.5" fill="#e02020" stroke="#7a0000" stroke-width="1.3"/>
</svg>
<div class="qb-mid">
<div class="and-power-bar qb-power qban-bar"></div>
<div id="qban-info"></div>
</div>
<button id="qban-replay" class="an-cta qban-replay" disabled>▶ Đánh lại</button>
</div>
</nav>
<div id="toast" class="hidden"></div>
<script src="app.js"></script>
</body>
</html>