| import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| 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); |
| 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([]); |
| const [filtering, setFiltering] = useState(null); |
| const [status, setStatus] = useState(""); |
|
|
| |
| 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(); |
| |
| }, [sequences, clipNames, nSeg]); |
|
|
| |
| 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]); |
|
|
| |
| 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; |
| }; |
|
|
| |
| 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]; |
| }; |
|
|
| |
| 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)); |
| |
| 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); |
| |
| 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; |
| } |
| |
| 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]); |
| }; |
|
|
| |
| 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; |
|
|