poolcoach / app /static /app.js
masterdanh's picture
deploy: snapshot for HF Space
78738de
Raw
History Blame Contribute Delete
116 kB
/* PoolCoach FE — Canvas bàn dọc + kéo-thả bi + gọi API (23/07/2026).
Không framework, không build step, không localStorage.
Contract + hành vi: PoolCoach_App_Web_Design.md §4-5 (poolcoach-docs).
Toạ độ world = mét, hệ pooltool (gốc góc dưới-trái, y dọc chiều dài bàn);
Canvas y hướng xuống nên toPx/toWorld lật trục y.
25/07/2026 (Production §4): full rack 9 bi — chip bật/tắt bi 1-9, nút
"Đánh cú này" (animate quỹ đạo rồi áp kết quả), Undo 1 nấc, Auto-play. */
"use strict";
const $ = (s) => document.querySelector(s);
const all = (s) => [...document.querySelectorAll(s)];
// màu 9-ball chuẩn: fill bi / highlight gradient / màu line quỹ đạo.
// `dark:false` = bi sáng → số viết chữ đen. Bi 9 vẽ riêng (sọc vàng trên trắng).
const COLORS = {
cue: { fill: "#e8e8e8", hi: "#ffffff", dark: false },
"1": { fill: "#f5c518", hi: "#ffe27a", dark: false },
"2": { fill: "#4169e1", hi: "#8fa7f5", dark: true },
"3": { fill: "#e0342f", hi: "#f5847f", dark: true },
"4": { fill: "#7d3fbf", hi: "#b98ee6", dark: true },
"5": { fill: "#f07f13", hi: "#ffc27a", dark: false },
"6": { fill: "#2e9e46", hi: "#7fd694", dark: true },
"7": { fill: "#8b2020", hi: "#cf6b6b", dark: true },
"8": { fill: "#1c1c1c", hi: "#5a5a5a", dark: true },
"9": { fill: "#f5c518", hi: "#ffe27a", dark: false, stripe: true },
};
const CUE_LINE = "#d62728"; // quỹ đạo bi cái — nét liền, như CLI render_png
const TARGET_LINE = "#ff7f0e"; // quỹ đạo bi mục tiêu
const ZONE_LINE = "#38c8f5"; // vùng đích cue_zone của drill (F3) — khớp --zone
const MARGIN = 0.055; // m — viền gỗ vẽ quanh mặt bàn
const ALTERNATIVES = 3; // số cú thay thế xin từ API
const BALL_IDS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"];
// Bàn mẫu DỰ PHÒNG (fx, fy tỉ lệ bàn): chỉ dùng khi demo_boards.json không
// nạp được. Bàn mẫu THẬT lấy ngẫu nhiên từ danh sách đã QUÉT OFFLINE — seed
// nào V2 tự chơi chạy hết bộ tới bi 9 mới được vào danh sách
// (scripts/gen_demo_boards.py, BRIEF 31/07 việc B.5). Sim tất định nên seed
// đạt là đạt mãi — VỚI ĐÚNG BẢN TIÊU CHÍ NÀY; đổi tiêu chí là phải quét lại.
const PRESET = {
cue: [0.45, 0.15], "1": [0.32, 0.55], "2": [0.75, 0.48], "3": [0.22, 0.72],
"4": [0.60, 0.85], "5": [0.85, 0.68], "6": [0.40, 0.92], "7": [0.15, 0.40],
"8": [0.68, 0.28], "9": [0.50, 0.65],
};
let DEMO_BOARDS = null; // [{seed, balls: {id: [x, y] MÉT tuyệt đối}}]
async function loadDemoBoards() {
try {
const r = await fetch("demo_boards.json");
if (!r.ok) return;
const d = await r.json();
if (Array.isArray(d.boards) && d.boards.length) DEMO_BOARDS = d.boards;
} catch { /* thiếu file → PRESET dự phòng, app vẫn chạy */ }
}
const ANIM_MS = 1600; // thời lượng 1 cú khi bấm "Đánh cú này"
const FADE_TAU = 0.92; // τ bắt đầu mờ bi đã vào lỗ
const AUTO_MAX = 15; // safeguard auto-play (ván 9-ball tối đa 9 cú)
const AUTO_DELAY = 700; // ms nghỉ giữa 2 cú auto-play
const S = {
table: null, // response /api/table
balls: {}, // {cue: {x,y}, "1": {x,y,on}, ...} — applyPreset() dựng
jitReady: false,
shots: [], selected: -1, search: null, message: null,
fallback: null, // cú "ít tệ nhất" khi shots rỗng (chỉ xem, không đánh)
reqBalls: null, // thế bàn ĐÃ GỬI cho /api/recommend — /api/trajectory
// cần đúng thế bàn đó, không phải S.balls hiện tại
// (chúng chỉ trùng nhau nhờ clearShots() ở mọi chỗ
// đổi bi; giữ bản chụp thì không phải dựa vào điều đó)
dragging: null, busy: false,
undo: null, // snapshot S.balls 1 nấc (structuredClone)
auto: false, animating: false, gameOver: false,
scale: 1, cssW: 0, cssH: 0,
// ---- tab Bài tập (F3, 04/08/2026) — lớp thêm, không đụng luồng cũ
drillMode: false, // tab Bài tập đang mở
drill: null, // drill đã chọn (response /api/drills/{id}) | null
drillDone: null, // {pass, fail} của vòng tập hiện tại
drillPosting: false, // đang chờ POST /api/drill-attempts (chống double-tap)
savedBoard: null, // chụp S.balls/undo/gameOver lúc VÀO tab — thoát là trả
// ---- phiên bàn qua QR (bàn giao 12) — {id, table, expires_at, token} | null
session: null,
// ---- scan ảnh (F2, bàn giao 18) — ngữ cảnh scan GẮN VỚI thế bàn hiện tại:
// sửa tay (kéo bi / chip) thì edited=true nhưng scan_id còn; thay bàn hẳn
// (đánh cú, preset, undo, vào drill) thì ngữ cảnh scan hết — clearScanContext
scanId: null, // scan_id server trả — gửi lại trong /api/recommend
scanEdited: false, // người dùng ĐÃ sửa gì đó sau scan chưa
scanConf: {}, // {bid: conf} các bi CV chưa chắc (< SCAN_CONF_WARN)
// ---- tab Analyzer (bàn giao 24) — lớp thêm, KHÔNG đụng S.balls:
// canvas trong anMode vẽ kết quả phân tích thay vì bàn gợi ý.
// A2b: true từ boot — Analyzer là tab MẶC ĐỊNH (khớp HTML tĩnh: nút
// active + section hiện sẵn); ?qr= vào thẳng tab Gợi ý (init cuối file).
anMode: true,
};
// vị trí đè lên S.balls trong lúc animate: {pos: {bid: {x,y}}, alpha: {bid}}
let ANIM = null;
const canvas = $("#table");
const ctx = canvas.getContext("2d");
const tooltip = $("#tooltip");
// ------------------------------------------------------ toạ độ & kích thước
function sizeCanvas() {
if (!S.table) return;
const W = S.table.w + 2 * MARGIN, L = S.table.l + 2 * MARGIN;
// fit theo chiều cao (desktop) NHƯNG không tràn chiều rộng (mobile);
// mobile chừa thêm chỗ cho quickbar cố định dưới màn hình
const mobile = window.matchMedia("(max-width: 760px)").matches;
// sàn 200px: viewport bất thường (tab ẩn/chưa layout → innerWidth = 0) sẽ cho
// availW âm → cssW/cssH âm → ctx.arc() ném IndexSizeError vì bán kính âm
const availW = Math.max(200, Math.min(window.innerWidth - 28, 560));
let cssH = Math.max(340, Math.min(720, window.innerHeight - (mobile ? 210 : 130)));
let cssW = cssH * (W / L);
if (cssW > availW) { cssW = availW; cssH = cssW * (L / W); }
const dpr = window.devicePixelRatio || 1;
canvas.style.width = cssW + "px";
canvas.style.height = cssH + "px";
canvas.width = Math.round(cssW * dpr);
canvas.height = Math.round(cssH * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
S.scale = cssH / L;
S.cssW = cssW;
S.cssH = cssH;
}
const toPx = (x, y) => [(x + MARGIN) * S.scale, S.cssH - (y + MARGIN) * S.scale];
const toWorld = (px, py) => [px / S.scale - MARGIN, (S.cssH - py) / S.scale - MARGIN];
// ------------------------------------------------------------- trạng thái bàn
/** id các bi 1-9 đang trên bàn, thứ tự tăng dần. */
function onIds() {
return BALL_IDS.filter((b) => S.balls[b] && S.balls[b].on);
}
/** Bi mục tiêu = số nhỏ nhất còn trên bàn — MỘT nguồn sự thật, tính từ
S.balls chứ không lưu vào state (tự đúng sau khi đánh/undo/toggle).
`target` của response chỉ dùng để đối chiếu. */
function targetId() {
return onIds()[0] || null;
}
function applyPreset() {
clearScanContext(); // bàn mẫu thay hẳn thế bàn — ngữ cảnh scan hết
S.balls = {};
if (DEMO_BOARDS) {
// bàn "thoáng" đã curate: V2 tự chơi chạy hết bộ trong sim (B.5). Toạ độ
// trong file là MÉT TUYỆT ĐỐI (script sinh trên đúng bàn này).
const b = DEMO_BOARDS[Math.floor(Math.random() * DEMO_BOARDS.length)];
for (const [bid, xy] of Object.entries(b.balls)) {
S.balls[bid] = { x: xy[0], y: xy[1] };
if (bid !== "cue") S.balls[bid].on = true;
}
} else {
for (const [bid, [fx, fy]] of Object.entries(PRESET)) {
S.balls[bid] = { x: fx * S.table.w, y: fy * S.table.l };
if (bid !== "cue") S.balls[bid].on = true;
}
}
S.undo = null;
S.gameOver = false;
hideBanner();
clearShots();
renderChips();
updateTargetBadge();
syncButtons();
draw();
}
// -------------------------------------------------------------------- vẽ
function draw() {
if (!S.table) return;
const t = S.table;
// anMode: bàn vẽ kết quả Analyzer — không có cú gợi ý nào trên đó
const shot = S.anMode ? null : currentShot();
// khung gỗ + mặt bàn (gradient cho đỡ phẳng)
ctx.clearRect(0, 0, S.cssW, S.cssH);
const wood = ctx.createLinearGradient(0, 0, S.cssW, S.cssH);
wood.addColorStop(0, "#5e3c23");
wood.addColorStop(1, "#3e2916");
ctx.fillStyle = wood;
roundedFill(0, 0, S.cssW, S.cssH, 16);
const [fx, fy] = toPx(0, t.l);
const fw = t.w * S.scale, fh = t.l * S.scale;
const felt = ctx.createLinearGradient(fx, fy, fx, fy + fh);
felt.addColorStop(0, "#0e7d48");
felt.addColorStop(1, "#0a5a34");
ctx.fillStyle = felt;
roundedFill(fx, fy, fw, fh, 6);
ctx.strokeStyle = "rgba(0,0,0,.28)";
ctx.lineWidth = 1;
ctx.strokeRect(fx + .5, fy + .5, fw - 1, fh - 1);
// 6 lỗ (+ vòng vàng lỗ đích của cú đang chọn)
t.pockets.forEach((p, i) => {
const [px, py] = toPx(p.x, p.y);
ctx.beginPath();
ctx.arc(px, py, p.r * S.scale, 0, 7);
ctx.fillStyle = "#000";
ctx.fill();
if (shot && shot.pocket.index === i) {
ctx.beginPath();
ctx.arc(px, py, p.r * S.scale * 1.35, 0, 7);
ctx.strokeStyle = "#ffd700";
ctx.lineWidth = 2.5;
ctx.setLineDash([]);
ctx.stroke();
}
});
// vùng đích drill — vẽ TRƯỚC quỹ đạo/bi để nằm dưới chúng (F3, 04/08)
if (S.drill) drawDrillOverlay();
// tab Analyzer (bàn giao 24): sau khung bàn + lỗ là nội dung phân tích,
// KHÔNG vẽ bi/quỹ đạo của luồng gợi ý — hai thế giới không trộn nhau
if (S.anMode) { drawAnalyzerOverlay(); return; }
// quỹ đạo cú đang chọn — mọi bi có mặt trong response (BE đã lọc bi đứng yên)
if (shot) {
const isFallback = S.selected < 0;
const tgt = shot.target;
for (const [bid, xy] of Object.entries(shot.trajectories || {})) {
if (!xy || xy.length < 2) continue;
const line = bid === "cue" ? CUE_LINE
: bid === tgt ? TARGET_LINE
: (COLORS[bid] || {}).fill || "#ccc";
const dash = bid === "cue" ? [] : bid === tgt ? [7, 5] : [2, 5];
ctx.beginPath();
xy.forEach((p, k) => {
const [px, py] = toPx(p[0], p[1]);
k ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
});
ctx.strokeStyle = line;
ctx.lineWidth = 2;
ctx.setLineDash(dash);
ctx.globalAlpha = isFallback ? 0.45
: (bid === "cue" || bid === tgt) ? 0.95 : 0.6;
ctx.shadowColor = line; // glow nhẹ cho quỹ đạo nổi trên nỉ
ctx.shadowBlur = 6;
ctx.stroke();
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
ctx.setLineDash([]);
}
// bi cái mờ ở vị trí dừng dự kiến (không vẽ khi đang animate — sẽ rối)
const bf = shot.outcome.balls_final || {};
if (!ANIM && !isFallback && bf.cue && !shot.outcome.scratch) {
drawBall(bf.cue.x, bf.cue.y, "cue", 0.55, { ghost: true });
}
}
// bi trên bàn (bi tắt không vẽ). Trong lúc animate: vị trí lấy từ ANIM.
const tgt = targetId();
for (const bid of ["cue", ...onIds()]) {
const b = S.balls[bid];
if (!b) continue;
const p = (ANIM && ANIM.pos[bid]) || b;
const a = (ANIM && ANIM.alpha[bid] !== undefined) ? ANIM.alpha[bid] : 1;
if (a <= 0.01) continue;
drawBall(p.x, p.y, bid, a, {
isTarget: bid === tgt && !ANIM,
lowConf: !ANIM && S.scanConf[bid] !== undefined, // bi scan chưa chắc
});
}
}
/** Vòng tròn cue_zone (màu riêng --zone, nhãn "vùng đích") + nhẫn lỗ goal
của drill đang chọn. Lỗ goal dùng nét ĐỨT cùng màu zone để không lẫn với
nhẫn vàng "lỗ đích của cú gợi ý" (nét liền, gold). */
function drawDrillOverlay() {
const z = S.drill.goal.cue_zone;
const [zx, zy] = toPx(z.cx, z.cy);
const zr = z.r * S.scale;
ctx.beginPath();
ctx.arc(zx, zy, zr, 0, 7);
ctx.fillStyle = "rgba(56, 200, 245, .13)";
ctx.fill();
ctx.strokeStyle = ZONE_LINE;
ctx.lineWidth = 2;
ctx.setLineDash([6, 5]);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = ZONE_LINE;
ctx.font = "600 11px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.fillText("vùng đích", zx, zy + zr + 3);
const pk = S.drill.goal.pot.pocket;
if (pk !== "any" && S.table.pockets[pk]) {
const p = S.table.pockets[pk];
const [px, py] = toPx(p.x, p.y);
ctx.beginPath();
ctx.arc(px, py, p.r * S.scale * 1.35, 0, 7);
ctx.strokeStyle = ZONE_LINE;
ctx.lineWidth = 2.5;
ctx.setLineDash([5, 4]);
ctx.stroke();
ctx.setLineDash([]);
}
}
function roundedFill(x, y, w, h, r) {
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(x, y, w, h, r);
else ctx.rect(x, y, w, h);
ctx.fill();
}
function drawBall(x, y, bid, alpha, opts = {}) {
const [px, py] = toPx(x, y);
const r = S.table.ball_r * S.scale;
const c = COLORS[bid] || COLORS.cue;
ctx.globalAlpha = alpha;
if (alpha === 1) { // bóng đổ nhẹ (bi thật, không áp cho bi ma)
ctx.shadowColor = "rgba(0,0,0,.45)";
ctx.shadowBlur = 4;
ctx.shadowOffsetY = 1.5;
}
if (c.stripe) {
// bi 9: nền trắng + băng ngang vàng qua tâm, clip trong hình tròn
ctx.save();
ctx.beginPath();
ctx.arc(px, py, r, 0, 7);
ctx.clip();
const g = ctx.createRadialGradient(px - r * .35, py - r * .4, r * .15, px, py, r);
g.addColorStop(0, "#ffffff");
g.addColorStop(1, "#e2e2e2");
ctx.fillStyle = g;
ctx.fillRect(px - r, py - r, 2 * r, 2 * r);
const gs = ctx.createLinearGradient(px, py - r * .9, px, py + r * .9);
gs.addColorStop(0, c.hi);
gs.addColorStop(1, c.fill);
ctx.fillStyle = gs;
ctx.fillRect(px - r, py - r * .9, 2 * r, r * 1.8);
ctx.restore();
} else {
const g = ctx.createRadialGradient(px - r * .35, py - r * .4, r * .15, px, py, r);
g.addColorStop(0, c.hi);
g.addColorStop(1, c.fill);
ctx.beginPath();
ctx.arc(px, py, r, 0, 7);
ctx.fillStyle = g;
ctx.fill();
}
ctx.shadowBlur = 0;
ctx.shadowOffsetY = 0;
// bi mục tiêu: viền vàng sáng + glow (hiện cả khi chưa bấm Gợi ý)
if (opts.isTarget) {
ctx.beginPath();
ctx.arc(px, py, r + 1, 0, 7);
ctx.strokeStyle = "#ffd700";
ctx.lineWidth = 2.5;
ctx.shadowColor = "#ffd700";
ctx.shadowBlur = 9;
ctx.setLineDash([]);
ctx.stroke();
ctx.shadowBlur = 0;
} else {
ctx.beginPath();
ctx.arc(px, py, r, 0, 7);
ctx.strokeStyle = opts.ghost ? CUE_LINE : "rgba(0,0,0,.55)";
ctx.lineWidth = 1.2;
ctx.setLineDash([]);
ctx.stroke();
}
// bi scan CV chưa chắc (conf thấp, bàn giao 18): viền ĐỨT màu cam — khác
// nhẫn vàng liền của bi mục tiêu; kéo bi là viền tắt (user đã xác nhận)
if (opts.lowConf) {
ctx.beginPath();
ctx.arc(px, py, r + 2.5, 0, 7);
ctx.strokeStyle = "#ffb020";
ctx.lineWidth = 2;
ctx.setLineDash([4, 3]);
ctx.stroke();
ctx.setLineDash([]);
}
if (bid !== "cue" && !opts.ghost) {
ctx.fillStyle = c.dark ? "#fff" : "#111";
ctx.font = `bold ${Math.max(9, r)}px system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(bid, px, py);
}
ctx.globalAlpha = 1;
}
// ------------------------------------------------------------- kéo-thả bi
function ballAt(px, py) {
const grab = Math.max(S.table.ball_r * S.scale * 2.2, 20); // tap target mobile
let best = null, bestD = Infinity;
for (const id of ["cue", ...onIds()]) {
const [bx, by] = toPx(S.balls[id].x, S.balls[id].y);
const d = Math.hypot(px - bx, py - by);
if (d < grab && d < bestD) { best = id; bestD = d; }
}
return best;
}
canvas.addEventListener("pointerdown", (e) => {
if (!S.table || S.busy || S.auto || S.animating) return;
const rect = canvas.getBoundingClientRect();
const id = ballAt(e.clientX - rect.left, e.clientY - rect.top);
if (!id) return;
S.dragging = id;
// sửa tay sau scan (van an toàn §5.2): edited=true cho recommend log;
// bi được đụng tới coi như đã xác nhận — tắt viền "chưa chắc" của nó
if (S.scanId) { S.scanEdited = true; delete S.scanConf[id]; }
canvas.classList.add("dragging");
canvas.setPointerCapture(e.pointerId);
if (S.gameOver) { S.gameOver = false; hideBanner(); syncButtons(); }
clearShots(); // vị trí đổi → kết quả cũ hết hiệu lực
moveDrag(e);
});
canvas.addEventListener("pointermove", moveDrag);
for (const ev of ["pointerup", "pointercancel"]) {
canvas.addEventListener(ev, () => {
S.dragging = null;
canvas.classList.remove("dragging");
tooltip.classList.add("hidden");
});
}
function moveDrag(e) {
if (!S.dragging) return;
const rect = canvas.getBoundingClientRect();
let [x, y] = toWorld(e.clientX - rect.left, e.clientY - rect.top);
const r = S.table.ball_r;
x = Math.min(Math.max(x, r), S.table.w - r);
y = Math.min(Math.max(y, r), S.table.l - r);
// spread giữ cờ `on` — gán {x, y} trần sẽ xoá nó và bi biến mất
S.balls[S.dragging] = { ...S.balls[S.dragging], x, y };
tooltip.textContent = `${S.dragging}: (${x.toFixed(2)}, ${y.toFixed(2)}) m`;
tooltip.style.left = (e.clientX - rect.left + 16) + "px";
tooltip.style.top = (e.clientY - rect.top - 8) + "px";
tooltip.classList.remove("hidden");
draw();
}
// ------------------------------------------------------------------- API
async function loadTable() {
try {
const r = await fetch("/api/table");
if (!r.ok) throw new Error(String(r.status));
S.table = await r.json();
sizeCanvas();
applyPreset();
} catch {
setTimeout(loadTable, 1000); // env chưa lên — thử lại
}
}
async function pollHealth() {
let h = null;
try { h = await (await fetch("/api/health")).json(); } catch { /* server chưa lên */ }
if (h) {
S.jitReady = h.jit_ready;
// mode "local" = bản deploy một tiến trình, CV chạy CPU (việc B 14/08).
// FE dùng đúng tín hiệu này để cảnh báo chậm — không đoán theo user-agent
// và cũng không cảnh báo oan trên máy có GPU (mode queue).
S.cpuOnly = h.mode === "local";
syncAnUi();
if (h.status === "ok") {
hideBanner();
} else {
showBanner(h.status === "error"
? "Server lỗi khởi động: " + (h.error || "xem console uvicorn")
: "Đang khởi động vật lý (JIT Numba, ~40s lần đầu)...",
h.status === "error" ? "error" : null);
}
syncButtons();
if (!S.shots.length) updateShotViz(null); // cập nhật dòng trạng thái quickbar
if (h.status === "error") return; // dừng poll — cần restart server
}
if (!S.jitReady) setTimeout(pollHealth, 2000);
}
function overlapError() {
// FE validate trước cho đỡ round-trip; BE vẫn validate lại (message y hệt CLI)
const ids = ["cue", ...onIds()];
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
const a = S.balls[ids[i]], b = S.balls[ids[j]];
if (Math.hypot(a.x - b.x, a.y - b.y) < 2 * S.table.ball_r) {
return `${ids[i]}${ids[j]} chồng lên nhau (< 2R) — kéo bi ra xa nhau`;
}
}
}
return null;
}
async function recommend() {
if (!S.jitReady || S.busy || S.animating || S.gameOver || !S.table) return;
const err = overlapError();
if (err) { toast(err); return; }
const balls = { cue: { x: S.balls.cue.x, y: S.balls.cue.y } };
for (const bid of onIds()) {
balls[bid] = { x: S.balls[bid].x, y: S.balls[bid].y };
}
// KHÔNG còn trường engine (31/07): server chỉ có một đường — zone V2.
const body = { balls, alternatives: ALTERNATIVES };
// scan (bàn giao 18): thế bàn hiện tại đến từ ảnh scan → gửi scan_id +
// edited để BE ghi log — trường sẵn từ bàn giao 10, response không đổi byte
if (S.scanId) {
body.scan_id = S.scanId;
body.edited = S.scanEdited;
}
// phiên bàn (bàn giao 12): chỉ để BE gắn tenant vào row log — response
// không đổi một byte, nên FE cũng không đọc gì thêm từ nó.
// tvLocalId (bàn giao 15): không có phiên QR nhưng ĐÃ ghép TV bằng id cục
// bộ → gửi id đó để BE push cú xuống TV; chưa từng ghép thì null — request
// y hệt trước bàn giao 15.
const sid = sessionId() || tvLocalId();
if (sid) body.session_id = sid;
setBusy(true);
try {
const r = await fetch("/api/recommend", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) {
toast(errText(data, r.status));
return;
}
S.shots = data.shots || [];
S.fallback = data.fallback || null;
S.search = data.search || null;
S.message = data.message || null;
S.reqBalls = balls;
S.selected = S.shots.length ? 0 : -1;
if (data.target && data.target !== targetId()) {
console.warn("target lệch giữa FE và BE:", targetId(), "vs", data.target);
}
renderPanel();
} catch {
toast("Không gọi được API — server có đang chạy không?");
} finally {
setBusy(false);
draw();
}
}
/** Quỹ đạo của cú thay thế — nạp LƯỜI (30/07/2026).
`/api/recommend` chỉ dựng quỹ đạo cho cú rank 1 (`n_render=1` ở BE); ba cú
còn lại có đủ tham số nhưng `trajectories` rỗng. Hàm này lấy phần thiếu
rồi cache thẳng vào `S.shots[i]`, nên bấm qua lại lần thứ hai vẽ ngay.
KHÔNG chặn UI, cố ý: không đụng `S.busy`, không disable nút nào. Người
dùng bấm sang cú khác giữa chừng vẫn được — request cũ về sau chỉ ghi vào
đúng object cú của nó và chỉ vẽ lại nếu cú đó còn nằm trong `S.shots`. */
async function ensureTrajectories(i) {
const s = S.shots[i];
if (!s || !S.reqBalls) return;
if (s.trajectories && Object.keys(s.trajectories).length) return;
if (s._trajPending) return s._trajPending; // bấm qua lại: MỘT request thôi
s._trajPending = (async () => {
try {
const r = await fetch("/api/trajectory", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
balls: S.reqBalls, phi: s.phi, v0: s.v0, side: s.side, vert: s.vert,
}),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) { toast(errText(data, r.status)); return; }
s.trajectories = data.trajectories || {};
} catch {
toast("Không lấy được quỹ đạo cú này — server có đang chạy không?");
} finally {
s._trajPending = null;
if (S.shots[i] === s) { renderPanel(); draw(); }
}
})();
renderPanel(); // hiện trạng thái chờ trên dòng .alt (không chặn gì khác)
return s._trajPending;
}
// ------------------------------------------- phiên bàn qua QR (bàn giao 12)
// Khách quét QR dán trên bàn → URL /?qr=<token> (query param — giữ single
// page, không thêm route). Phiên nằm trong sessionStorage CHỨ KHÔNG
// localStorage: phiên là của LƯỢT ngồi bàn, không phải của máy — đóng tab là
// sạch, khớp TTL 24h phía server. QR hỏng / Space không DB → một toast rồi
// chạy chế độ thường — QR không bao giờ chặn dùng app.
const SESSION_KEY = "poolcoach-session";
/** Phiên đã lưu trong sessionStorage; hỏng/hết hạn → dọn lặng lẽ, trả null. */
function readStoredSession() {
try {
const s = JSON.parse(sessionStorage.getItem(SESSION_KEY));
if (s && s.id && Date.parse(s.expires_at) > Date.now()) return s;
} catch { /* JSON hỏng — coi như không có phiên */ }
sessionStorage.removeItem(SESSION_KEY);
return null;
}
/** session_id còn hiệu lực | null. Kiểm hạn MỖI lần dùng: phiên hết hạn giữa
chừng thì bỏ lặng lẽ (badge tắt), request sau chạy chế độ thường (1.4). */
function sessionId() {
if (S.session && Date.parse(S.session.expires_at) > Date.now()) {
return S.session.id;
}
if (S.session) dropSession();
return null;
}
function dropSession() {
S.session = null;
sessionStorage.removeItem(SESSION_KEY);
updateSessionBadge();
}
function updateSessionBadge() {
const b = $("#session-badge");
if (S.session) {
b.textContent = `Bàn: ${S.session.table}`;
b.classList.remove("hidden");
} else {
b.classList.add("hidden");
}
}
/** Vào phiên lúc mở trang. Token trong URL đã đổi phiên rồi (reload cùng
tab) thì DÙNG LẠI phiên trong sessionStorage — mỗi GET /api/table-qr là
một session mới phía server, reload không được phép nhân bản phiên. */
async function initSession() {
const token = new URLSearchParams(location.search).get("qr");
S.session = readStoredSession();
if (!token || (S.session && S.session.token === token)) {
updateSessionBadge();
return;
}
try {
const r = await fetch("/api/table-qr/" + encodeURIComponent(token));
const data = await r.json().catch(() => ({}));
if (!r.ok) {
// 400/404/503 — detail server đã là câu tiếng Việt nói rõ lý do;
// app chạy tiếp chế độ thường với phiên cũ (nếu còn) hoặc không phiên
toast(errText(data, r.status));
updateSessionBadge();
return;
}
S.session = { id: data.session_id, table: data.table.name,
expires_at: data.expires_at, token };
sessionStorage.setItem(SESSION_KEY, JSON.stringify(S.session));
} catch {
toast("Không kết nối được server để vào phiên bàn — chạy chế độ thường.");
}
updateSessionBadge();
}
// ------------------------------------------------- Ghép TV (bàn giao 15)
// Chìa ghép = session_id gửi lên /api/displays/pair, và cũng là chìa mà
// /api/recommend dùng để push (BE chỉ SO CHUỖI, không validate DB):
// - đang trong phiên QR → dùng luôn session_id đó ("bàn của phiên");
// - không phiên (Space degraded, không DB) → sinh id cục bộ "tv-…" trong
// sessionStorage, CHỈ LƯU sau khi pair 200 — pair fail hay chưa từng
// ghép thì recommend không gửi gì thêm, hành vi cũ y nguyên (G4).
const TV_KEY = "poolcoach-tv-session";
/** id cục bộ đã dùng để ghép TV | null nếu chưa từng ghép kiểu cục bộ. */
function tvLocalId() {
return sessionStorage.getItem(TV_KEY);
}
async function pairTv() {
const code = $("#tv-code").value.trim();
if (!/^\d{6}$/.test(code)) {
toast("Mã ghép là 6 chữ số hiện trên màn hình TV.");
return;
}
const qrSid = sessionId();
const sid = qrSid || tvLocalId()
|| ("tv-" + Math.random().toString(36).slice(2, 12));
try {
const r = await fetch("/api/displays/pair", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, session_id: sid }),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) { toast(errText(data, r.status)); return; }
if (!qrSid) sessionStorage.setItem(TV_KEY, sid);
$("#tv-pair").classList.add("hidden");
$("#tv-code").value = "";
toast("Đã ghép TV — bấm Gợi ý là cú đánh hiện lên màn hình.");
} catch {
toast("Không gọi được API — server có đang chạy không?");
}
}
// ------------------------------------------- Scan ảnh (F2 MVP, bàn giao 18)
// Upload ảnh → chấm 4 góc mặt nỉ → POST /api/scan (CV worker) → thế bi lên
// bàn. Đây là VAN AN TOÀN FullVision §5.2: CV chỉ GỢI Ý thế bi — người chơi
// xác nhận/sửa (kéo-thả, chip số) trước khi xin gợi ý. Số bi: worker mới
// gán theo MÀU (BallID, bàn giao 22) — bi thiếu/trùng số rơi xuống lưới an
// toàn đánh-số-theo-vị-trí cũ (applyScan); response kiểu cũ không number →
// toàn bộ đi lưới an toàn, hành vi y hệt bàn giao 18.
const SCAN_MAX_DIM = 1280; // resize TRƯỚC khi upload: giảm payload HTTP +
// Redis (BRIEF 1.4); worker vẫn tự phòng hờ 1280
const SCAN_JPEG_Q = 0.9;
const SCAN_CONF_WARN = 0.5; // dưới ngưỡng: viền cam đứt nhắc kiểm lại
const NUMBER_CONF_WARN = 0.5; // dưới ngưỡng: SỐ CV gán coi như chưa chắc —
// cùng cơ chế viền cam đứt với conf vị trí
const SCAN_CORNERS_KEY = "poolcoach-scan-corners";
// thứ tự chấm góc = thứ tự table_corners() của BE: đi VÒNG quanh bàn,
// 2 góc đầu là một BĂNG NGẮN — (0,0) → (W,0) → (W,L) → (0,L)
const SCAN_STEPS = [
"Chấm góc ① — một góc TUỲ Ý của BĂNG NGẮN (đầu bàn) gần camera, ngay mép nỉ.",
"Chấm góc ② — góc còn lại của CÙNG băng ngắn đó (trái hay phải trước đều được).",
"Chấm góc ③ — sang băng ngắn ĐỐI DIỆN: góc thẳng phía trên góc ②.",
"Chấm góc ④ — góc cuối cùng (thẳng phía trên góc ①).",
];
const SCAN = {
img: null, // canvas offscreen chứa ảnh ĐÃ resize — cái thật sự gửi đi
blob: null, // JPEG blob của đúng ảnh đó
corners: [], // [[x,y]×4] pixel trên ảnh đã resize
reused: false, // corners nạp lại từ sessionStorage (cùng khung hình)
busy: false,
};
function clearScanContext() {
S.scanId = null;
S.scanEdited = false;
S.scanConf = {};
}
function openScan() {
if (!S.table || S.busy || S.animating || S.auto) return;
$("#scan-file").click(); // overlay chỉ mở khi ảnh đã đọc xong
}
async function onScanFile(e) {
const file = e.target.files && e.target.files[0];
e.target.value = ""; // chọn lại CÙNG file lần sau vẫn kích change
if (!file) return;
let bmp;
try {
bmp = await createImageBitmap(file);
} catch {
toast("Không đọc được ảnh — chọn file JPEG/PNG.");
return;
}
// resize ngay tại đây; corners chấm trên đúng ảnh đã resize nên gửi đi
// không phải quy đổi gì nữa
const k = Math.min(1, SCAN_MAX_DIM / Math.max(bmp.width, bmp.height));
const w = Math.round(bmp.width * k), h = Math.round(bmp.height * k);
const off = document.createElement("canvas");
off.width = w;
off.height = h;
off.getContext("2d").drawImage(bmp, 0, 0, w, h);
bmp.close && bmp.close();
SCAN.img = off;
SCAN.blob = await new Promise((res) => off.toBlob(res, "image/jpeg", SCAN_JPEG_Q));
if (!SCAN.blob) { toast("Không nén được ảnh — thử ảnh khác."); return; }
// corners cũ chỉ dùng lại khi ảnh mới CÙNG kích thước (một camera cố định
// ra cùng khung hình); khác kích thước là khung khác — chấm lại từ đầu
SCAN.corners = [];
SCAN.reused = false;
try {
const saved = JSON.parse(sessionStorage.getItem(SCAN_CORNERS_KEY));
if (saved && saved.w === w && saved.h === h &&
Array.isArray(saved.pts) && saved.pts.length === 4) {
SCAN.corners = saved.pts.map((p) => [p[0], p[1]]);
SCAN.reused = true;
}
} catch { /* JSON hỏng — coi như chưa từng lưu */ }
$("#scan-overlay").classList.remove("hidden");
drawScanCanvas();
syncScanUi();
}
function drawScanCanvas() {
const c = $("#scan-canvas");
c.width = SCAN.img.width; // backing = pixel ảnh; CSS tự thu về vừa khung
c.height = SCAN.img.height;
const g = c.getContext("2d");
g.drawImage(SCAN.img, 0, 0);
const lw = Math.max(2, c.width / 420);
const n = SCAN.corners.length;
if (n > 1) {
g.beginPath();
SCAN.corners.forEach(([x, y], i) => (i ? g.lineTo(x, y) : g.moveTo(x, y)));
if (n === 4) g.closePath();
g.strokeStyle = "#ffd700";
g.lineWidth = lw;
g.setLineDash([9, 7]);
g.stroke();
g.setLineDash([]);
}
const r = Math.max(8, c.width / 85);
SCAN.corners.forEach(([x, y], i) => {
g.beginPath();
g.arc(x, y, r, 0, 7);
g.fillStyle = "rgba(255, 215, 0, .92)";
g.fill();
g.strokeStyle = "#1c1c1c";
g.lineWidth = lw;
g.stroke();
g.fillStyle = "#1c1c1c";
g.font = `bold ${Math.round(r * 1.15)}px system-ui, sans-serif`;
g.textAlign = "center";
g.textBaseline = "middle";
g.fillText(String(i + 1), x, y);
});
}
function syncScanUi() {
const n = SCAN.corners.length;
const hint = $("#scan-hint");
if (SCAN.busy) {
hint.textContent = "Đang quét — CV đang tìm bi trên ảnh...";
} else if (n < 4) {
hint.textContent = `(${n}/4) ${SCAN_STEPS[n]}`;
} else {
hint.textContent = SCAN.reused
? "Dùng lại 4 góc lần scan trước (ảnh cùng khung hình) — bấm Quét, "
+ "hoặc Chấm lại góc nếu camera đã xê dịch."
: "Đủ 4 góc — bấm Quét thế bi. Góc lệch thì Chấm lại.";
}
$("#scan-go").disabled = n !== 4 || SCAN.busy;
$("#scan-go").textContent = SCAN.busy ? "Đang quét..." : "Quét thế bi";
$("#scan-redo").disabled = SCAN.busy || n === 0;
}
function onScanCanvasDown(e) {
if (SCAN.busy || !SCAN.img || SCAN.corners.length >= 4) return;
e.preventDefault();
const c = $("#scan-canvas");
const rect = c.getBoundingClientRect();
// canvas backing = pixel ảnh → quy đổi toạ độ click theo tỉ lệ hiển thị
const x = (e.clientX - rect.left) * (c.width / rect.width);
const y = (e.clientY - rect.top) * (c.height / rect.height);
SCAN.corners.push([Math.round(x * 10) / 10, Math.round(y * 10) / 10]);
SCAN.reused = false;
drawScanCanvas();
syncScanUi();
}
function closeScanOverlay() {
if (SCAN.busy) return; // đang chờ server — đừng đóng nửa chừng
$("#scan-overlay").classList.add("hidden");
}
async function doScan() {
if (SCAN.busy || SCAN.corners.length !== 4 || !SCAN.blob) return;
SCAN.busy = true;
syncScanUi();
try {
const fd = new FormData();
fd.append("image", SCAN.blob, "scan.jpg");
fd.append("corners", JSON.stringify(SCAN.corners));
const r = await fetch("/api/scan", { method: "POST", body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok) { toast(errText(data, r.status)); return; }
sessionStorage.setItem(SCAN_CORNERS_KEY, JSON.stringify(
{ w: SCAN.img.width, h: SCAN.img.height, pts: SCAN.corners }));
SCAN.busy = false; // xong trước khi đóng (closeScan chặn busy)
closeScanOverlay();
applyScan(data);
} catch {
toast("Không gọi được API — server có đang chạy không?");
} finally {
SCAN.busy = false;
syncScanUi();
}
}
/** Đổ kết quả scan lên bàn. Hai đường gán số:
(1) worker mới (bàn giao 22): bi có `number` 1–9 hợp lệ đặt ĐÚNG số đó —
mỗi số một bi; số null/trùng/ngoài khoảng rơi xuống (2);
(2) LƯỚI AN TOÀN = heuristic vị trí cũ (bàn giao 18, giữ nguyên có chủ
đích): giữ bi CONF CAO NHẤT cho các số còn trống, đánh số theo VỊ TRÍ —
y tăng rồi x tăng — tất định, nhìn bàn đoán được bi nào mang số nào.
Response kiểu cũ (không trường number) đi trọn đường (2): y hệt trước.
Viền cam đứt (cơ chế conf sẵn có) thêm ca: số CV gán nhưng number_conf
thấp, hoặc bi phải đi lưới an toàn trong khi response CÓ BallID. */
function applyScan(data) {
const rb = S.table.ball_r;
const cx = (v) => Math.min(Math.max(v, rb), S.table.w - rb);
const cy = (v) => Math.min(Math.max(v, rb), S.table.l - rb);
const cues = data.balls.filter((b) => b.type === "cue");
const others = data.balls.filter((b) => b.type !== "cue");
const nAll = others.length;
const hasId = others.some((b) => b.number !== undefined);
const byNum = {}; // "1".."9" → bi nhận số đó
let rest = [];
for (const b of others) {
if (Number.isInteger(b.number) && b.number >= 1 && b.number <= 9
&& !byNum[b.number]) byNum[b.number] = b;
else rest.push(b); // null / trùng số / ngoài 1-9
}
const freeIds = BALL_IDS.filter((bid) => !byNum[bid]);
rest.sort((a, b) => b.conf - a.conf);
rest = rest.slice(0, freeIds.length);
rest.sort((a, b) => (a.y - b.y) || (a.x - b.x));
rest.forEach((b, i) => { byNum[freeIds[i]] = b; });
const nPlaced = Object.keys(byNum).length;
S.undo = structuredClone(S.balls); // scan đè bàn — Undo 1 nấc lấy lại
S.scanConf = {};
BALL_IDS.forEach((bid) => {
const src = byNum[bid];
const cur = S.balls[bid] || { x: S.table.w / 2, y: S.table.l / 2 };
if (src) {
S.balls[bid] = { x: cx(src.x), y: cy(src.y), on: true };
// số đáng ngờ = response có BallID nhưng bi này KHÔNG mang đúng số
// CV chắc tay (đi lưới an toàn, hoặc number_conf thấp)
const numOk = Number.isInteger(src.number) && String(src.number) === bid
&& (src.number_conf || 0) >= NUMBER_CONF_WARN;
if (src.conf < SCAN_CONF_WARN || (hasId && !numOk)) {
S.scanConf[bid] = src.conf;
}
} else {
S.balls[bid] = { ...cur, on: false };
}
});
if (cues.length) {
S.balls.cue = { x: cx(cues[0].x), y: cy(cues[0].y) };
if (cues[0].conf < SCAN_CONF_WARN) S.scanConf.cue = cues[0].conf;
}
S.scanId = data.scan_id;
S.scanEdited = false;
S.gameOver = false;
hideBanner();
clearShots();
renderChips();
updateTargetBadge();
syncButtons();
draw();
const parts = [hasId
? `Đã lên ${nPlaced} bi từ ảnh — số bi gán theo MÀU, kiểm nhanh các bi `
+ `viền cam đứt rồi bấm Gợi ý`
: `Đã lên ${nPlaced} bi từ ảnh — số bi GÁN TẠM, kéo-thả/chip để sửa `
+ `rồi bấm Gợi ý`];
if (nAll > BALL_IDS.length) parts.push(`CV thấy ${nAll} bi, giữ 9 bi rõ nhất`);
if (!cues.length) parts.push("CV không thấy bi cái — tự kéo bi trắng vào đúng chỗ");
const nWarn = Object.keys(S.scanConf).length;
if (nWarn) parts.push(`${nWarn} bi viền cam đứt: CV chưa chắc, kiểm lại`);
toast(parts.join(". ") + ".");
}
/** detail của FastAPI là string (HTTPException) hoặc list (pydantic). */
function errText(data, status) {
const d = data && data.detail;
if (typeof d === "string") return d;
if (Array.isArray(d) && d.length) return d[0].msg || `Lỗi ${status}`;
return `Lỗi ${status}`;
}
// ------------------------------------------- tab Analyzer (bàn giao 24)
// Upload video 1..N cú → FE tự trích frame ĐẦU bằng <video> (click pocket
// chắc chắn nằm trên frame của chính video đó — không round-trip server) →
// POST /api/analyzer/videos → poll GET → bảng danh sách cú + trang chi tiết.
// Toạ độ kết quả là hệ bàn BROADCAST (2.54×1.27m) — vẽ lên bàn pooltool
// bằng một hệ số đồng dạng (cả hai đều 2:1), số trong bảng giữ nguyên mét.
// Luồng 1-cú (POST /api/analyze) GỠ ở A2b — video 1 cú đi luồng đa cú.
const AN_SMOOTH_WIN = 3; // cửa sổ trung bình trượt CHỈ ĐỂ VẼ (nhiễu dọc
// hướng chạy ±13mm — HANDOFF 23 Bất ngờ 2)
// Ngưỡng cảnh báo BẤT ĐỒNG 2 cột model↔analytic (BG28) — bất đồng là THÔNG
// TIN cho người xem (thay vế verifier chưa có), không phải lỗi:
const AN_DIFF_PHI_DEG = 20; // |Δφ| vòng tròn ≥ mức này → dòng bất đồng
// (floor tự nhiên: squirt ±1.6° + nhiễu chord
// analytic vài độ — dưới 20° coi như khớp)
const AN_DIFF_V0_RATIO = 2; // V0 gậy (model) / tốc độ bi đo (analytic)
// ngoài [1/2, 2] → bất đồng. HAI THƯỚC khác
// nhau (gậy vs bi, vật lý cho phép lệch
// ~0.66–1.45×) nên chỉ bắt lệch THÔ
// thứ tự chấm pocket = thứ tự corners của BE (như scan): đi VÒNG quanh bàn,
// 2 điểm đầu là một BĂNG NGẮN. Độ cao điểm chấm theo chuẩn BG31: mép NOSE
// VẢI (câu + hình tĩnh #an-note trong index.html) — bù độ cao phía worker
// tính cho mặt phẳng vải, click cao hơn là bù quá tay.
const AN_STEPS = [
"Chấm pocket ① — một pocket GÓC tuỳ ý của BĂNG NGẮN (đầu bàn), ngay giao 2 mép nose vải.",
"Chấm pocket ② — pocket góc còn lại của CÙNG băng ngắn đó.",
"Chấm pocket ③ — sang băng ngắn ĐỐI DIỆN: pocket thẳng phía góc ②.",
"Chấm pocket ④ — pocket góc cuối cùng (thẳng phía góc ①).",
];
const AN = {
file: null, // File video đã chọn — gửi nguyên đi, không nén
frame: null, // canvas offscreen chứa frame ĐẦU (kích thước video gốc)
corners: [], // [[x,y]×4] pixel trên frame gốc
busy: false, // đang chờ POST /api/analyzer/videos
// --- gợi ý sẵn 4 góc (BRIEF 14/08 việc A) ---
suggesting: false, // đang chờ /api/analyzer/suggest-corners
suggested: false, // 4 điểm đang hiện là do MÁY đề xuất (chưa chấm tay)
drag: null, // index góc đang kéo | null
};
// ------------------------- Analyzer đa cú (lát A1, 13/08) — bảng danh sách
// Video 1..N cú → job segment server cắt cú → poll GET /api/analyzer/shots
// → bảng §3.1 hiện DẦN ("phát hiện 6 cú, xong 3/6"). Cú lỗi = hàng xám kèm
// lý do, KHÔNG biến mất. Chi tiết cú ở A1 = JSON thô (click hàng đã xong).
const ANV_MAX_DUR_S = 20 * 60 + 0.5; // server enforce 20 phút — chặn sớm
const ANV_POLL_MS = 1000;
// A2b phần 4 (feedback #3): lớp resim trên bàn chi tiết là toggle "So với
// mô phỏng", MẶC ĐỊNH TẮT — path quan sát ↔ resim đang lệch V0-thang, hiện
// cả hai thì rối. Ngày V0-thang được chữa: đổi hằng này thành true là xong.
const AND_RESIM_DEFAULT_ON = false;
// Thang thanh lực widget "Đánh lại cú": V0 GẬY của model — khoảng tin của
// van [1, 8] m/s nên trần 8.0 (thang tab gợi ý 0.5–4.0 là V0 grid engine,
// thước khác).
const AND_V0_MIN = 0.5, AND_V0_MAX = 8.0;
const ANV = {
videoId: null,
timer: null,
last: null, // body GET gần nhất — cho smoke/debug
// lát A2 phần 4 — trang chi tiết cú đang mở | null (danh sách):
// {idx, result (JSON per cú), overlayUrl, resultUrl, ts,
// showResim (A2b: trạng thái toggle "So với mô phỏng" per trang)}
detail: null,
isDemo: false, // bảng đang hiện là VIDEO MẪU bundled (việc B, 14/08)
};
function enterAnalyzer() {
if (S.anMode || S.animating || S.auto || S.busy) return;
if (S.drillMode) exitDrills();
S.anMode = true;
hideBanner();
clearShots({ keepSheet: true });
setTab("analyzer");
draw();
}
function exitAnalyzer() {
if (!S.anMode) return;
// "Đánh lại cú" đang animate: KHÔNG rời tab giữa chừng — ANIM đè vị trí
// bi theo key ("cue"...) mà tab Gợi ý cũng dùng, rời ngang là bi của
// luồng gợi ý chạy theo quỹ đạo resim (bẫy BRIEF A2: thoát tab phải sạch)
if (S.animating) return;
S.anMode = false;
// poll KHÔNG dừng: job server cứ chạy, quay lại tab là thấy kết quả
setTab("suggest");
clearShots({ keepSheet: true });
renderChips();
updateTargetBadge();
syncButtons();
draw();
}
function anvPick() {
if (AN.busy) return;
$("#an-file").click();
}
async function onAnFile(e) {
const file = e.target.files && e.target.files[0];
e.target.value = "";
if (!file) return;
// trích frame đầu: <video> + canvas — trình duyệt tự decode MP4/MOV h264
const url = URL.createObjectURL(file);
const v = document.createElement("video");
v.muted = true;
v.playsInline = true;
v.preload = "auto";
v.src = url;
try {
await new Promise((res, rej) => {
v.onloadeddata = res; // frame ở t=0 đã decode xong
v.onerror = () => rej(new Error("decode"));
setTimeout(() => rej(new Error("timeout")), 10000);
});
if (v.duration > ANV_MAX_DUR_S) {
toast(`Video dài ${(v.duration / 60).toFixed(1)} phút — giới hạn 20 `
+ `phút một lần quét: cắt đoạn trận cần phân tích rồi thử lại.`);
return;
}
const off = document.createElement("canvas");
off.width = v.videoWidth;
off.height = v.videoHeight;
off.getContext("2d").drawImage(v, 0, 0);
AN.file = file;
AN.frame = off;
AN.corners = [];
AN.suggested = false;
AN.suggestReason = null;
AN.drag = null;
} catch {
toast("Không đọc được video — cần MP4/MOV (H.264) trình duyệt mở được.");
return;
} finally {
URL.revokeObjectURL(url);
}
$("#an-overlay").classList.remove("hidden");
drawAnCanvas();
syncAnUi();
anSuggestCorners();
}
/** Hỏi server 4 góc ĐỀ XUẤT cho frame đầu (BRIEF 14/08 việc A).
Không chặn gì: người dùng chấm tay được ngay trong lúc chờ, và mọi
đường thất bại (server không có cv2, van sanity chặn, mạng hỏng) đều
rơi về luồng chấm tay Y HỆT trước đây — đề xuất là lớp TIỆN, không
phải điều kiện để phân tích. Vì thế không toast lỗi ồn ào: lý do hiện
ngay trong dòng hướng dẫn. */
async function anSuggestCorners() {
const frame = AN.frame;
AN.suggesting = true;
syncAnUi();
try {
const blob = await new Promise((res) =>
frame.toBlob(res, "image/jpeg", 0.92));
if (!blob) throw new Error("encode");
const fd = new FormData();
fd.append("image", blob, "frame.jpg");
const r = await fetch("/api/analyzer/suggest-corners",
{ method: "POST", body: fd });
const data = await r.json().catch(() => ({}));
// người dùng đã chấm tay / đổi video trong lúc chờ → bỏ kết quả cũ
if (AN.frame !== frame || AN.corners.length) return;
if (r.ok && data.ok && (data.corners || []).length === 4) {
AN.corners = data.corners.map((p) => [p[0], p[1]]);
AN.suggested = true;
AN.suggestReason = null;
} else {
AN.suggestReason = (r.ok ? data.reason : errText(data, r.status))
|| "Máy không đề xuất được góc bàn cho frame này.";
}
drawAnCanvas();
} catch {
if (AN.frame === frame) {
AN.suggestReason = "Không hỏi được server về góc đề xuất.";
}
} finally {
if (AN.frame === frame) {
AN.suggesting = false;
syncAnUi();
}
}
}
function drawAnCanvas() {
const c = $("#an-canvas");
c.width = AN.frame.width; // backing = pixel video gốc; CSS thu về khung
c.height = AN.frame.height;
const g = c.getContext("2d");
g.drawImage(AN.frame, 0, 0);
const lw = Math.max(2, c.width / 420);
const n = AN.corners.length;
if (n > 1) {
g.beginPath();
AN.corners.forEach(([x, y], i) => (i ? g.lineTo(x, y) : g.moveTo(x, y)));
if (n === 4) g.closePath();
g.strokeStyle = "#ffd700";
g.lineWidth = lw;
g.setLineDash([9, 7]);
g.stroke();
g.setLineDash([]);
}
const r = Math.max(8, c.width / 85);
AN.corners.forEach(([x, y], i) => {
g.beginPath();
g.arc(x, y, r, 0, 7);
g.fillStyle = "rgba(255, 215, 0, .92)";
g.fill();
g.strokeStyle = "#1c1c1c";
g.lineWidth = lw;
g.stroke();
g.fillStyle = "#1c1c1c";
g.font = `bold ${Math.round(r * 1.15)}px system-ui, sans-serif`;
g.textAlign = "center";
g.textBaseline = "middle";
g.fillText(String(i + 1), x, y);
});
}
function syncAnUi() {
const n = AN.corners.length;
const hint = $("#an-hint");
if (AN.busy) {
hint.textContent = "Đang gửi video lên server...";
} else if (AN.suggesting && n === 0) {
hint.textContent = "Đang dò mặt bàn để gợi ý sẵn 4 pocket... "
+ "(chấm tay được ngay, không cần chờ)";
} else if (n < 4) {
hint.textContent = (AN.suggestReason ? AN.suggestReason + " " : "")
+ `(${n}/4) ${AN_STEPS[n]}`;
} else if (AN.suggested) {
hint.textContent = "4 pocket MÁY ĐỀ XUẤT — kéo từng điểm cho khớp mép "
+ "nose vải rồi bấm Phân tích. Sai cặp băng ngắn thì bấm Xoay nhãn; "
+ "muốn tự chấm thì bấm Chấm lại.";
} else {
hint.textContent = "Đủ 4 pocket — bấm Phân tích. Kéo điểm để chỉnh, "
+ "hoặc Chấm lại.";
}
$("#an-go").disabled = n !== 4 || AN.busy;
$("#an-go").textContent = AN.busy ? "Đang gửi..." : "Phân tích";
$("#an-redo").disabled = AN.busy || n === 0;
// Cảnh báo chậm CHỈ trên bản deploy CPU (BRIEF 14/08 việc B mục 3): nói
// trước khi bấm, kèm thứ người dùng làm được (bảng hiện dần, đóng tab
// không mất kết quả) — không phải một câu doạ suông.
$("#an-cpu-warn").classList.toggle("hidden", !S.cpuOnly);
// Xoay nhãn chỉ có nghĩa khi đủ 4 điểm: nó sửa việc máy gán nhầm CẶP
// BĂNG NGẮN (heuristic bất đối xứng độ dài — xem poolcoach_cv/autocorner)
$("#an-rotate").disabled = AN.busy || n !== 4;
}
/** Toạ độ pixel FRAME GỐC từ một pointer event trên #an-canvas. */
function anCanvasPoint(e) {
const c = $("#an-canvas");
const rect = c.getBoundingClientRect();
return [
Math.round((e.clientX - rect.left) * (c.width / rect.width) * 10) / 10,
Math.round((e.clientY - rect.top) * (c.height / rect.height) * 10) / 10,
];
}
/** Index góc nằm trong tầm bắt của điểm chạm, -1 nếu không có. Tầm bắt =
2.5× bán kính chấm vẽ — ngón tay trên mobile cần rộng hơn con trỏ. */
function anGrabCorner([x, y]) {
const c = $("#an-canvas");
const r = Math.max(8, c.width / 85) * 2.5;
let best = -1, bd = r;
AN.corners.forEach(([px, py], i) => {
const d = Math.hypot(px - x, py - y);
if (d <= bd) { best = i; bd = d; }
});
return best;
}
function onAnCanvasDown(e) {
if (AN.busy || !AN.frame) return;
e.preventDefault();
const p = anCanvasPoint(e);
if (AN.corners.length >= 4) {
const i = anGrabCorner(p);
if (i < 0) return;
AN.drag = i;
try { $("#an-canvas").setPointerCapture(e.pointerId); } catch { /**/ }
return;
}
AN.corners.push(p);
drawAnCanvas();
syncAnUi();
}
function onAnCanvasMove(e) {
if (AN.drag === null || AN.busy) return;
e.preventDefault();
AN.corners[AN.drag] = anCanvasPoint(e);
// đã kéo tay thì không còn là "máy đề xuất" nguyên bản nữa — dòng hướng
// dẫn phải nói đúng thứ đang hiện
AN.suggested = false;
drawAnCanvas();
}
function onAnCanvasUp(e) {
if (AN.drag === null) return;
AN.drag = null;
try { $("#an-canvas").releasePointerCapture(e.pointerId); } catch { /**/ }
syncAnUi();
}
function closeAnOverlay() {
if (AN.busy) return;
$("#an-overlay").classList.add("hidden");
}
async function doAnalyze() {
if (AN.busy || AN.corners.length !== 4 || !AN.file) return;
stopAnvPoll();
AN.busy = true;
syncAnUi();
try {
const fd = new FormData();
fd.append("video", AN.file, AN.file.name || "video.mp4");
fd.append("corners", JSON.stringify(AN.corners));
const r = await fetch("/api/analyzer/videos",
{ method: "POST", body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok) { toast(errText(data, r.status)); return; }
AN.busy = false; // xong trước khi đóng (close chặn busy)
closeAnOverlay();
startAnvPoll(data.id);
} catch {
toast("Không gọi được API — server có đang chạy không?");
} finally {
AN.busy = false;
syncAnUi();
}
}
const fmtOrDash = (v, f) => (v === null || v === undefined ? "—" : f(v));
/** |Δφ| trên vòng tròn, độ — cùng công thức harness eval. */
function circDiffDeg(a, b) {
return Math.abs((((a - b + 180) % 360) + 360) % 360 - 180);
}
/** Dòng bất đồng model↔analytic (BG28) — THÔNG TIN, không phải lỗi:
hai cột lệch lớn chính là tín hiệu chất lượng cho người xem (thay vế
verifier chưa có). Chỉ so trục nào CẢ HAI bên đọc được. */
function anDisagreements(sn, m, spinClass) {
const out = [];
if (m.phi_deg !== null && m.phi_deg !== undefined) {
const d = circDiffDeg(sn.phi_deg, m.phi_deg);
if (d >= AN_DIFF_PHI_DEG) {
out.push(`hai cột lệch hướng φ ${d.toFixed(0)}° (≥ ${AN_DIFF_PHI_DEG}°)`);
}
}
if (m.v0_mps) {
const r = sn.v0_cue_mps / m.v0_mps;
if (r > AN_DIFF_V0_RATIO || r < 1 / AN_DIFF_V0_RATIO) {
out.push(`V0 gậy (model) gấp ${r.toFixed(2)}× tốc độ bi đo — ngoài `
+ `dải thô [1/${AN_DIFF_V0_RATIO}, ${AN_DIFF_V0_RATIO}] (lưu ý: `
+ `hai thước khác nhau)`);
}
}
for (const part of (spinClass || "").split("+")) {
if (["follow", "draw", "stun"].includes(part) && part !== sn.spin_vert) {
out.push(`spin dọc: model nói ${sn.spin_vert}, giải tích nói ${part}`);
} else if (part.startsWith("side") && part !== sn.spin_side) {
out.push(`spin ngang: model nói ${sn.spin_side}, giải tích nói ${part}`);
}
}
return out;
}
/** Dựng bảng số vào `tbl` (trang chi tiết cú A2, #and-metrics). Số hiện
NGUYÊN từ server — người xem cuối là dân CV (BRIEF #2). BG28: có khối
`shotnet` → 2 cột "Model (ShotNet)" vs "Đo giải tích (đối chứng)";
không có → 1 cột. `flags: true` (trang chi tiết §3.2) thêm hàng cờ
height_comp + identifiable. Trả true khi có cột model — caller bật
caveat bắt buộc. */
function fillAnMetrics(tbl, res, { flags = false } = {}) {
const m = res.metrics;
const sn = res.shotnet || null;
// Hàng spin (BG25): baseline giải tích thô — null hiện đúng giọng cảnh
// báo (không xác định được ≠ lỗi); có lớp thì kèm confidence + evidence
// một dòng (server soạn tiếng Việt sẵn trong spin_evidence[].text).
let spinTxt;
if (!res.spin_class) {
spinTxt = "không xác định được (cú không đủ va chạm)";
} else {
const conf = res.spin_confidence === "medium" ? "vừa" : "thấp";
const why = (res.spin_evidence || []).map((e) => e.text).join("; ");
spinTxt = `${res.spin_class} (tin cậy: ${conf})` + (why ? ` — ${why}` : "");
}
const elapsedTxt = fmtOrDash(m.elapsed_s, (v) => `${v.toFixed(1)}s`)
+ (sn ? ` (ShotNet ${sn.inference_ms} ms)` : "");
// hàng chung (1 giá trị, span 2 cột khi có model)
const common = [
["Va chạm suy ra", String(m.n_collisions)],
["Track phủ", `${(100 * m.coverage).toFixed(1)}% thời lượng`],
["Frame", `${m.n_covered}/${m.n_frames} thấy cue ball`
+ ` · ${m.n_dup_frames} frame trùng đã loại`],
["Đứt track dài nhất", `${m.max_gap_s.toFixed(2)}s`],
["Thời lượng cú", `${m.duration_s.toFixed(2)}s`],
["Server xử lý", elapsedTxt],
];
if (flags) {
// cờ height_comp + identifiable (§3.2) — chỉ trang chi tiết
const hc = res.height_comp;
common.push(["Bù độ cao tâm bi",
!hc ? "không rõ (kết quả trước BG31 — hiểu là chưa bù)"
: hc.on
? `có — camera decompose h=${hc.h_m.toFixed(2)}m`
+ (hc.f_px ? `, f=${Math.round(hc.f_px)}px` : "")
: `không (${hc.reason || "không rõ vì sao"})`]);
common.push(["Cờ identifiable (spin)",
(sn ? `model p_ident ${sn.identifiable_prob.toFixed(2)}` : "model —")
+ " · analytic "
+ (res.spin_class
? `"${res.spin_class}"` : "null (không đọc được từ va chạm)")]);
}
tbl.innerHTML = "";
const addRow = (cells, { head = false, span = false } = {}) => {
const tr = document.createElement("tr");
cells.forEach(([txt, cls], i) => {
const td = document.createElement(head ? "th" : "td");
td.textContent = txt;
if (cls) td.className = cls;
if (span && i === 1) td.colSpan = 2;
tr.appendChild(td);
});
tbl.appendChild(tr);
};
if (sn) {
// 2 cột (BRIEF 28 bước 2.4). V0 HAI THƯỚC khác nhau — nhãn ngay trong ô.
const vnConf = (c) => ({ low: "thấp", medium: "vừa", high: "cao" }[c] || c);
const confVN = vnConf(sn.confidence);
// BG29: hạng tin HIỂN THỊ đã qua van mật độ detection — nói thẳng là đã
// hạ và hạ vì cái gì (server còn soạn một dòng cảnh báo đầy đủ ở
// `warnings`; ô này để người xem không phải đọc chéo hai chỗ).
const hasDowngrade = Boolean(sn.confidence_raw)
&& sn.confidence_raw !== sn.confidence;
const dens = fmtOrDash(sn.det_density, (v) => `${v.toFixed(2)}/frame`);
const confTxt = confVN + (hasDowngrade
? ` ↓ hạ từ ${vnConf(sn.confidence_raw)} — mật độ detection ${dens}`
: "");
addRow([[""], ["Model (ShotNet)"], ["Đo giải tích (đối chứng)"]],
{ head: true });
addRow([["Tốc độ (hai thước!)"],
[`V0 gậy (model): ${sn.v0_cue_mps.toFixed(2)} m/s`],
[fmtOrDash(m.v0_mps, (v) => `tốc độ bi đo được: ${v.toFixed(2)} m/s`)]]);
addRow([["φ (hướng cú đánh)"],
[`${sn.phi_deg.toFixed(1)}°`],
[fmtOrDash(m.phi_deg, (v) => `${v.toFixed(1)}°`)]]);
addRow([["Spin"],
[`${sn.spin_vert} + ${sn.spin_side} (a=${sn.a.toFixed(2)}, `
+ `b=${sn.b.toFixed(2)}) — tin cậy ${confTxt} `
+ `(p_ident ${sn.identifiable_prob.toFixed(2)})`],
[spinTxt]]);
for (const [k, v] of common) addRow([[k], [v]], { span: true });
} else {
addRow([["V0 (tốc độ cue ball)"],
[fmtOrDash(m.v0_mps, (v) => `${v.toFixed(2)} m/s`)]]);
addRow([["φ (hướng cú đánh)"],
[fmtOrDash(m.phi_deg, (v) => `${v.toFixed(1)}°`)]]);
addRow([["Spin (baseline thô)"], [spinTxt]]);
for (const [k, v] of common) addRow([[k], [v]]);
}
return Boolean(sn);
}
/** Cảnh báo server + bất đồng model↔analytic vào `el` (trang chi tiết). */
function fillAnWarnings(el, res) {
el.innerHTML = "";
for (const w of res.warnings || []) {
const div = document.createElement("div");
div.className = "an-warn";
div.textContent = "⚠ " + w;
el.appendChild(div);
}
const sn = res.shotnet || null;
if (sn) {
for (const d of anDisagreements(sn, res.metrics, res.spin_class)) {
const div = document.createElement("div");
div.className = "an-diff";
div.textContent = "ℹ Bất đồng model↔giải tích: " + d
+ " — thông tin cho người xem, không phải lỗi.";
el.appendChild(div);
}
}
}
/** Trung bình trượt cửa sổ AN_SMOOTH_WIN — CHỈ ĐỂ VẼ, số giữ nguyên thô. */
function smoothTrack(track) {
const h = Math.floor(AN_SMOOTH_WIN / 2);
return track.map((p, i) => {
const win = track.slice(Math.max(0, i - h), i + h + 1);
return {
x: win.reduce((s, q) => s + q.x_m, 0) / win.length,
y: win.reduce((s, q) => s + q.y_m, 0) / win.length,
t: p.t_s,
};
});
}
/** Nội dung tab Analyzer trên canvas bàn: trang chi tiết cú (A2) khi đang
mở, không thì dòng mời chọn video (danh sách cú sống trong panel). */
function drawAnalyzerOverlay() {
if (ANV.detail) {
drawAnvDetail();
return;
}
ctx.fillStyle = "rgba(255,255,255,.55)";
ctx.font = "600 13px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.fillText("Chọn video (1 hoặc nhiều cú) để phân tích",
S.cssW / 2, S.cssH / 2);
}
/** Lớp QUAN SÁT (dùng chung luồng cũ + trang chi tiết): bi tĩnh frame đầu
+ quỹ đạo cue ball tô màu theo thời gian + marker va chạm. Toạ độ bàn
broadcast đưa về bàn app bằng MỘT hệ số đồng dạng (cùng tỉ lệ 2:1). */
function drawAnObserved(res) {
const k = S.table.w / res.metrics.table_w_m;
const rb = S.table.ball_r * S.scale;
// A2b phần 4: bi gắn màu + số theo BallID như overlay.mp4 — trường
// `number` do worker gắn từ nhận diện màu frame đầu (kết quả cũ không
// có trường → bi xám như trước, không bịa số). Cue: drawBall chuẩn.
// Đang "Đánh lại cú" (ANIM) thì bi cái tĩnh frame đầu KHÔNG vẽ — bi
// đang chạy của animation chính là nó, vẽ cả hai thành bi ma đôi.
for (const b of res.balls_init || []) {
if (ANIM && b.type === "cue") continue;
const bid = b.type === "cue" ? "cue"
: b.number >= 1 && b.number <= 9 ? String(b.number) : null;
if (bid) {
drawBall(b.x_m * k, b.y_m * k, bid, 1);
continue;
}
const [px, py] = toPx(b.x_m * k, b.y_m * k);
ctx.beginPath();
ctx.arc(px, py, rb, 0, 7);
ctx.fillStyle = "#8d9aa8";
ctx.fill();
ctx.strokeStyle = "#1c1c1c";
ctx.lineWidth = 1.2;
ctx.stroke();
}
const pts = smoothTrack(res.track || []);
if (pts.length >= 2) {
const t0 = pts[0].t, t1 = pts[pts.length - 1].t || 1;
for (let i = 1; i < pts.length; i++) {
const u = (pts[i].t - t0) / Math.max(t1 - t0, 1e-6);
const [ax, ay] = toPx(pts[i - 1].x * k, pts[i - 1].y * k);
const [bx, by] = toPx(pts[i].x * k, pts[i].y * k);
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx, by);
ctx.strokeStyle = `hsl(${Math.round(205 - 205 * u)}, 90%, 58%)`;
ctx.lineWidth = 2.5;
ctx.stroke();
}
}
for (const c of res.collisions || []) {
const [px, py] = toPx(c.x_m * k, c.y_m * k);
const s = 7;
ctx.strokeStyle = "#ffd700";
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(px - s, py - s); ctx.lineTo(px + s, py + s);
ctx.moveTo(px - s, py + s); ctx.lineTo(px + s, py - s);
ctx.stroke();
ctx.fillStyle = "#ffd700";
ctx.font = "600 11px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
ctx.fillText(`${c.t_s.toFixed(2)}s`, px, py - s - 2);
}
}
/** Bộ resim để VẼ/CHẠY LẠI — cùng thứ tự ưu tiên với cột RMSE của bảng
(server: shotnet trước, fallback analytic có dán nhãn). */
function anvChosenResim(res) {
const sets = (res && res.resim && res.resim.sets) || {};
for (const name of ["shotnet", "analytic"]) {
const blk = sets[name];
if (blk && blk.series
&& blk.rmse_mm !== undefined && blk.rmse_mm !== null) {
return { name, blk };
}
}
return null;
}
/** Trang chi tiết cú trên canvas bàn (§3.2, A2b): quan sát (nét liền, tô
màu theo thời gian; bi màu + số theo BallID). Lớp resim (NÉT ĐỨT trắng)
CHỈ vẽ khi toggle "So với mô phỏng" bật — mặc định tắt
(AND_RESIM_DEFAULT_ON). Lúc "Đánh lại cú" vẽ thêm bi cái chạy theo ANIM
(animateShot() nguyên trạng đặt pos — quỹ đạo QUAN SÁT). */
function drawAnvDetail() {
const res = ANV.detail.result || {};
if (!res.metrics) {
ctx.fillStyle = "rgba(255,255,255,.55)";
ctx.font = "600 13px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.fillText("Cú không có thông số — xem overlay/JSON trong panel",
S.cssW / 2, S.cssH / 2);
return;
}
drawAnObserved(res);
const k = S.table.w / res.metrics.table_w_m;
const chosen = ANV.detail.showResim ? anvChosenResim(res) : null;
if (chosen) {
for (const [bid, pts] of Object.entries(chosen.blk.series || {})) {
if (!pts || pts.length < 2) continue;
ctx.beginPath();
pts.forEach((p, i) => {
const [px, py] = toPx(p[1] * k, p[2] * k);
i ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
});
ctx.strokeStyle = bid === "cue" ? "#ffffff" : "rgba(255,255,255,.45)";
ctx.lineWidth = bid === "cue" ? 2 : 1.5;
ctx.setLineDash([7, 6]);
ctx.stroke();
ctx.setLineDash([]);
}
}
if (ANIM) {
for (const p of Object.values(ANIM.pos)) {
drawBall(p.x, p.y, "cue", 1); // chỉ cue chạy — track quan sát chỉ có nó
}
}
}
// ---------------------- Analyzer đa cú: poll + bảng danh sách cú (lát A1)
function startAnvPoll(videoId, isDemo = false) {
ANV.videoId = videoId;
ANV.isDemo = isDemo;
ANV.last = null;
$("#anv-tbody").innerHTML = "";
$("#anv-warnings").innerHTML = "";
$("#anv-progressline").textContent = "Đang quét video tìm cú...";
$("#anv-result").classList.remove("hidden");
ANV.timer = setInterval(pollAnvShots, ANV_POLL_MS);
}
function stopAnvPoll() {
if (ANV.timer) clearInterval(ANV.timer);
ANV.timer = null;
}
/** Nạp video MẪU đã phân tích sẵn lúc mở app (việc B, 14/08).
Vì sao tự nạp chứ không để một nút: bản deploy chạy CPU, phân tích một
VOD mất hàng chục phút, nên người mở link mà thấy ô upload trống là
thấy một app "chưa có gì". Kết quả mẫu nằm sẵn trên đĩa (server chép
vào thư mục kết quả lúc khởi động) nên đây chỉ là một GET đọc file —
không tốn CPU, không cần CV worker, không cần GPU.
Không đè lên việc đang làm: có video đang xem/đang phân tích thì thôi.
Bản không đóng gói mẫu trả id null → im lặng bỏ qua, app y như cũ. */
async function loadAnvDemo() {
if (ANV.videoId) return;
try {
const r = await fetch("/api/analyzer/demo");
if (!r.ok) return;
const { id } = await r.json();
if (!id || ANV.videoId) return;
startAnvPoll(id, true);
} catch { /* không có mẫu thì thôi — app không phụ thuộc nó */ }
}
async function pollAnvShots() {
if (!ANV.videoId) { stopAnvPoll(); return; }
let data = null, status = 0;
try {
const r = await fetch("/api/analyzer/shots?video="
+ encodeURIComponent(ANV.videoId));
status = r.status;
data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(String(status));
} catch {
stopAnvPoll();
toast(data && data.detail ? errText(data, status)
: "Mất liên lạc với server giữa chừng — thử phân tích lại.");
return;
}
renderAnvShots(data);
// dừng poll khi không còn gì chuyển động: video lỗi, hoặc mọi cú đã ở
// trạng thái cuối (done/error/unknown)
const terminal = new Set(["done", "error", "unknown"]);
if (data.video.status === "error"
|| (data.video.status === "done" && data.shots.length
&& data.shots.every((s) => terminal.has(s.status)))) {
stopAnvPoll();
}
}
const fmtTs = (s) => (s === null || s === undefined ? "—"
: `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`);
const VN_CONF = { low: "thấp", medium: "vừa", high: "cao" };
/** Bảng §3.1 — hiện DẦN theo tiến độ. Số hiện NGUYÊN từ server (người xem
cuối dân CV — BRIEF #2); V0/φ/spin ưu tiên cột model (ShotNet), fallback
analytic có dán nhãn "bi" (hai thước khác nhau). Cú lỗi = hàng xám
.anv-err + lý do — cú không phân tích được vẫn NẰM TRONG danh sách. */
function renderAnvShots(body) {
ANV.last = body;
const v = body.video || {};
// nút xuất CSV/JSON (A2 phần 5) — hiện khi biết video id; server dựng
// file từ CÙNG nguồn với bảng (_collect_shots) nên không thể lệch nhau
if (v.id) {
$("#anv-csv").href = `/api/analyzer/videos/${v.id}/export.csv`;
$("#anv-json").href = `/api/analyzer/videos/${v.id}/export.json`;
$(".anv-export").classList.remove("hidden");
}
const line = $("#anv-progressline");
if (v.status === "queued") {
line.textContent = "Video trong hàng đợi — chờ CV worker...";
} else if (v.status === "running") {
const pct = Math.round(100 * (v.progress || 0));
line.textContent = `Đang quét video tìm cú... ${pct}%`
+ (v.stage ? ` (${v.stage})` : "");
} else if (v.status === "error") {
line.textContent = "⚠ Quét video lỗi: " + (v.message || "không rõ");
} else {
const nRun = (body.shots || []).filter(
(s) => s.status === "running").length;
line.textContent = (ANV.isDemo ? "🎞 VIDEO MẪU (đã phân tích sẵn) — " : "")
+ `Phát hiện ${body.n_total} cú — xong `
+ `${body.n_done}/${body.n_total}`
+ (nRun ? " · đang phân tích..." : "")
+ (ANV.isDemo ? " · bấm \"Phân tích video\" để nạp video của bạn" : "");
}
const warn = $("#anv-warnings");
warn.innerHTML = "";
for (const w of v.warnings || []) {
const div = document.createElement("div");
div.className = "an-warn";
div.textContent = "⚠ " + w;
warn.appendChild(div);
}
const tbody = $("#anv-tbody");
tbody.innerHTML = "";
for (const s of body.shots || []) {
const tr = document.createElement("tr");
const bad = s.status === "error" || s.status === "unknown";
if (bad) tr.className = "anv-err";
// van A2 phần 1: cú bị nghi không phải cú đánh — hàng chuyển dạng xám
// (.anv-suspect) kèm lý do, KHÔNG xoá/lọc, vẫn click xem được như hàng
// done thường (server chỉ gắn cờ, không đổi số nào).
const suspect = s.status === "done" && s.suspect_not_shot
&& s.suspect_not_shot.flagged;
if (suspect) tr.className = "anv-suspect";
const sn = s.shotnet || null;
const cells = [];
cells.push(String(s.idx));
cells.push(null); // ô "Cú" dựng riêng (thumb + t)
cells.push(sn ? `${sn.v0_cue_mps.toFixed(2)} m/s`
: s.v0_mps !== undefined && s.v0_mps !== null
? `${s.v0_mps.toFixed(2)} m/s (bi)` : "—");
cells.push(sn ? `${sn.phi_deg.toFixed(1)}°`
: s.phi_deg !== undefined && s.phi_deg !== null
? `${s.phi_deg.toFixed(1)}° (bi)` : "—");
cells.push(sn
? `${sn.a.toFixed(2)} / ${sn.b.toFixed(2)} (${sn.spin_vert}`
+ `+${sn.spin_side})`
: s.spin_class || "—");
cells.push(null); // ô tin cậy dựng riêng (badge)
// RMSE resim (A2 phần 2): server đo — bộ shotnet mặc định; bộ khác thì
// server dán nhãn qua rmse_set, FE hiện kèm (không trộn hai bộ)
cells.push(s.rmse_mm !== undefined && s.rmse_mm !== null
? `${s.rmse_mm.toFixed(0)} mm`
+ (s.rmse_set && s.rmse_set !== "shotnet" ? ` (${s.rmse_set})` : "")
: "—");
cells.push(null); // ô trạng thái dựng riêng
cells.forEach((txt, i) => {
const td = document.createElement("td");
if (i === 1) { // thumbnail + timestamp
if (s.thumb_url) {
const img = document.createElement("img");
img.src = s.thumb_url;
img.alt = `cú ${s.idx}`;
img.className = "anv-thumb";
td.appendChild(img);
}
const t = document.createElement("div");
t.className = "anv-ts";
t.textContent = fmtTs(s.t_onset_s !== undefined ? s.t_onset_s
: s.t_start_s);
td.appendChild(t);
} else if (i === 5) { // badge tin cậy
if (sn) {
const b = document.createElement("span");
b.className = `anv-badge anv-badge-${sn.confidence}`;
b.textContent = VN_CONF[sn.confidence] || sn.confidence;
if (sn.confidence_raw && sn.confidence_raw !== sn.confidence) {
b.title = `hạ từ ${VN_CONF[sn.confidence_raw]} — mật độ `
+ `detection ${(sn.det_density || 0).toFixed(2)}/frame`;
b.textContent += " ↓";
}
td.appendChild(b);
} else {
td.textContent = s.spin_confidence
? (VN_CONF[s.spin_confidence] || s.spin_confidence) : "—";
}
} else if (i === 7) { // trạng thái + lý do
const st = document.createElement("div");
st.textContent = suspect ? "⚑ nghi không phải cú đánh"
: s.status === "done" ? "✓ xong"
: s.status === "running"
? `đang phân tích ${Math.round(100 * (s.progress || 0))}%`
: s.status === "queued" ? "chờ phân tích"
: s.status === "unknown" ? "mất dấu" : "không phân tích được";
td.appendChild(st);
if (suspect) {
// lý do van — server soạn tiếng Việt sẵn (not_shot_flag), FE nối
for (const why of s.suspect_not_shot.reasons || []) {
const div = document.createElement("div");
div.className = "anv-reason";
div.textContent = why;
td.appendChild(div);
}
}
if (s.reason) {
const why = document.createElement("div");
why.className = "anv-reason";
why.textContent = s.reason;
td.appendChild(why);
}
// overlay per cú (A2 phần 3): nút phát + nút tải — link không
// được kích click-hàng (mở JSON) nên chặn propagation
if (s.overlay_url) {
const links = document.createElement("div");
links.className = "anv-overlay-links";
const play = document.createElement("a");
play.href = s.overlay_url;
play.target = "_blank";
play.textContent = "▶ overlay";
const dl = document.createElement("a");
dl.href = s.overlay_url;
dl.download = `overlay_cu_${s.idx}.mp4`;
dl.textContent = "⬇ tải mp4";
for (const a of [play, dl]) {
a.addEventListener("click", (e) => e.stopPropagation());
links.appendChild(a);
}
td.appendChild(links);
}
} else {
td.textContent = txt;
}
tr.appendChild(td);
});
if (s.status === "done" && s.result_url) {
tr.classList.add("anv-clickable");
tr.title = "Mở trang chi tiết cú";
// A2 phần 4: click mở trang chi tiết (A1 mở JSON thô — JSON vẫn có
// link trong trang chi tiết). Cú bị van gắn cờ CŨNG mở được.
tr.addEventListener("click", () => openAnvDetail(s));
}
tbody.appendChild(tr);
}
}
// ------------------------ Trang chi tiết cú (lát A2 phần 4, §3.2 design)
async function openAnvDetail(s) {
if (S.animating || S.busy || S.auto) return;
let res = null;
try {
const r = await fetch(s.result_url);
if (!r.ok) throw new Error(String(r.status));
res = await r.json();
} catch {
toast("Không tải được kết quả cú này — server có đang chạy không?");
return;
}
ANV.detail = {
idx: s.idx,
result: res,
overlayUrl: s.overlay_url || null,
resultUrl: s.result_url,
ts: s.t_onset_s !== undefined && s.t_onset_s !== null
? s.t_onset_s : s.t_start_s,
showResim: AND_RESIM_DEFAULT_ON,
};
renderAnvDetail();
draw();
}
function closeAnvDetail() {
if (S.animating) return; // chờ "Đánh lại cú" chạy xong — sạch state
ANV.detail = null;
const v = $("#and-video");
v.pause();
v.removeAttribute("src"); // dừng tải/phát hẳn khi rời trang
v.load();
$("#and-detail").classList.add("hidden");
$("#anv-list").classList.remove("hidden");
syncQbAnalyzer(); // về danh sách: không còn cú nào để hiện
draw();
}
/** Đổ DOM trang chi tiết từ ANV.detail — canvas bàn do draw() lo riêng.
Cú bị van gắn cờ vẫn mở được (overlay + JSON; hộp cảnh báo lý do);
JSON lỗi ({"error","message"}) → thông số trống ghi lý do. */
function renderAnvDetail() {
const d = ANV.detail;
const res = d.result || {};
$("#anv-list").classList.add("hidden");
$("#and-detail").classList.remove("hidden");
$("#and-title").textContent = `Cú ${d.idx}`
+ (d.ts !== undefined && d.ts !== null ? ` — ${fmtTs(d.ts)}` : "");
$("#and-json").href = d.resultUrl;
const v = $("#and-video");
const dl = $("#and-download");
if (d.overlayUrl) {
v.src = d.overlayUrl;
v.classList.remove("hidden");
$("#and-video-missing").classList.add("hidden");
dl.href = d.overlayUrl;
dl.download = `overlay_cu_${d.idx}.mp4`;
dl.classList.remove("hidden");
} else {
v.pause();
v.removeAttribute("src");
v.classList.add("hidden");
$("#and-video-missing").classList.remove("hidden");
dl.classList.add("hidden");
}
// van A2 phần 1 — trang chi tiết nhắc lại lý do, thông số vẫn hiện
const sus = res.suspect_not_shot;
const box = $("#and-suspect");
if (sus && sus.flagged) {
box.textContent = "⚑ Nghi không phải cú đánh — "
+ (sus.reasons || []).join("; ")
+ ". Thông số dưới đây hiện để đối chiếu, đọc kèm dè dặt.";
box.classList.remove("hidden");
} else {
box.classList.add("hidden");
}
const tbl = $("#and-metrics");
const warn = $("#and-warnings");
const line = $("#and-resim");
const btn = $("#and-replay");
$("#and-shotviz").classList.add("hidden"); // hiện khi bấm "Đánh lại cú"
if (!res.metrics) {
tbl.innerHTML = "";
warn.innerHTML = "";
line.classList.add("hidden");
$("#and-resim-row").classList.add("hidden");
$("#and-caveat").classList.add("hidden");
const div = document.createElement("div");
div.className = "an-warn";
div.textContent = "⚠ Cú không có thông số: "
+ (res.message || "không rõ lý do");
warn.appendChild(div);
btn.disabled = true;
btn.title = "không có kết quả phân tích để chạy lại";
syncQbAnalyzer();
return;
}
const hasSn = fillAnMetrics(tbl, res, { flags: true });
$("#and-caveat").classList.toggle("hidden", !hasSn);
fillAnWarnings(warn, res);
// dòng RMSE: liệt kê CẢ hai bộ, mỗi bộ tự khai giả định/lý do vắng —
// "ghi rõ bộ nào, đừng trộn" (BRIEF A2 bối cảnh 3). A2b: chỉ HIỆN khi
// toggle "So với mô phỏng" bật (syncAndResim), nội dung dựng sẵn ở đây.
const sets = (res.resim || {}).sets || {};
const parts = [];
for (const name of ["shotnet", "analytic"]) {
const b = sets[name];
if (!b) continue;
parts.push(b.rmse_mm !== undefined && b.rmse_mm !== null
? `bộ ${name}: ${b.rmse_mm.toFixed(1)} mm (${b.n_points} điểm, `
+ `t_align ${b.t_align_s}s)`
+ (b.assumptions ? ` — ${b.assumptions}` : "")
: `bộ ${name}: không resim được (${b.error || "?"})`);
}
line.textContent = parts.length
? "RMSE resim ↔ quan sát — " + parts.join(" · ")
: "Chưa có resim cho cú này (kết quả thuộc lần chạy trước A2).";
syncAndResim();
// A2b: "Đánh lại cú" chạy quỹ đạo QUAN SÁT — chỉ cần track đủ điểm,
// không phụ thuộc resim/model nữa
const nTrack = (res.track || []).length;
btn.disabled = nTrack < 2;
btn.title = nTrack >= 2
? "animate quỹ đạo quan sát trên bàn — cú thật sống lại"
: "không đủ điểm track quan sát để chạy lại";
syncQbAnalyzer();
}
/** Toggle "So với mô phỏng" (A2b): đồng bộ checkbox + dòng RMSE với
ANV.detail.showResim. Cú không có khối resim → giấu cả hàng toggle. */
function syncAndResim() {
const d = ANV.detail;
const row = $("#and-resim-row");
const sets = d && d.result && d.result.resim && d.result.resim.sets;
const has = Boolean(sets && Object.keys(sets).length);
row.classList.toggle("hidden", !has);
if (!has) {
$("#and-resim").classList.add("hidden");
return;
}
$("#and-resim-toggle").checked = Boolean(d.showResim);
$("#and-resim").classList.toggle("hidden", !d.showResim);
}
/** Widget thông tin cú khi "Đánh lại cú" (A2b, §3.2): chấm điểm đầu cơ
(a/b) + thanh lực V0 — SỐ TỪ BỘ SHOTNET (analytic không có a/b), nhãn
bộ ghi thẳng trong widget. Không có khối shotnet → không hiện (không
bịa số). Trả true khi widget có số để hiện.
Viết cho MỌI thanh lực/chấm spin mang class .and-* — từ 14/08 có hai
bản: widget trang chi tiết (#and-shotviz, trong #panel) và hàng
quickbar mobile (.qb-an). Một nguồn số cho cả hai, không có đường thứ
hai để lệch nhau. */
function fillAndShotviz(sn) {
if (!sn) return false;
const frac = Math.min(1, Math.max(0,
(sn.v0_cue_mps - AND_V0_MIN) / (AND_V0_MAX - AND_V0_MIN)));
const lit = Math.round(frac * (POWER_SEGS - 1)) + 1;
for (const bar of all(".and-power-bar")) {
bar.querySelectorAll(".seg").forEach((seg, i) => {
seg.style.background = i < lit ? seg.dataset.color : "";
});
}
// cùng quy ước chấm spin tab gợi ý: a=+1 là mép TRÁI bi (pooltool,
// nghiệm thu 24/07) → cx = 50 − a·46; b=+1 là đỉnh bi → cy = 50 − b·46
for (const dot of all(".and-spin-dot")) {
dot.setAttribute("cx", 50 - sn.a * 46);
dot.setAttribute("cy", 50 - sn.b * 46);
}
$("#and-power-val").textContent = `${sn.v0_cue_mps.toFixed(2)} m/s (V0 gậy)`;
$("#and-spin-cap").textContent =
`${sn.spin_vert} + ${sn.spin_side} (a=${sn.a.toFixed(2)}, `
+ `b=${sn.b.toFixed(2)})`;
$("#and-viz-label").textContent =
`số bộ shotnet (${sn.model}) — analytic không có a/b`;
return true;
}
/** Hàng quickbar của tab Analyzer (14/08) — bản mobile của widget "Đánh
lại cú": hiện khi ĐANG mở một cú, giấu ở màn danh sách và ở tab khác.
Cùng luật với `.qb-main` của tab Gợi ý (chỉ thuộc về tab của nó), nên
nó phải được gọi ở cả 4 chỗ trạng thái đổi: mở cú, đóng cú, đổi tab,
và quanh lúc animate. Cú KHÔNG có khối shotnet vẫn đánh lại được (quỹ
đạo quan sát) — khi đó giấu chấm spin + thanh lực thay vì bịa số, chỉ
còn dòng chữ nói rõ là không có số model. */
function syncQbAnalyzer() {
const d = ANV.detail;
const on = S.anMode && Boolean(d);
$(".qb-an").classList.toggle("hidden", !on);
if (!on) return;
const res = d.result || {};
const sn = res.shotnet;
const nTrack = (res.track || []).length;
$("#qban-replay").disabled = nTrack < 2 || S.animating || S.busy || S.auto;
$("#qban-spin").classList.toggle("hidden", !sn);
$(".qb-an .and-power-bar").classList.toggle("hidden", !sn);
if (sn) fillAndShotviz(sn);
// nhãn BỘ đứng sớm trong câu: dòng này bị cắt đuôi (ellipsis) ở màn hẹp,
// mà "số của bộ nào" là thứ không được phép rơi mất (nếp A2 phần 2)
$("#qban-info").textContent = sn
? `Cú ${d.idx} · bộ shotnet: ${sn.v0_cue_mps.toFixed(2)} m/s · `
+ `${sn.spin_vert}+${sn.spin_side} (a ${sn.a.toFixed(2)}, `
+ `b ${sn.b.toFixed(2)})`
: `Cú ${d.idx} · không có số model — đánh lại theo quỹ đạo quan sát`;
}
/** Nút "Đánh lại cú" (A2b, Danh chốt 13/08 tối): animate quỹ đạo QUAN SÁT
— cú thật sống lại, luôn đúng bất kể model. Vẫn animateShot() NGUYÊN
TRẠNG của app gợi ý (không animation mới), chạy trên polyline track đã
smooth (chỉ để vẽ — trùng nét liền đang hiện); widget số bộ shotnet
hiện kèm trong lúc animate. */
async function anvReplay() {
const d = ANV.detail;
if (!d || S.animating || S.busy || S.auto) return;
const res = d.result || {};
if (!res.metrics || (res.track || []).length < 2) return;
// mobile: #panel là bottom sheet CHE BÀN — không đóng thì cú chạy sau
// lưng người xem. Trên desktop chỉ là gỡ một class không ai dùng (nếp
// openDrill: "đóng sheet để thấy ngay layout trên bàn").
closeSheet();
const k = S.table.w / res.metrics.table_w_m;
const pts = smoothTrack(res.track).map((p) => [p.x * k, p.y * k]);
if (fillAndShotviz(res.shotnet)) {
$("#and-shotviz").classList.remove("hidden");
}
// khoá TAY chứ không qua syncQbAnalyzer: `S.animating` chỉ bật BÊN TRONG
// animateShot, nên gọi sync ở đây đọc ra false và nút vẫn bấm được
$("#qban-replay").disabled = true;
await animateShot({ trajectories: { cue: pts }, outcome: { potted: [] } });
syncButtons();
syncQbAnalyzer();
draw();
}
// Móc smoke DOM trang chi tiết — cùng nếp __anvRenderMock: bơm (hàng, JSON
// per cú) qua ĐÚNG renderAnvDetail thật.
window.__andRenderMock = (item, res) => {
$("#anv-result").classList.remove("hidden");
ANV.detail = {
idx: item.idx,
result: res,
overlayUrl: item.overlay_url || null,
resultUrl: item.result_url || "#",
ts: item.t_onset_s !== undefined ? item.t_onset_s : item.t_start_s,
showResim: AND_RESIM_DEFAULT_ON,
};
renderAnvDetail();
};
// Móc smoke DOM cho bảng đa cú (nếp bàn giao 18/22 — pane browser ẩn không
// screenshot được): bơm body GET mẫu
// qua ĐÚNG renderAnvShots thật, không phải luồng render thứ hai.
window.__anvRenderMock = (body) => {
$("#anv-result").classList.remove("hidden");
renderAnvShots(body);
};
// ---------------------------------------------- đánh cú / undo / auto-play
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
const easeInOut = (t) =>
(t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2);
/** Cú đang hiển thị: shot đã chọn, hoặc fallback khi không có cú hợp lệ. */
function currentShot() {
if (S.selected >= 0) return S.shots[S.selected];
return S.fallback;
}
/** Chạy bi theo `trajectories`. Mọi bi dùng CHUNG τ ∈ [0,1] — polyline của
chúng cùng khung thời gian sim (continuize dt đều + downsample cùng chỉ
số) nên cùng τ là đồng bộ đúng: cue chạm bi rồi bi mới lăn. */
function animateShot(shot) {
return new Promise((resolve) => {
const traj = shot.trajectories || {};
const ids = Object.keys(traj).filter((b) => (traj[b] || []).length > 1);
if (!ids.length) { resolve(); return; }
const potted = new Set(shot.outcome.potted || []);
if (shot.outcome.scratch) potted.add("cue");
S.animating = true;
syncButtons();
const t0 = performance.now();
const step = (now) => {
const t = Math.min(1, (now - t0) / ANIM_MS);
const tau = easeInOut(t);
const pos = {}, alpha = {};
for (const bid of ids) {
const pts = traj[bid];
const f = tau * (pts.length - 1);
const i = Math.min(pts.length - 2, Math.floor(f));
const k = f - i;
pos[bid] = {
x: pts[i][0] + (pts[i + 1][0] - pts[i][0]) * k,
y: pts[i][1] + (pts[i + 1][1] - pts[i][1]) * k,
};
alpha[bid] = potted.has(bid) && tau >= FADE_TAU
? Math.max(0, 1 - (tau - FADE_TAU) / (1 - FADE_TAU))
: 1;
}
ANIM = { pos, alpha };
draw();
if (t < 1) { requestAnimationFrame(step); return; }
ANIM = null;
S.animating = false;
resolve();
};
requestAnimationFrame(step);
});
}
/** Áp kết quả sim lên bàn: bi vào lỗ tắt đi, bi còn lại về vị trí dừng. */
function applyFinal(shot) {
const bf = (shot.outcome || {}).balls_final || {};
for (const [bid, p] of Object.entries(bf)) {
if (!S.balls[bid]) continue;
if (p === null) {
if (bid !== "cue") S.balls[bid].on = false; // cú hợp lệ không scratch
} else {
S.balls[bid].x = p.x;
S.balls[bid].y = p.y;
}
}
}
async function playShot() {
const shot = S.selected >= 0 ? S.shots[S.selected] : null;
if (!shot || S.animating || S.busy || S.gameOver) return;
// render lười: bấm "Đánh cú này" ngay sau khi chọn cú thay thế có thể tới
// trước quỹ đạo. Chờ ở đây thay vì để animateShot() lặng lẽ bỏ qua animation
// — bàn nhảy thẳng sang kết quả trông như một bug.
await ensureTrajectories(S.selected);
if (S.animating || S.gameOver || S.shots[S.selected] !== shot) return;
S.undo = structuredClone(S.balls);
await animateShot(shot);
applyFinal(shot);
clearScanContext(); // bàn đã tiến hoá theo cú đánh — không còn là thế scan
if (shot.win) {
S.gameOver = true;
showBanner("🏆 Thắng — bi 9 vào lỗ!", "win");
}
clearShots(); // không đụng vị trí bi
renderChips();
updateTargetBadge();
syncButtons();
draw();
}
function undoShot() {
if (!S.undo) return;
clearScanContext(); // undo trả bàn về trước — thế scan (nếu có) đã bỏ
S.balls = S.undo;
S.undo = null;
S.gameOver = false;
hideBanner();
clearShots();
renderChips();
updateTargetBadge();
syncButtons();
draw();
}
async function autoPlay() {
if (S.auto) { S.auto = false; syncButtons(); return; } // bấm lần 2 = ⏹
S.auto = true;
syncButtons();
for (let i = 0; i < AUTO_MAX && S.auto; i++) {
await recommend();
if (!S.auto) break;
if (!S.shots.length) break; // hết cú hợp lệ — banner fallback giữ nguyên
await playShot();
if (S.gameOver) break;
await sleep(AUTO_DELAY);
}
S.auto = false;
syncButtons();
}
// ------------------------------------------------------------------ panel
function setBusy(b) {
S.busy = b;
syncButtons();
}
/** Một chỗ duy nhất quyết định nút nào bấm được — tránh 5 chỗ set disabled. */
function syncButtons() {
const idle = S.jitReady && !S.busy && !S.animating;
for (const btn of all(".btn-go")) {
btn.disabled = !idle || S.gameOver || S.auto;
btn.textContent = S.busy ? "Đang tính..." :
btn.id === "qb-go" ? "Gợi ý" :
btn.id === "drill-suggest" ? "Xem gợi ý" : "Gợi ý cú đánh";
}
const onFallback = S.selected < 0 && !!S.fallback;
const canPlay = idle && !S.gameOver && S.selected >= 0 && S.shots.length > 0;
for (const btn of all(".btn-play")) {
btn.disabled = !canPlay;
btn.title = onFallback
? "Cú foul/miss — tự đặt bi theo kết quả bàn thật" : "";
}
$("#btn-undo").disabled = !S.undo || S.animating || S.auto;
$("#btn-preset").disabled = S.animating || S.auto;
$("#btn-scan").disabled = !S.table || S.busy || S.animating || S.auto;
const auto = $("#btn-auto");
auto.disabled = !S.jitReady || S.animating || (S.gameOver && !S.auto);
auto.textContent = S.auto ? "⏹ Dừng" : "▶ Tự chơi hết ván";
for (const chip of all(".chip")) chip.disabled = S.animating || S.auto;
// "Xem gợi ý" của drill đi cùng điều kiện .btn-go (cần JIT + không busy)
$("#drill-suggest").disabled = !idle || !S.drill;
}
function renderChips() {
for (const chip of all(".chip")) {
const b = S.balls[chip.dataset.bid];
const on = !!(b && b.on);
chip.classList.toggle("off", !on);
chip.style.background = on ? COLORS[chip.dataset.bid].fill : "";
chip.style.color = on
? (COLORS[chip.dataset.bid].dark ? "#fff" : "#111") : "";
}
}
function updateTargetBadge() {
const t = targetId();
const txt = t ? `→ bi ${t}` : "— hết bi";
$("#panel-target").textContent = txt;
$("#qb-target").textContent = txt;
}
function toggleChip(bid) {
if (S.animating || S.auto) return;
const b = S.balls[bid];
if (!b) return;
if (b.on && onIds().length <= 1) { toast("Cần ≥1 bi trên bàn"); return; }
b.on = !b.on;
if (S.scanId) S.scanEdited = true; // bật/tắt bi sau scan cũng là sửa tay
S.gameOver = false;
hideBanner();
// keepSheet: đang dựng bàn giữa ván trên mobile thì cửa sổ chi tiết PHẢI
// đứng yên cho tới khi user tự đóng — toggle từng bi mà panel sập ngay là
// mỗi lần chỉnh được đúng một bi (BRIEF 31/07 việc B.6).
clearShots({ keepSheet: true });
renderChips();
updateTargetBadge();
syncButtons();
draw();
}
function showBanner(text, kind) {
const b = $("#status");
b.textContent = text;
b.classList.remove("hidden", "error", "warn", "win");
if (kind) b.classList.add(kind);
}
function hideBanner() { $("#status").classList.add("hidden"); }
const fmtSigned = (v) => (v >= 0 ? "+" : "") + v.toFixed(1);
/** Khoá xếp hạng V2, hiện trong dòng `.alt` (31/07/2026, BRIEF việc B.3).
Không hiện thì danh sách trông như XẾP SAI: thứ tự là lexicographic theo
(đường lăn ngắn nhất, |d_đáp − 1 m|), không phải theo EV/Q — hai số đó
không còn được in ở đâu trên UI nữa.
`d_land` null = cú cuối ván (không có bi kế): chỉ có đường lăn để xếp,
nói rõ ra thay vì in "d 0.00 m" (null là "không đo được", không phải 0). */
function rankTag(s) {
if (s.roll_len === null || s.roll_len === undefined) return "";
const roll = ` · lăn ${s.roll_len.toFixed(2)} m`;
return s.d_land === null || s.d_land === undefined
? roll + " · cú cuối ván"
: roll + ` · d ${s.d_land.toFixed(2)} m`;
}
// ------------------------------------------------- viz: thanh lực + bi cái
const V0_MIN = 0.5, V0_MAX = 4.0, POWER_SEGS = 10; // khớp grid v0_steps=10
function initPowerBar() {
// dựng nấc cho MỌI thanh lực: panel + quickbar (.power-bar — updateShotViz
// quét) và widget trang chi tiết (.and-power-bar — A2b, fillAndShotviz tự
// quản, cố ý KHÔNG mang class .power-bar để không bị quét chéo)
for (const bar of all(".power-bar, .and-power-bar")) {
for (let i = 0; i < POWER_SEGS; i++) {
const seg = document.createElement("div");
seg.className = "seg";
// gradient xanh → vàng → đỏ theo nấc (như power bar Unity cũ)
seg.dataset.color = `hsl(${Math.round(120 - (i / (POWER_SEGS - 1)) * 120)}, 78%, 46%)`;
bar.appendChild(seg);
}
}
}
function updateShotViz(shot) {
const viz = $("#shotviz");
const qbInfo = $("#qb-info");
// lực: sáng số nấc tương ứng vị trí V0 trên grid 0.5–4.0
const frac = shot
? Math.min(1, Math.max(0, (shot.v0 - V0_MIN) / (V0_MAX - V0_MIN))) : 0;
const lit = shot ? Math.round(frac * (POWER_SEGS - 1)) + 1 : 0;
for (const bar of all(".power-bar")) {
bar.querySelectorAll(".seg").forEach((seg, i) => {
seg.style.background = i < lit ? seg.dataset.color : "";
});
}
// spin: red dot trên bi cái — side/vert tính theo bán kính bi (±0.4);
// +vert = đánh tới (dot lên trên, b=+1 là đỉnh bi — khớp pooltool).
// +side vẽ sang TRÁI: pooltool quy ước a=+1 là mép trái bi (nghiệm thu
// 24/07/2026, docstring Cue v0.6.0) → cx = 50 − side·46.
for (const dot of all(".spin-dot")) {
dot.setAttribute("cx", 50 - (shot ? shot.side : 0) * 46);
dot.setAttribute("cy", 50 - (shot ? shot.vert : 0) * 46);
}
if (!shot) {
viz.classList.add("hidden");
qbInfo.textContent = S.jitReady
? "Đặt bi rồi bấm Gợi ý" : "Đang khởi động vật lý (~40s)...";
return;
}
viz.classList.remove("hidden");
const force = shot.v0 < 1.5 ? "nhẹ" : shot.v0 < 2.8 ? "vừa" : "mạnh";
$("#power-val").textContent = `${shot.v0.toFixed(2)} m/s (${force})`;
const cap = [];
if (shot.vert >= 0.15) cap.push("đánh tới (follow)");
else if (shot.vert <= -0.15) cap.push("kéo về (draw)");
if (Math.abs(shot.side) >= 0.15) cap.push(`xoáy ngang (a=${fmtSigned(shot.side)})`);
$("#spin-cap").textContent = cap.length ? cap.join(" + ") : "đánh tâm bi";
// quickbar: tóm tắt 1 dòng — khoá xếp hạng V2 (đường lăn + d_đáp), KHÔNG
// còn EV/Q (BRIEF việc B.3 — trả nợ #qb-info ghi EV từ 30/07)
qbInfo.textContent =
`lỗ ${shot.pocket.name} · V0 ${shot.v0.toFixed(2)} (${force})${rankTag(shot)}`;
}
function renderPanel() {
$("#result").classList.remove("hidden");
// KHÔNG in tên engine nữa (31/07): chỉ còn một đường chọn cú, nhãn engine
// là thông tin rỗng. Thời gian đo bởi server giữ nguyên.
$("#searchinfo").textContent = S.search
? `${S.search.n_pot}/${S.search.n_sim} cú đạt đủ tiêu chí trong không gian tìm kiếm · ${S.search.elapsed_s.toFixed(1)}s`
: "";
updateTargetBadge();
const alts = $("#alts");
alts.innerHTML = "";
if (!S.shots.length) {
// không có cú hợp lệ: hiện cú "ít tệ nhất" để tham khảo, KHÔNG cho đánh
if (S.fallback) {
showBanner("⚠ Không tìm được cú hợp lệ (đường bị chắn) — cân nhắc "
+ "kick/safety (ngoài phạm vi MVP). Cú ít rủi ro nhất:", "warn");
$("#describe").textContent = S.fallback.describe;
updateShotViz(S.fallback);
} else {
$("#describe").textContent = S.message || "Không tìm được cú pot.";
updateShotViz(null);
}
syncButtons();
return;
}
const shot = S.shots[S.selected];
$("#describe").textContent = shot.describe;
updateShotViz(shot);
S.shots.forEach((s, i) => {
const div = document.createElement("div");
div.className = "alt" + (i === S.selected ? " active" : "")
+ (s._trajPending ? " pending" : "");
const extra = (s.outcome.potted || []).filter((b) => b !== s.target);
div.textContent = `${s.rank}. lỗ ${s.pocket.name} · V0 ${s.v0.toFixed(2)}` +
` · side ${fmtSigned(s.side)} · vert ${fmtSigned(s.vert)}` +
rankTag(s) +
(s.win ? " · ⚑ WIN" : "") +
(extra.length ? ` · +bi ${extra.join(",")}` : "") +
(s._trajPending ? " · đang vẽ..." : "");
div.onclick = () => {
if (S.animating || S.auto) return;
S.selected = i;
renderPanel();
draw();
ensureTrajectories(i); // render lười — vẽ xong sẽ tự draw() lại
};
alts.appendChild(div);
});
syncButtons();
}
function clearShots(opts = {}) {
S.shots = [];
S.selected = -1;
S.search = null;
S.message = null;
S.fallback = null;
S.reqBalls = null;
$("#result").classList.add("hidden");
updateShotViz(null); // reset cả quickbar
// keepSheet (B.6): toggle bi trong cửa sổ chi tiết mobile không được đóng
// panel — user còn đang chọn nhiều bi, đóng bằng nút ✕ khi xong.
if (!S.auto && !opts.keepSheet) closeSheet();
}
// ------------------------------------------- tab Bài tập (F3, 04/08/2026)
// Lớp THÊM trên app cũ (BRIEF 3.5): vào tab chụp bàn hiện tại, thoát tab trả
// lại nguyên trạng — mọi phần tử của luồng gợi ý cũ không bị đổi hành vi.
// "Xem gợi ý" gọi ĐÚNG recommend() sẵn có trên layout drill (engine không
// biết zone — cú máy có thể không đạt goal drill, đó là điều bình thường).
let DRILL_LIST = null; // cache danh sách /api/drills (file tĩnh phía server)
/** 3 tab từ bàn giao 24: "suggest" | "drills" | "analyzer". Chỉ tab Gợi ý
có controls/legend; hai tab kia mỗi tab một section riêng. */
function setTab(mode) {
$("#tab-suggest").classList.toggle("active", mode === "suggest");
$("#tab-drills").classList.toggle("active", mode === "drills");
$("#tab-analyzer").classList.toggle("active", mode === "analyzer");
$(".controls").classList.toggle("hidden", mode !== "suggest");
$(".legend").classList.toggle("hidden", mode !== "suggest");
$("#drills").classList.toggle("hidden", mode !== "drills");
$("#analyzer").classList.toggle("hidden", mode !== "analyzer");
// Quickbar mobile ở NGOÀI #panel nên 4 dòng trên không với tới (bug Danh
// 14/08: tab Analyzer trên điện thoại vẫn còn UI gợi ý ở đáy màn hình —
// desktop không lộ vì #quickbar là display:none). Ẩn tay đúng hai khối
// thuộc tab Gợi ý; hàng .qb-settings (Bài tập / Chi tiết ▴) PHẢI ở lại —
// sheet đóng thì đó là lối duy nhất mở #panel để đổi tab trên mobile.
//
// Chỉ ẩn ở tab Analyzer, KHÔNG ẩn ở tab Bài tập: drill dùng chính luồng
// gợi ý (bấm "Xem gợi ý" rồi "Đánh") nên hai nút quickbar còn là lối tắt
// thật; ẩn luôn ở đó là gỡ chức năng đang chạy chứ không phải sửa bug.
const shotUiOff = mode === "analyzer";
$("#qb-target").classList.toggle("hidden", shotUiOff);
$(".qb-main").classList.toggle("hidden", shotUiOff); // .qb-main duy nhất
// ...và ngược lại: hàng Analyzer chỉ thuộc về tab Analyzer (14/08)
syncQbAnalyzer();
}
async function enterDrills() {
if (S.drillMode || S.animating || S.auto || S.busy) return;
exitAnalyzer(); // đang ở tab Analyzer thì trả tab đó về trước
clearScanContext(); // layout drill thay bàn — ngữ cảnh scan hết
S.drillMode = true;
// chụp bàn MỘT lần lúc vào — thoát tab là trả lại y nguyên
S.savedBoard = { balls: structuredClone(S.balls), undo: S.undo,
gameOver: S.gameOver };
S.gameOver = false;
hideBanner();
clearShots({ keepSheet: true });
setTab("drills");
showDrillList();
try {
if (!DRILL_LIST) {
const r = await fetch("/api/drills");
if (!r.ok) throw new Error(String(r.status));
DRILL_LIST = (await r.json()).drills;
}
renderDrillList(DRILL_LIST);
} catch {
toast("Không tải được danh sách bài tập — server có đang chạy không?");
}
// sau khi có DRILL_LIST (để hiện tên thay vì id) — fire-and-forget,
// list hỏng thì khu Tiến bộ vẫn tự lo phần của nó
refreshProgress();
}
function exitDrills() {
if (!S.drillMode) return;
S.drillMode = false;
S.drill = null;
if (S.savedBoard) {
S.balls = S.savedBoard.balls;
S.undo = S.savedBoard.undo;
S.gameOver = S.savedBoard.gameOver;
S.savedBoard = null;
}
setTab("suggest");
clearShots({ keepSheet: true });
renderChips();
updateTargetBadge();
syncButtons();
draw();
}
function showDrillList() {
$("#drill-detail").classList.add("hidden");
$("#drill-list").classList.remove("hidden");
}
/** Khu "Tiến bộ" (bàn giao 12): có phiên + DB → tổng hợp pass/fail theo
drill của ĐÚNG phiên này (GET /api/drill-attempts?session_id=).
Không phiên / API lỗi (503 Space không DB) → ẩn khu + một dòng nhắc —
không toast, không chặn gì: tiến bộ là lớp thêm, không phải điều kiện. */
async function refreshProgress() {
const box = $("#drill-progress"), hint = $("#drill-progress-hint");
const sid = sessionId();
if (!sid) {
box.classList.add("hidden");
hint.classList.remove("hidden");
return;
}
try {
const r = await fetch("/api/drill-attempts?session_id="
+ encodeURIComponent(sid));
if (!r.ok) throw new Error(String(r.status));
renderProgress((await r.json()).summary || {});
} catch {
box.classList.add("hidden");
hint.classList.remove("hidden");
}
}
function renderProgress(summary) {
const body = $("#drill-progress-body");
body.innerHTML = "";
const ids = Object.keys(summary);
if (!ids.length) {
const div = document.createElement("div");
div.className = "progress-row";
div.textContent = "Chưa có lượt tập nào trong phiên này.";
body.appendChild(div);
}
for (const id of ids) {
const c = summary[id];
// tên drill lấy từ danh sách đã cache; list chưa nạp được thì hiện id —
// vẫn đúng dữ liệu, chỉ kém đẹp
const item = (DRILL_LIST || []).find((d) => d.id === id);
const div = document.createElement("div");
div.className = "progress-row";
div.textContent = `${item ? item.title : id}: `
+ `${c.pass || 0} đạt · ${c.fail || 0} trượt`;
body.appendChild(div);
}
$("#drill-progress-hint").classList.add("hidden");
$("#drill-progress").classList.remove("hidden");
}
function renderDrillList(list) {
const box = $("#drill-list");
box.innerHTML = "";
for (const d of list) {
const div = document.createElement("div");
div.className = "alt drill-item";
const b = document.createElement("b");
b.textContent = d.title;
div.appendChild(b);
for (const t of d.tags) {
const tag = document.createElement("span");
tag.className = "tag";
tag.textContent = t;
div.appendChild(tag);
}
const reps = document.createElement("span");
reps.className = "drill-reps";
reps.textContent = `${d.reps} lượt`;
div.appendChild(reps);
div.onclick = () => selectDrill(d.id);
box.appendChild(div);
}
}
async function selectDrill(id) {
if (!S.table) { toast("Bàn chưa tải xong — thử lại sau giây lát"); return; }
let d;
try {
const r = await fetch("/api/drills/" + encodeURIComponent(id));
const data = await r.json().catch(() => ({}));
if (!r.ok) { toast(errText(data, r.status)); return; }
d = data;
} catch {
toast("Không tải được bài tập — server có đang chạy không?");
return;
}
S.drill = d;
S.drillDone = { pass: 0, fail: 0 };
$("#drill-list").classList.add("hidden");
$("#drill-detail").classList.remove("hidden");
$("#drill-title").textContent = d.title;
$("#drill-goal").textContent = drillGoalText(d);
$("#drill-note").classList.add("hidden");
applyDrillLayout();
renderDrillRound();
closeSheet(); // mobile: đóng sheet để thấy ngay layout trên bàn
}
/** Đặt bàn theo layout drill. Bi ngoài layout TẮT nhưng giữ vị trí cũ —
savedBoard mới là thứ khôi phục khi thoát tab, đây chỉ để chip khỏi mất
tham chiếu. */
function applyDrillLayout() {
if (!S.drill) return;
const lay = S.drill.layout;
for (const bid of BALL_IDS) {
const cur = S.balls[bid] || { x: S.table.w / 2, y: S.table.l / 2 };
S.balls[bid] = lay[bid]
? { x: lay[bid].x, y: lay[bid].y, on: true }
: { ...cur, on: false };
}
S.balls.cue = { x: lay.cue.x, y: lay.cue.y };
S.undo = null;
S.gameOver = false;
hideBanner();
clearShots({ keepSheet: true });
renderChips();
updateTargetBadge();
syncButtons();
draw();
}
/** Mô tả goal tiếng Việt TỰ SINH từ schema — không viết tay từng drill. */
function drillGoalText(d) {
const pot = d.goal.pot;
const pk = pot.pocket === "any"
? "lỗ bất kỳ"
: `lỗ ${((S.table.pockets[pot.pocket] || {}).name) || "số " + pot.pocket}`;
const z = d.goal.cue_zone;
return `Pot bi ${pot.ball} vào ${pk} và để bi cái dừng trong vùng đích `
+ `(vòng tròn xanh, bán kính ${Math.round(z.r * 100)} cm). `
+ `Tập ${d.reps} lượt, tự chấm Đạt/Trượt sau mỗi cú trên bàn thật.`;
}
function renderDrillRound() {
const d = S.drill;
if (!d) return;
const n = S.drillDone.pass + S.drillDone.fail;
$("#drill-count").textContent =
`${n}/${d.reps} lượt · ${S.drillDone.pass} đạt`;
const done = n >= d.reps;
$("#drill-pass").disabled = done || S.drillPosting;
$("#drill-fail").disabled = done || S.drillPosting;
$("#drill-retry").classList.toggle("hidden", !done);
const sum = $("#drill-summary");
sum.classList.toggle("hidden", !done);
if (done) sum.textContent = `Chốt vòng: ${S.drillDone.pass}/${d.reps} đạt`;
}
async function reportAttempt(result) {
const d = S.drill;
if (!d || S.drillPosting) return;
if (S.drillDone.pass + S.drillDone.fail >= d.reps) return;
S.drillPosting = true;
renderDrillRound();
try {
const body = { drill_id: d.id, result };
// phiên bàn (bàn giao 12): có phiên thì lượt tập mang session_id để row
// drill_attempts gắn được guest_session_id + tenant_id
const sid = sessionId();
if (sid) body.session_id = sid;
const r = await fetch("/api/drill-attempts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) { toast(errText(data, r.status)); return; }
// chỉ tăng đếm khi server ĐÃ trả lời — lượt bấm hụt mạng không được tính
S.drillDone[result] += 1;
if (data.persisted === false) $("#drill-note").classList.remove("hidden");
else if (sid) refreshProgress(); // phiên này vừa có thêm một row — cập nhật khu Tiến bộ
} catch {
toast("Không gửi được kết quả — server có đang chạy không?");
} finally {
S.drillPosting = false;
renderDrillRound();
}
}
let toastTimer = null;
function toast(msg) {
const t = $("#toast");
t.textContent = msg;
t.classList.remove("hidden");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.add("hidden"), 4500);
}
// ------------------------------------------------------------------- init
function closeSheet() { document.body.classList.remove("sheet-open"); }
for (const b of all(".btn-go")) b.addEventListener("click", recommend);
for (const b of all(".btn-play")) b.addEventListener("click", playShot);
for (const chip of all(".chip")) {
chip.addEventListener("click", () => toggleChip(chip.dataset.bid));
}
$("#btn-preset").addEventListener("click", applyPreset);
$("#btn-undo").addEventListener("click", undoShot);
// Ghép TV (bàn giao 15): nút mở/đóng form, Enter trong ô mã cũng là Ghép
$("#btn-tv").addEventListener("click", () => {
const f = $("#tv-pair");
f.classList.toggle("hidden");
if (!f.classList.contains("hidden")) $("#tv-code").focus();
});
$("#tv-pair-go").addEventListener("click", pairTv);
$("#tv-code").addEventListener("keydown", (e) => {
if (e.key === "Enter") pairTv();
});
// Scan ảnh (bàn giao 18): nút mở file picker; overlay tự quản lý vòng đời
$("#btn-scan").addEventListener("click", openScan);
$("#scan-file").addEventListener("change", onScanFile);
$("#scan-canvas").addEventListener("pointerdown", onScanCanvasDown);
$("#scan-redo").addEventListener("click", () => {
if (SCAN.busy) return;
SCAN.corners = [];
SCAN.reused = false;
drawScanCanvas();
syncScanUi();
});
$("#scan-close").addEventListener("click", closeScanOverlay);
$("#scan-go").addEventListener("click", doScan);
$("#btn-auto").addEventListener("click", autoPlay);
$("#qb-detail").addEventListener("click", () =>
document.body.classList.add("sheet-open"));
// tab Bài tập (F3): 2 tab trong panel + lối vào quickbar mobile.
// Tab Gợi ý thoát CẢ HAI tab kia — mỗi exit tự no-op nếu không ở tab đó.
$("#tab-suggest").addEventListener("click", () => {
exitDrills();
exitAnalyzer();
});
$("#tab-drills").addEventListener("click", enterDrills);
// tab Analyzer (bàn giao 24)
$("#tab-analyzer").addEventListener("click", enterAnalyzer);
// Analyzer đa cú (lát A1; từ A2b là đường vào DUY NHẤT của tab)
$("#anv-pick").addEventListener("click", anvPick);
$("#an-file").addEventListener("change", onAnFile);
$("#an-canvas").addEventListener("pointerdown", onAnCanvasDown);
$("#an-canvas").addEventListener("pointermove", onAnCanvasMove);
$("#an-canvas").addEventListener("pointerup", onAnCanvasUp);
$("#an-canvas").addEventListener("pointercancel", onAnCanvasUp);
$("#an-redo").addEventListener("click", () => {
if (AN.busy) return;
AN.corners = [];
AN.suggested = false;
AN.suggestReason = null;
AN.drag = null;
drawAnCanvas();
syncAnUi();
});
// Xoay nhãn ①②③④ đi MỘT nhịp — sửa đúng một lỗi: máy gán nhầm cặp băng
// ngắn ↔ băng dài (đo 14/08: 0/11 ca sai, nhưng heuristic chỉ nghiệm trên
// MỘT camera). Xoay giữ nguyên vị trí 4 điểm nên rẻ hơn chấm lại nhiều.
$("#an-rotate").addEventListener("click", () => {
if (AN.busy || AN.corners.length !== 4) return;
AN.corners = AN.corners.slice(1).concat(AN.corners.slice(0, 1));
drawAnCanvas();
syncAnUi();
});
$("#an-close").addEventListener("click", closeAnOverlay);
$("#an-go").addEventListener("click", doAnalyze);
// trang chi tiết cú (lát A2 phần 4)
$("#and-back").addEventListener("click", closeAnvDetail);
$("#and-replay").addEventListener("click", anvReplay);
// cùng handler với nút trong sheet — một đường chạy lại cú, hai lối bấm
$("#qban-replay").addEventListener("click", anvReplay);
// toggle "So với mô phỏng" (A2b phần 4) — bật mới vẽ nét đứt resim + RMSE
$("#and-resim-toggle").addEventListener("change", (e) => {
if (!ANV.detail) return;
ANV.detail.showResim = e.target.checked;
syncAndResim();
draw();
});
$("#qb-drills").addEventListener("click", () => {
document.body.classList.add("sheet-open");
enterDrills();
});
$("#drill-back").addEventListener("click", () => {
S.drill = null;
showDrillList();
clearShots({ keepSheet: true });
draw();
});
$("#drill-reset").addEventListener("click", applyDrillLayout);
$("#drill-pass").addEventListener("click", () => reportAttempt("pass"));
$("#drill-fail").addEventListener("click", () => reportAttempt("fail"));
$("#drill-retry").addEventListener("click", () => {
if (!S.drill) return;
S.drillDone = { pass: 0, fail: 0 };
renderDrillRound();
});
// #drill-suggest KHÔNG bind riêng: nó mang class .btn-go nên vòng lặp trên
// đã gắn recommend() — bind thêm là double request mỗi lần bấm.
$("#sheet-close").addEventListener("click", closeSheet);
$("#backdrop").addEventListener("click", closeSheet);
window.addEventListener("resize", () => { sizeCanvas(); draw(); });
initPowerBar();
// A2b (feedback #2): Analyzer là tab mặc định (S.anMode=true + HTML tĩnh
// khớp nhau từ boot). Deep-link CŨ ?qr=<token> là lối vào bàn của luồng
// gợi ý (QR dán ở bàn) — link đó phải đáp xuống ĐÚNG tab Gợi ý như trước,
// không bắt người chơi tự bấm tab. exitAnalyzer() trước loadTable: mọi
// hàm nó gọi tự no-op an toàn khi S.table còn null (draw/renderChips
// đều guard), và không đụng vùng state bẫy BG25/26 (animate chưa thể chạy).
if (new URLSearchParams(location.search).get("qr")) exitAnalyzer();
// bàn mẫu curate nạp TRƯỚC loadTable: applyPreset đầu tiên (trong loadTable)
// phải thấy DEMO_BOARDS; fetch hỏng thì finally vẫn mở app bằng PRESET.
loadDemoBoards().finally(loadTable);
initSession(); // /?qr=<token> → phiên bàn; hỏng thì toast rồi chạy thường
pollHealth();
loadAnvDemo(); // video mẫu đã phân tích sẵn → bảng cú hiện NGAY khi mở app