tnt-group / frontend /src /components /ComboTable.jsx
titusgiap's picture
feat(shuffle): bảng tổ hợp mirror video_cook — ảo hoá 300k + lọc bằng nút (JS)
824497d
Raw
History Blame Contribute Delete
12.8 kB
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
// Bảng tổ hợp ẢO HOÁ (chỉ vẽ dòng đang thấy) + bộ lọc bằng NÚT nối chuỗi —
// mirror tool video_cook (ui/combo_model.py + ui/tab2_output.py).
// Cột: STT | "tên + tên + tên" | [🎵 Source] | ✓
// Lọc: Đặt lại / Chọn hết / Bỏ hết / Lọc ngẫu nhiên % / Lọc tương đồng %
// + "Ưu tiên khúc khác hoàn toàn". Tất cả chạy trên tập ĐANG CHỌN.
// Trạng thái tick giữ trong Uint8Array (chịu được vài trăm nghìn dòng, thao tác
// hàng loạt chỉ vài ms). Lọc tương đồng đẩy sang Web Worker để UI không đơ.
const ROW_H = 30;
const VIEW_H = 384;
const RANK_BLOCK = 8192;
const JOIN = " + ";
const ComboTable = forwardRef(function ComboTable(
{ sequences, clipNames, nSeg, sources = [], maxRender = 10000, onCount }, ref) {
const n = sequences.length;
const checkedRef = useRef(new Uint8Array(0));
const rankRef = useRef(null); // prefix-sum số dòng chọn / block
const workerRef = useRef(null);
const [checkedCount, setCheckedCount] = useState(0);
const [, force] = useState(0);
const bump = () => force((x) => x + 1);
const [scrollTop, setScrollTop] = useState(0);
const [randPct, setRandPct] = useState(50);
const [simPct, setSimPct] = useState(50);
const [prio, setPrio] = useState([]); // bool theo từng khúc
const [filtering, setFiltering] = useState(null); // {msg,pct} | null
const [status, setStatus] = useState("");
// Nạp bộ dữ liệu mới -> tick hết, dọn mọi thứ.
useEffect(() => {
const buf = new Uint8Array(n);
buf.fill(1);
checkedRef.current = buf;
rankRef.current = null;
setCheckedCount(n);
setScrollTop(0);
setPrio(Array.from({ length: nSeg }, () => false));
setStatus("");
bump();
// eslint-disable-next-line
}, [sequences, clipNames, nSeg]);
// Worker lọc tương đồng (tạo 1 lần).
useEffect(() => {
const w = new Worker(new URL("../workers/simFilter.js", import.meta.url), { type: "module" });
workerRef.current = w;
return () => { try { w.terminate(); } catch { /* */ } };
}, []);
useEffect(() => { onCount && onCount(checkedCount); }, [checkedCount, onCount]);
useImperativeHandle(ref, () => ({
getSelected() {
const c = checkedRef.current;
const out = [];
for (let i = 0; i < c.length; i++) if (c[i]) out.push(sequences[i]);
return out;
},
selectedCount: () => checkedCount,
}), [sequences, checkedCount]);
// ── thao tác tick ──────────────────────────────────────────────────────
const invalidate = () => { rankRef.current = null; };
const recount = () => {
let s = 0; const c = checkedRef.current;
for (let i = 0; i < c.length; i++) s += c[i];
setCheckedCount(s);
};
const toggle = (i) => {
const c = checkedRef.current;
c[i] ^= 1;
setCheckedCount((v) => v + (c[i] ? 1 : -1));
invalidate(); bump();
};
const setAll = (v) => {
checkedRef.current.fill(v ? 1 : 0);
setCheckedCount(v ? n : 0);
invalidate(); bump();
setStatus(v ? `↺ Đã chọn tất cả ${n} tổ hợp` : "Đã bỏ chọn hết");
};
const setRows = (rows) => {
const buf = new Uint8Array(n);
for (const r of rows) if (r >= 0 && r < n) buf[r] = 1;
checkedRef.current = buf;
invalidate(); bump(); recount();
};
const checkedRows = () => {
const c = checkedRef.current; const out = [];
for (let i = 0; i < c.length; i++) if (c[i]) out.push(i);
return out;
};
// ── cột 🎵 Source: xoay vòng theo THỨ HẠNG trong các dòng ĐANG CHỌN ───────
const buildRank = () => {
const c = checkedRef.current;
const blocks = Math.ceil(n / RANK_BLOCK) + 1;
const idx = new Int32Array(blocks);
let total = 0;
for (let b = 0; b < blocks - 1; b++) {
idx[b] = total;
const end = Math.min((b + 1) * RANK_BLOCK, n);
for (let i = b * RANK_BLOCK; i < end; i++) total += c[i];
}
idx[blocks - 1] = total;
rankRef.current = idx;
return idx;
};
const sourceFor = (row) => {
if (!sources.length) return null;
const idx = rankRef.current || buildRank();
const block = (row / RANK_BLOCK) | 0;
let rank = idx[block];
const c = checkedRef.current;
for (let i = block * RANK_BLOCK; i < row; i++) rank += c[i];
return sources[rank % sources.length];
};
// ── bộ lọc ───────────────────────────────────────────────────────────────
const reset = () => setAll(true);
const randomFilter = () => {
const pool = checkedRows();
if (!pool.length) { setStatus("Chưa chọn tổ hợp nào để lọc."); return; }
const keepCount = Math.max(1, Math.round(pool.length * randPct / 100));
// Fisher–Yates một phần -> lấy keepCount phần tử ngẫu nhiên, không lệch.
for (let i = 0; i < keepCount; i++) {
const j = i + Math.floor(Math.random() * (pool.length - i));
const t = pool[i]; pool[i] = pool[j]; pool[j] = t;
}
setRows(pool.slice(0, keepCount));
setStatus(`🎲 Giữ ${keepCount}/${pool.length} ngẫu nhiên (${randPct}% của tập đang chọn)`);
};
const similarityFilter = () => {
const pool = checkedRows();
if (!pool.length) { setStatus("Chưa chọn tổ hợp nào để lọc."); return; }
const maxSame = Math.floor(simPct / 100 * nSeg + 1e-9);
const priorityCols = prio.map((p, i) => (p ? i : -1)).filter((i) => i >= 0);
// dựng mảng phẳng các hàng đang chọn -> chuyển thẳng sang worker (transferable)
const flat = new Int32Array(pool.length * nSeg);
for (let k = 0; k < pool.length; k++) {
const seq = sequences[pool[k]]; const base = k * nSeg;
for (let j = 0; j < nSeg; j++) flat[base + j] = seq[j];
}
setFiltering({ msg: `🔍 Đang lọc ${pool.length} tổ hợp…`, pct: 0 });
const w = workerRef.current;
w.onmessage = (e) => {
const d = e.data;
if (d.type === "progress") {
setFiltering({ msg: `🔍 Đang lọc tương đồng… ${Math.round(d.done / d.total * 100)}%`, pct: Math.round(d.done / d.total * 100) });
return;
}
// done
const keep = d.keep;
const keptRows = [];
for (let k = 0; k < pool.length; k++) if (keep[k]) keptRows.push(pool[k]);
setRows(keptRows);
setFiltering(null);
setStatus(`🔍 Xong! Còn ${keptRows.length}/${pool.length} (loại ${pool.length - keptRows.length} tổ hợp tương đồng)`);
};
w.postMessage({ flat, nSeg, maxSame, priorityCols }, [flat.buffer]);
};
// ── ảo hoá ────────────────────────────────────────────────────────────────
const start = Math.max(0, ((scrollTop / ROW_H) | 0) - 6);
const visN = Math.ceil(VIEW_H / ROW_H) + 12;
const end = Math.min(n, start + visN);
const rows = [];
const c = checkedRef.current;
for (let i = start; i < end; i++) {
const on = !!c[i];
const names = sequences[i].map((id) => clipNames[id] || String(id)).join(JOIN);
const src = sourceFor(i);
rows.push(
<div key={i} onClick={() => toggle(i)}
style={{ display: "flex", alignItems: "center", height: ROW_H, cursor: "pointer",
background: i % 2 ? "var(--white)" : "var(--cream)", opacity: on ? 1 : 0.4,
borderBottom: "1px solid var(--line)", fontSize: 12 }}>
<div style={{ width: 56, textAlign: "center", color: "var(--orange-700)", flexShrink: 0 }}>{i + 1}</div>
<div style={{ flex: 1, minWidth: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", paddingRight: 8 }}
title={sequences[i].map((id) => clipNames[id] || String(id)).join("\n")}>{names}</div>
{src != null && (
<div style={{ width: 150, flexShrink: 0, color: "#0a8f3c", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", paddingRight: 8 }} title={src}>🎵 {src}</div>
)}
<div style={{ width: 46, textAlign: "center", flexShrink: 0 }}>
<input type="checkbox" checked={on} readOnly tabIndex={-1} style={{ pointerEvents: "none" }} />
</div>
</div>
);
}
const over = checkedCount > maxRender;
return (
<div>
{/* thanh công cụ */}
<div className="flex center gap8" style={{ flexWrap: "wrap", marginBottom: 8 }}>
<button className="btn sm" onClick={reset} title="Chọn lại TẤT CẢ — huỷ mọi bộ lọc đã áp">↺ Đặt lại</button>
<button className="btn sm" onClick={() => setAll(true)}>☑ Tất cả</button>
<button className="btn sm ghost" onClick={() => setAll(false)}>☐ Bỏ hết</button>
<span style={{ width: 1, height: 22, background: "var(--line)" }} />
<span className="small muted">Ngẫu nhiên</span>
<input type="number" min="1" max="99" value={randPct} onChange={(e) => setRandPct(Math.max(1, Math.min(99, +e.target.value || 1)))} style={{ width: 60 }} />
<button className="btn sm" onClick={randomFilter} disabled={!!filtering} title="Giữ lại X% video ngẫu nhiên (trong tập đang chọn)">Lọc ngẫu nhiên</button>
<span style={{ width: 1, height: 22, background: "var(--line)" }} />
<span className="small muted">Tương đồng</span>
<input type="number" min="0" max="100" value={simPct} onChange={(e) => setSimPct(Math.max(0, Math.min(100, +e.target.value || 0)))} style={{ width: 60 }} />
<button className="btn sm" onClick={similarityFilter} disabled={!!filtering}
title="Chỉ giữ combo có mức tương đồng ≤ ngưỡng. 0% = khác nhau HOÀN TOÀN; 100% = giữ tất cả.">
{filtering ? "⏳ Đang lọc…" : "Lọc tương đồng"}
</button>
<span className="small" style={{ marginLeft: "auto", color: "var(--orange-700)", fontWeight: 600 }}>
Đã chọn: {checkedCount.toLocaleString()} / {n.toLocaleString()}
</span>
</div>
{/* ưu tiên khúc khác hoàn toàn */}
{nSeg > 0 && (
<div className="flex center gap8" style={{ flexWrap: "wrap", marginBottom: 8 }}>
<span className="small muted" title="Các khúc được tick sẽ bị ép KHÁC NHAU HOÀN TOÀN giữa các video giữ lại (kết hợp với ngưỡng % tương đồng).">Ưu tiên khác hoàn toàn ở khúc:</span>
{prio.map((p, i) => (
<label key={i} className="small" style={{ display: "inline-flex", alignItems: "center", gap: 4, cursor: "pointer" }}>
<input type="checkbox" checked={p} onChange={() => setPrio((cur) => cur.map((v, j) => (j === i ? !v : v)))} />
Khúc {i + 1}
</label>
))}
</div>
)}
{/* tiêu đề cột */}
<div style={{ display: "flex", alignItems: "center", height: 26, fontSize: 11, fontWeight: 700, color: "var(--muted)", borderBottom: "2px solid var(--line)" }}>
<div style={{ width: 56, textAlign: "center", flexShrink: 0 }}>STT</div>
<div style={{ flex: 1 }}>Tổ hợp video</div>
{sources.length > 0 && <div style={{ width: 150, flexShrink: 0 }}>🎵 Source</div>}
<div style={{ width: 46, textAlign: "center", flexShrink: 0 }}></div>
</div>
{/* danh sách ảo hoá */}
<div onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
style={{ height: VIEW_H, overflow: "auto", border: "1px solid var(--line)", borderTop: "none" }}>
<div style={{ height: n * ROW_H, position: "relative" }}>
<div style={{ position: "absolute", top: start * ROW_H, left: 0, right: 0 }}>{rows}</div>
</div>
</div>
{filtering && (
<div className="small" style={{ color: "#b45309", marginTop: 6 }}>{filtering.msg}</div>
)}
{!filtering && status && (
<div className="small muted" style={{ marginTop: 6 }}>{status}</div>
)}
{over && (
<p className="small mt8" style={{ color: "var(--orange-700)" }}>
⚠ Đang chọn {checkedCount.toLocaleString()} biến thể — chỉ render tối đa {maxRender.toLocaleString()} video/lần, phần dư sẽ bị bỏ.
</p>
)}
</div>
);
});
export default ComboTable;