Spaces:
Sleeping
Sleeping
| /* 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. */ | |
| ; | |
| 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=<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 | |