/* 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]} và ${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= (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