1967 lines
89 KiB
JavaScript
1967 lines
89 KiB
JavaScript
const $ = (s, r = document) => r.querySelector(s);
|
||
const view = $('#app');
|
||
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
const num = n => Number(n || 0).toLocaleString('id-ID');
|
||
const pct = n => Number(n || 0).toLocaleString('id-ID', { maximumFractionDigits: 1 }) + '%';
|
||
const tgl = iso => {
|
||
if (!iso) return '-';
|
||
try {
|
||
return new Date(iso).toLocaleString('id-ID', { dateStyle: 'medium', timeStyle: 'short' });
|
||
} catch (e) {
|
||
return String(iso);
|
||
}
|
||
};
|
||
|
||
function pager(current, total) {
|
||
current = Number(current) || 1;
|
||
total = Number(total) || 1;
|
||
if (total <= 1) return '';
|
||
|
||
let html = '';
|
||
|
||
if (current > 1) {
|
||
html += `<button type="button" class="chip" data-p="${current - 1}">← Prev</button>`;
|
||
}
|
||
|
||
const delta = 2;
|
||
const start = Math.max(1, current - delta);
|
||
const end = Math.min(total, current + delta);
|
||
|
||
if (start > 1) {
|
||
html += `<button type="button" class="chip" data-p="1">1</button>`;
|
||
if (start > 2) html += `<span style="padding:4px 6px;color:var(--muted);align-self:center">...</span>`;
|
||
}
|
||
|
||
for (let p = start; p <= end; p++) {
|
||
html += `<button type="button" class="chip ${p === current ? 'on' : ''}" data-p="${p}">${p}</button>`;
|
||
}
|
||
|
||
if (end < total) {
|
||
if (end < total - 1) html += `<span style="padding:4px 6px;color:var(--muted);align-self:center">...</span>`;
|
||
html += `<button type="button" class="chip" data-p="${total}">${total}</button>`;
|
||
}
|
||
|
||
if (current < total) {
|
||
html += `<button type="button" class="chip" data-p="${current + 1}">Next →</button>`;
|
||
}
|
||
|
||
html += `<span style="font-size:12px;color:var(--muted);align-self:center;margin-left:8px">Hal. ${current} / ${total}</span>`;
|
||
return html;
|
||
}
|
||
|
||
let user = null;
|
||
let poll = null;
|
||
|
||
// ==========================================
|
||
// API HELPER
|
||
// ==========================================
|
||
async function api(path, { method = 'GET', body } = {}) {
|
||
const isPredict = path === '/predict';
|
||
const url = isPredict ? '/predict' : '/api' + path;
|
||
|
||
const res = await fetch(url, {
|
||
method,
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
});
|
||
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
if (res.status === 401 && !path.startsWith('/log') && !path.startsWith('/reg')) {
|
||
user = null;
|
||
location.hash = '#/login';
|
||
}
|
||
throw new Error(data.message || 'Terjadi kesalahan sistem.');
|
||
}
|
||
return data;
|
||
}
|
||
|
||
// Periksa status database saat startup
|
||
async function updateDbStatus() {
|
||
try {
|
||
const res = await api('/db-status');
|
||
const badge = $('#dbStatusText');
|
||
const pill = $('#dbStatusBadge');
|
||
if (badge && pill) {
|
||
badge.textContent = res.info || 'Database Aktif';
|
||
pill.title = `Engine: ${res.engine} | Host: ${res.host} | DB: ${res.database}`;
|
||
}
|
||
} catch (e) {
|
||
// Abaikan jika offline
|
||
}
|
||
}
|
||
|
||
// ==========================================
|
||
// ROUTER
|
||
// ==========================================
|
||
async function route() {
|
||
clearInterval(poll);
|
||
if (user === null) {
|
||
try {
|
||
user = (await api('/me')).user;
|
||
} catch {
|
||
user = false;
|
||
}
|
||
}
|
||
|
||
const topbar = $('#topbar');
|
||
if (topbar) topbar.hidden = !user;
|
||
|
||
const h = location.hash || '#/';
|
||
const authPage = h === '#/login' || h === '#/register';
|
||
|
||
if (!user && !authPage) {
|
||
location.hash = '#/login';
|
||
return;
|
||
}
|
||
if (user && authPage) {
|
||
location.hash = '#/';
|
||
return;
|
||
}
|
||
|
||
if (user) {
|
||
const who = $('#whoami');
|
||
if (who) who.textContent = user.name || user.email;
|
||
const av = $('#userAvatar');
|
||
if (av) {
|
||
const initial = (user.name || user.email || 'A').charAt(0).toUpperCase();
|
||
av.textContent = initial;
|
||
}
|
||
updateDbStatus();
|
||
} else {
|
||
const who = $('#whoami');
|
||
if (who) who.textContent = '';
|
||
const av = $('#userAvatar');
|
||
if (av) av.textContent = '-';
|
||
}
|
||
|
||
// Update visibilitas menu admin di navbar
|
||
const navEval = $('#navEvaluation');
|
||
if (navEval) {
|
||
navEval.style.display = (user && (user.is_admin || user.email === 'admin@tiktok.com')) ? 'inline-flex' : 'none';
|
||
}
|
||
|
||
// Update active state di navbar
|
||
document.querySelectorAll('[data-nav]').forEach(a => {
|
||
const navKey = a.dataset.nav;
|
||
let isActive = false;
|
||
if (navKey === 'dashboard' && h === '#/') isActive = true;
|
||
else if (navKey === 'playground' && h === '#/playground') isActive = true;
|
||
else if (navKey === 'history' && (h === '#/history' || h.startsWith('#/analysis/'))) isActive = true;
|
||
else if (navKey === 'evaluation' && (h === '#/evaluation' || h === '#/model-test')) isActive = true;
|
||
a.classList.toggle('on', isActive);
|
||
});
|
||
|
||
const m = h.match(/^#\/analysis\/(\d+)$/);
|
||
if (h === '#/login') return authView('login');
|
||
if (h === '#/register') return authView('register');
|
||
if (h === '#/playground') return playgroundView();
|
||
if (h === '#/history') return historyView();
|
||
if (h === '#/evaluation' || h === '#/model-test') return evaluationView();
|
||
if (m) return detailView(+m[1]);
|
||
return dashboardView();
|
||
}
|
||
|
||
window.addEventListener('hashchange', route);
|
||
|
||
$('#btnLogout').onclick = async () => {
|
||
try {
|
||
await api('/logout', { method: 'POST' });
|
||
} catch (e) {}
|
||
user = false;
|
||
const topbar = $('#topbar');
|
||
if (topbar) topbar.hidden = true;
|
||
const who = $('#whoami');
|
||
if (who) who.textContent = '';
|
||
const av = $('#userAvatar');
|
||
if (av) av.textContent = '-';
|
||
if (location.hash === '#/login') {
|
||
route();
|
||
} else {
|
||
location.hash = '#/login';
|
||
}
|
||
};
|
||
|
||
// ==========================================
|
||
// 1. AUTH VIEW (LOGIN & REGISTER)
|
||
// ==========================================
|
||
function authView(mode) {
|
||
const reg = mode === 'register';
|
||
view.innerHTML = `
|
||
<div class="auth-wrapper">
|
||
<div class="auth-card">
|
||
<div style="text-align:center;margin-bottom:24px">
|
||
<div class="brand-icon" style="margin:0 auto 12px;width:48px;height:48px;border-radius:14px">
|
||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none">
|
||
<path d="M16.5 3C14.5 5.5 12 6.5 9 6.5V15.5C9 18 6.8 20 4.5 20C2.2 20 0 18 0 15.5C0 13 2 11 4.5 11C5.2 11 6 11.2 6.5 11.5V6.8C5.8 6.6 5.2 6.5 4.5 6.5C1.8 6.5 -0.5 9 -0.5 12C-0.5 15 1.8 17.5 4.5 17.5C7.2 17.5 9.5 15 9.5 12V3H16.5Z" fill="#25f4ee" transform="translate(4, 2)"/>
|
||
<path d="M18.5 5C16.5 7.5 14 8.5 11 8.5V17.5C11 20 8.8 22 6.5 22C4.2 22 2 20 2 17.5C2 15 4 13 6.5 13C7.2 13 8 13.2 8.5 13.5V8.8C7.8 8.6 7.2 8.5 6.5 8.5C3.8 8.5 1.5 11 1.5 14C1.5 17 3.8 19.5 6.5 19.5C9.2 19.5 11.5 17 11.5 14V5H18.5Z" fill="#fe2c55" transform="translate(3, 1)"/>
|
||
</svg>
|
||
</div>
|
||
<h1>${reg ? 'Buat Akun Baru' : 'Selamat Datang'}</h1>
|
||
<p class="sub" style="margin-bottom:0">${reg ? 'Daftar untuk menyimpan riwayat analisis sentimen Anda.' : 'Masuk untuk mulai menganalisis komentar TikTok.'}</p>
|
||
</div>
|
||
|
||
<form id="fAuth" novalidate>
|
||
${reg ? `
|
||
<label for="name">Nama Lengkap</label>
|
||
<input id="name" placeholder="Nama Anda" autocomplete="name" required>
|
||
` : ''}
|
||
<label for="email">Email</label>
|
||
<input id="email" type="email" placeholder="admin@tiktok.com" autocomplete="email" required>
|
||
|
||
<label for="pw">Password</label>
|
||
<input id="pw" type="password" placeholder="••••••••" autocomplete="${reg ? 'new-password' : 'current-password'}" required>
|
||
|
||
${reg ? `
|
||
<label for="pw2">Konfirmasi Password</label>
|
||
<input id="pw2" type="password" placeholder="••••••••" autocomplete="new-password" required>
|
||
` : ''}
|
||
|
||
<div class="msg" id="authMsg" role="alert"></div>
|
||
<button class="btn" id="btnAuthSubmit" style="margin-top:10px">
|
||
${reg ? 'Daftar Sekarang' : 'Masuk ke Sistem'}
|
||
</button>
|
||
</form>
|
||
|
||
${!reg ? `
|
||
<div class="auth-demo-box">
|
||
<span>Akun Default: <b>admin@tiktok.com</b> (pw: admin123)</span>
|
||
<button class="btn-fill-demo" id="btnFillDemo">Isi Otomatis</button>
|
||
</div>
|
||
` : ''}
|
||
|
||
<p class="sub" style="margin:22px 0 0;text-align:center;font-size:13.5px">
|
||
${reg ? 'Sudah memiliki akun? <a href="#/login">Masuk di sini</a>' : 'Belum memiliki akun? <a href="#/register">Daftar sekarang</a>'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
if (!reg) {
|
||
$('#btnFillDemo').onclick = () => {
|
||
$('#email').value = 'admin@tiktok.com';
|
||
$('#pw').value = 'admin123';
|
||
$('#authMsg').textContent = '';
|
||
};
|
||
}
|
||
|
||
$('#fAuth').onsubmit = async e => {
|
||
e.preventDefault();
|
||
const btn = $('#btnAuthSubmit');
|
||
const msg = $('#authMsg');
|
||
msg.textContent = '';
|
||
btn.disabled = true;
|
||
|
||
const body = {
|
||
email: $('#email').value.trim(),
|
||
password: $('#pw').value
|
||
};
|
||
if (reg) {
|
||
body.name = $('#name').value.trim();
|
||
body.password_confirm = $('#pw2').value;
|
||
}
|
||
|
||
try {
|
||
const res = await api(reg ? '/register' : '/login', { method: 'POST', body });
|
||
user = res.user;
|
||
location.hash = '#/';
|
||
route();
|
||
} catch (err) {
|
||
msg.textContent = err.message;
|
||
btn.disabled = false;
|
||
}
|
||
};
|
||
}
|
||
|
||
// ==========================================
|
||
// 2. DASHBOARD VIEW
|
||
// ==========================================
|
||
async function dashboardView() {
|
||
view.innerHTML = `
|
||
<div style="display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:20px;flex-wrap:wrap;gap:12px">
|
||
<div>
|
||
<h1>Dashboard Analisis</h1>
|
||
<p class="sub" style="margin-bottom:0">Ringkasan statistik data dan klasifikasi komentar TikTok berbasis Machine Learning.</p>
|
||
</div>
|
||
<a href="#/playground" class="chip on" style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;text-decoration:none">
|
||
<span>🔬 Uji Cepat Kata / Kalimat</span>
|
||
</a>
|
||
</div>
|
||
|
||
<!-- STATS CARDS -->
|
||
<div class="stats" id="statsGrid">
|
||
<div class="stat"><div class="stat-header"><span class="stat-label">Total Analisis</span><div class="stat-icon">📊</div></div><b>0</b></div>
|
||
<div class="stat"><div class="stat-header"><span class="stat-label">Total Komentar</span><div class="stat-icon">💬</div></div><b>0</b></div>
|
||
<div class="stat pos"><div class="stat-header"><span class="stat-label">Sentimen Positif</span><div class="stat-icon">👍</div></div><b>0</b></div>
|
||
<div class="stat neg"><div class="stat-header"><span class="stat-label">Sentimen Negatif</span><div class="stat-icon">👎</div></div><b>0</b></div>
|
||
</div>
|
||
|
||
<div class="grid2">
|
||
<!-- FORM ANALISIS CARD -->
|
||
<div class="card">
|
||
<h2>
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||
Mulai Analisis Sentimen Baru
|
||
</h2>
|
||
|
||
<!-- TABS MODE: VIDEO TIKTOK vs INPUT MANDIRI -->
|
||
<div class="tabs-mode" id="analysisTabs">
|
||
<button type="button" class="tab-btn active" data-tab="tab-url">
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||
Link Video TikTok
|
||
</button>
|
||
<button type="button" class="tab-btn" data-tab="tab-manual">
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||
Input Kata Langsung (Mandiri)
|
||
</button>
|
||
</div>
|
||
|
||
<form id="fAnalysis" novalidate>
|
||
<!-- TAB 1: URL TIKTOK -->
|
||
<div id="panelUrl">
|
||
<div class="db-pill" style="margin-bottom:14px;background:rgba(37,244,238,0.08);border-color:rgba(37,244,238,0.25);color:#25f4ee">
|
||
<span class="db-dot" style="background:#25f4ee;box-shadow:0 0 8px #25f4ee"></span>
|
||
<span>Web Scraping Otomatis: Komentar Asli Ditarik Langsung dari TikTok</span>
|
||
</div>
|
||
|
||
<label for="url">
|
||
<span>Link Video TikTok</span>
|
||
<span class="hint-tag">Mendukung link penuh & shortlink vt.tiktok.com</span>
|
||
</label>
|
||
<input id="url" type="text" placeholder="https://www.tiktok.com/@creator/video/7123456789012345678">
|
||
|
||
<div style="margin-top:14px;padding:12px 14px;background:rgba(255,255,255,0.02);border:1px solid var(--border);border-radius:12px">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:8px">
|
||
<label for="targetComments" style="margin:0;font-size:13px;color:var(--ink);display:flex;align-items:center;gap:6px">
|
||
<span>🎯 Target Jumlah Komentar:</span>
|
||
<span class="hint-tag" style="font-weight:400">(Bisa ditentukan bebas)</span>
|
||
</label>
|
||
<div style="display:flex;align-items:center;gap:6px">
|
||
<input type="number" id="targetComments" min="1" max="1000" step="5" value="100"
|
||
style="width:90px;padding:6px 10px;font-size:13px;font-weight:700;text-align:center;border-radius:8px;background:rgba(0,0,0,0.4);border:1px solid rgba(37,244,238,0.45);color:#25f4ee">
|
||
<span style="font-size:12px;color:var(--ink-soft)">komentar</span>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
|
||
<span style="font-size:11px;color:var(--muted)">Opsi Cepat:</span>
|
||
<button type="button" class="preset-chip target-preset" data-val="25">25</button>
|
||
<button type="button" class="preset-chip target-preset" data-val="50">50</button>
|
||
<button type="button" class="preset-chip target-preset active" data-val="100">100</button>
|
||
<button type="button" class="preset-chip target-preset" data-val="150">150</button>
|
||
<button type="button" class="preset-chip target-preset" data-val="200">200</button>
|
||
<button type="button" class="preset-chip target-preset" data-val="300">300</button>
|
||
<button type="button" class="preset-chip target-preset" data-val="500">500</button>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- TAB 2: INPUT KATA LANGSUNG / MANDIRI (TANPA LINK URL) -->
|
||
<div id="panelManual" style="display:none">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||
<label for="manualInput" style="margin:0">
|
||
<span>Ketik / Tempel Komentar Langsung</span>
|
||
<span class="hint-tag" id="lineCounter">0 komentar terdeteksi</span>
|
||
</label>
|
||
<input type="file" id="fileUpload" accept=".csv,.txt" style="display:none">
|
||
</div>
|
||
|
||
<textarea id="manualInput" style="min-height:140px" placeholder="Ketik atau tempel kalimat komentar di sini..."></textarea>
|
||
|
||
<div style="font-size:12px;color:var(--muted);margin:8px 0 6px">Preset Uji Coba Cepat:</div>
|
||
<div class="presets-wrap">
|
||
<button type="button" class="preset-chip pos" id="btnPresetPos">+ Contoh Positif</button>
|
||
<button type="button" class="preset-chip neg" id="btnPresetNeg">+ Contoh Negatif</button>
|
||
<button type="button" class="preset-chip" id="btnPresetAlay">+ Contoh Gaul / Slang</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="msg" id="analysisMsg" role="alert"></div>
|
||
|
||
<button class="btn" id="btnStartAnalysis" style="margin-top:12px">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||
<span>Mulai Analisis & Scraping Video</span>
|
||
</button>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- CARD ANALISIS TERBARU -->
|
||
<div class="card">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
|
||
<h2 style="margin-bottom:0">Analisis Terbaru</h2>
|
||
<a href="#/history" class="chip" style="font-size:12px;padding:4px 10px">Lihat Semua</a>
|
||
</div>
|
||
<div id="recentList" class="recent-list">
|
||
<div class="spin" style="margin:20px auto"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Logika Switch Tab Input
|
||
let activeTab = 'tab-url';
|
||
const tabs = document.querySelectorAll('#analysisTabs .tab-btn');
|
||
const panelUrl = $('#panelUrl');
|
||
const panelManual = $('#panelManual');
|
||
|
||
tabs.forEach(tab => {
|
||
tab.onclick = () => {
|
||
tabs.forEach(t => t.classList.remove('active'));
|
||
tab.classList.add('active');
|
||
activeTab = tab.dataset.tab;
|
||
|
||
if (activeTab === 'tab-url') {
|
||
panelUrl.style.display = 'block';
|
||
panelManual.style.display = 'none';
|
||
$('#btnStartAnalysis span').textContent = 'Mulai Analisis Video';
|
||
} else {
|
||
panelUrl.style.display = 'none';
|
||
panelManual.style.display = 'block';
|
||
$('#btnStartAnalysis span').textContent = 'Analisis Komentar Mandiri';
|
||
}
|
||
$('#analysisMsg').textContent = '';
|
||
};
|
||
});
|
||
|
||
|
||
|
||
// Target Komentar Preset & Input Bebas
|
||
document.querySelectorAll('.target-preset').forEach(btn => {
|
||
btn.onclick = () => {
|
||
document.querySelectorAll('.target-preset').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
const input = $('#targetComments');
|
||
if (input) input.value = btn.dataset.val;
|
||
};
|
||
});
|
||
$('#targetComments')?.addEventListener('input', e => {
|
||
const v = e.target.value;
|
||
document.querySelectorAll('.target-preset').forEach(b => {
|
||
b.classList.toggle('active', b.dataset.val === v);
|
||
});
|
||
});
|
||
|
||
// Live counter pada manual textarea
|
||
const manualInput = $('#manualInput');
|
||
const lineCounter = $('#lineCounter');
|
||
const updateLineCounter = () => {
|
||
const text = manualInput.value.trim();
|
||
if (!text) {
|
||
lineCounter.textContent = '0 komentar terdeteksi';
|
||
return;
|
||
}
|
||
const lines = text.split('\n').filter(l => l.trim().length > 0);
|
||
lineCounter.textContent = `${lines.length} komentar terdeteksi`;
|
||
};
|
||
manualInput.oninput = updateLineCounter;
|
||
|
||
// Preset Buttons untuk Input Mandiri
|
||
$('#btnPresetPos').onclick = () => {
|
||
manualInput.value = 'cantik banget kak videonya, sangat bermanfaat dan mengedukasi!\nmantap betul ilmunya, makasih banyak ya sukses selalu kak';
|
||
updateLineCounter();
|
||
};
|
||
$('#btnPresetNeg').onclick = () => {
|
||
manualInput.value = 'jelek banget penjelasannya ga jelas muter-muter bikin pusing\nkonten ga bermutu buang-buang kuota doang nontonnya';
|
||
updateLineCounter();
|
||
};
|
||
$('#btnPresetAlay').onclick = () => {
|
||
manualInput.value = 'bgt bgt parahh beneran wkwk, tp tetep oke kok makasih kk\ngak bener ini infonya hoax bgt parah sih';
|
||
updateLineCounter();
|
||
};
|
||
// Load Data Statistik & Riwayat Terbaru
|
||
try {
|
||
const [stats, analyses] = await Promise.all([api('/stats'), api('/analyses')]);
|
||
|
||
// Render Stats
|
||
$('#statsGrid').innerHTML = `
|
||
<div class="stat"><div class="stat-header"><span class="stat-label">Total Analisis</span><div class="stat-icon">📊</div></div><b>${num(stats.total_analyses)}</b></div>
|
||
<div class="stat"><div class="stat-header"><span class="stat-label">Total Komentar</span><div class="stat-icon">💬</div></div><b>${num(stats.total_comments)}</b></div>
|
||
<div class="stat pos"><div class="stat-header"><span class="stat-label">Sentimen Positif</span><div class="stat-icon">👍</div></div><b>${num(stats.positive)}</b></div>
|
||
<div class="stat neg"><div class="stat-header"><span class="stat-label">Sentimen Negatif</span><div class="stat-icon">👎</div></div><b>${num(stats.negative)}</b></div>
|
||
`;
|
||
|
||
// Render Recent
|
||
const recents = analyses.data || [];
|
||
$('#recentList').innerHTML = recents.length ? recents.slice(0, 5).map(a => `
|
||
<a class="recent-item" href="#/analysis/${a.id}">
|
||
<div class="recent-meta">
|
||
<div class="recent-title">${esc(shortUrl(a))}</div>
|
||
<div class="recent-date">${tgl(a.created_at)}</div>
|
||
</div>
|
||
<div class="recent-stat">
|
||
<span style="color:#34d399">${pct(a.positive_percentage)}</span> /
|
||
<span style="color:#fb7185">${pct(a.negative_percentage)}</span>
|
||
<div style="font-size:11px;font-weight:700;margin-top:2px;color:${a.negative_count > a.positive_count ? '#fb7185' : (a.positive_count > a.negative_count ? '#34d399' : '#fbbf24')}">
|
||
${a.negative_count > a.positive_count ? '🚨 Cyberbullying' : (a.positive_count > a.negative_count ? '✅ Aman' : '⚖️ Seimbang')}
|
||
</div>
|
||
<div style="font-size:10.5px;color:var(--muted)">${num(a.total_comments)} komen</div>
|
||
</div>
|
||
</a>
|
||
`).join('') : '<div class="empty">Belum ada riwayat analisis. Silakan masukkan data di samping untuk memulai.</div>';
|
||
|
||
} catch (e) {
|
||
// 401 ditangani otomatis oleh api()
|
||
}
|
||
|
||
// Handle Submit Form
|
||
$('#fAnalysis').onsubmit = async e => {
|
||
e.preventDefault();
|
||
const btn = $('#btnStartAnalysis');
|
||
const msg = $('#analysisMsg');
|
||
msg.textContent = '';
|
||
btn.disabled = true;
|
||
|
||
let tiktok_url = '';
|
||
let manual_comments = '';
|
||
const customTargetVal = parseInt($('#targetComments')?.value, 10);
|
||
const max_comments = (!isNaN(customTargetVal) && customTargetVal > 0) ? customTargetVal : 100;
|
||
|
||
if (activeTab === 'tab-url') {
|
||
tiktok_url = ($('#url')?.value || '').trim();
|
||
manual_comments = '';
|
||
|
||
if (!tiktok_url) {
|
||
msg.textContent = 'Harap isi link video TikTok terlebih dahulu.';
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
btn.innerHTML = `
|
||
<div class="spin" style="width:16px;height:16px;border-width:2px;margin:0"></div>
|
||
<span>Sedang men-scrape ${max_comments} komentar asli TikTok & menganalisis sentimen...</span>
|
||
`;
|
||
} else {
|
||
// Tab Input Mandiri
|
||
manual_comments = $('#manualInput').value.trim();
|
||
if (!manual_comments) {
|
||
msg.textContent = 'Ketik minimal satu komentar pada kotak teks di atas.';
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
btn.innerHTML = `
|
||
<div class="spin" style="width:16px;height:16px;border-width:2px;margin:0"></div>
|
||
<span>Sedang memproses ${lineCounter.textContent}...</span>
|
||
`;
|
||
}
|
||
|
||
try {
|
||
const res = await api('/analyses', {
|
||
method: 'POST',
|
||
body: { tiktok_url, manual_comments, max_comments }
|
||
});
|
||
location.hash = '#/analysis/' + res.data.id;
|
||
} catch (err) {
|
||
msg.textContent = err.message;
|
||
btn.disabled = false;
|
||
btn.innerHTML = `
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||
<span>${activeTab === 'tab-url' ? 'Mulai Analisis & Scraping Video' : 'Analisis Komentar Mandiri'}</span>
|
||
`;
|
||
}
|
||
};
|
||
}
|
||
|
||
const shortUrl = a => {
|
||
if (a.tiktok_url && a.tiktok_url.startsWith('Uji') || a.tiktok_url.startsWith('Input')) {
|
||
return a.tiktok_url;
|
||
}
|
||
return a.video_id ? 'Video TikTok #' + a.video_id : a.tiktok_url.replace(/^https?:\/\/(www\.)?/, '');
|
||
};
|
||
|
||
const statusText = s => s === 'failed' ? 'Gagal' : 'Memproses…';
|
||
|
||
// ==========================================
|
||
// 3. LIVE NLP PLAYGROUND (UJI CEPAT KATA/KALIMAT)
|
||
// ==========================================
|
||
function playgroundView() {
|
||
view.innerHTML = `
|
||
<div style="margin-bottom:20px">
|
||
<h1>Uji Cepat Sentimen (NLP Playground)</h1>
|
||
<p class="sub">Uji kata atau kalimat langsung untuk melihat proses pembersihan teks (preprocessing), prediksi sentimen, dan bobot fitur TF-IDF secara instan.</p>
|
||
</div>
|
||
|
||
<div class="playground-box">
|
||
<!-- INPUT CARD -->
|
||
<div class="card">
|
||
<h2>
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||
Masukkan Teks Komentar
|
||
</h2>
|
||
|
||
<form id="fPlayground" novalidate>
|
||
<label for="playText">Teks Komentar / Kata</label>
|
||
<textarea id="playText" style="min-height:120px" placeholder="Contoh: sok cantik banget jadi cewek, gayanya norak dan alay..."></textarea>
|
||
|
||
<div style="font-size:12px;color:var(--muted);margin:10px 0 6px">Uji dengan Contoh Cepat:</div>
|
||
<div class="presets-wrap">
|
||
<button type="button" class="preset-chip pos" data-test="cantik banget kak videonya, sangat bermanfaat dan mengedukasi!">+ Positif Lembut</button>
|
||
<button type="button" class="preset-chip pos" data-test="mantap betul ilmunya keren abis rekomen bgt">+ Positif Gaul</button>
|
||
<button type="button" class="preset-chip neg" data-test="sok cantik banget gayanya norak dan alay parah">+ Negatif Sarkas</button>
|
||
<button type="button" class="preset-chip neg" data-test="jelek banget penjelasannya ga jelas buang buang kuota">+ Negatif Keras</button>
|
||
<button type="button" class="preset-chip" style="border-color:rgba(168,85,247,0.45);color:#c084fc" data-test="astronomi galaksi supernova">+ OOV (Fitur Tidak Ditemukan)</button>
|
||
<button type="button" class="preset-chip" style="border-color:rgba(245,158,11,0.45);color:#fbbf24" data-test="😂😂😂 👍👍👍 12345 !@#$%">+ Preprocessing Kosong (Emotikon & Simbol)</button>
|
||
</div>
|
||
|
||
<div class="msg" id="playMsg"></div>
|
||
|
||
<button class="btn" id="btnRunPredict">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||
<span>Analisis Kata / Kalimat Sekarang</span>
|
||
</button>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- HASIL PREDIKSI CARD -->
|
||
<div class="card" id="playResultCard">
|
||
<h2>Hasil Analisis Inferensi</h2>
|
||
<div id="playPlaceholder" class="empty">
|
||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||
<span>Ketik teks di sebelah kiri lalu klik tombol analisis untuk melihat hasil pembacaan model.</span>
|
||
</div>
|
||
|
||
<div id="playResultContent" style="display:none">
|
||
<!-- VERDICT BADGE -->
|
||
<div class="verdict-card">
|
||
<div class="verdict-badge" id="resBadge">POSITIF</div>
|
||
<div class="play-conclusion-sub" id="resConclusionSub" style="display:none"></div>
|
||
<div class="confidence-text">Tingkat Keyakinan Model: <b id="resConfidence">0%</b></div>
|
||
|
||
<div class="dual-bar">
|
||
<div class="bar-pos" id="barPos" style="width:50%"></div>
|
||
<div class="bar-neg" id="barNeg" style="width:50%"></div>
|
||
</div>
|
||
<div class="bar-labels">
|
||
<span class="p">Positif: <span id="lblPos">0%</span></span>
|
||
<span class="n">Negatif: <span id="lblNeg">0%</span></span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- PREPROCESSING INSPECTOR -->
|
||
<div class="prep-step">
|
||
<div class="prep-step-title">Hasil Preprocessing (9 Tahapan NLP)</div>
|
||
<div class="prep-code" id="resCleaned">-</div>
|
||
</div>
|
||
|
||
<!-- FITUR DOMINAN TF-IDF -->
|
||
<div style="margin-top:16px">
|
||
<div class="prep-step-title">Fitur Dominan (Kata Kunci & Bobot Model)</div>
|
||
<div class="features-cloud" id="resFeatures"></div>
|
||
</div>
|
||
|
||
<div style="margin-top:14px;font-size:11.5px;color:var(--muted);text-align:right" id="resElapsed">
|
||
Waktu Inferensi: 0 ms
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Quick Preset Click
|
||
document.querySelectorAll('[data-test]').forEach(btn => {
|
||
btn.onclick = () => {
|
||
$('#playText').value = btn.dataset.test;
|
||
$('#fPlayground').dispatchEvent(new Event('submit'));
|
||
};
|
||
});
|
||
|
||
// Handle Predict Submit
|
||
$('#fPlayground').onsubmit = async e => {
|
||
e.preventDefault();
|
||
const text = $('#playText').value.trim();
|
||
const msg = $('#playMsg');
|
||
const btn = $('#btnRunPredict');
|
||
msg.textContent = '';
|
||
|
||
if (!text) {
|
||
msg.textContent = 'Harap ketik kata atau kalimat terlebih dahulu.';
|
||
return;
|
||
}
|
||
|
||
btn.disabled = true;
|
||
try {
|
||
const res = await api('/predict', {
|
||
method: 'POST',
|
||
body: { komentar: text }
|
||
});
|
||
|
||
$('#playPlaceholder').style.display = 'none';
|
||
$('#playResultContent').style.display = 'block';
|
||
|
||
// Badge Verdict
|
||
const badge = $('#resBadge');
|
||
badge.textContent = res.sentimen;
|
||
const sub = $('#resConclusionSub');
|
||
|
||
if (res.sentimen === 'GAGAL DIANALISIS') {
|
||
if (sub) sub.style.display = 'none';
|
||
badge.className = 'verdict-badge GAGAL';
|
||
$('#resConfidence').textContent = '0.0% (Gagal Dianalisis)';
|
||
$('#barPos').style.width = '0%';
|
||
$('#barNeg').style.width = '0%';
|
||
$('#lblPos').textContent = '0%';
|
||
$('#lblNeg').textContent = '0%';
|
||
$('#resCleaned').innerHTML = '<span style="color:#fbbf24;font-style:italic">⚠️ (Hasil preprocessing kosong - teks hanya berisi emotikon, angka, simbol, atau kata henti tanpa makna sentimen)</span>';
|
||
} else if (res.sentimen === 'FITUR TIDAK DITEMUKAN' || res.fitur_ditemukan === false) {
|
||
if (sub) sub.style.display = 'none';
|
||
badge.className = 'verdict-badge FITUR';
|
||
$('#resConfidence').textContent = '0.0% (Fitur Tidak Ditemukan)';
|
||
$('#barPos').style.width = '0%';
|
||
$('#barNeg').style.width = '0%';
|
||
$('#lblPos').textContent = '0%';
|
||
$('#lblNeg').textContent = '0%';
|
||
$('#resCleaned').textContent = res.preprocessing || '-';
|
||
} else {
|
||
badge.className = 'verdict-badge ' + res.sentimen;
|
||
$('#resConfidence').textContent = pct(res.keyakinan);
|
||
const posPct = res.probabilitas.positif * 100;
|
||
const negPct = res.probabilitas.negatif * 100;
|
||
|
||
if (sub) {
|
||
sub.style.display = 'inline-flex';
|
||
if (res.sentimen === 'NEGATIF') {
|
||
sub.className = 'play-conclusion-sub neg';
|
||
sub.innerHTML = '🚨 Kesimpulan: Terindikasi Cyberbullying';
|
||
} else {
|
||
sub.className = 'play-conclusion-sub pos';
|
||
sub.innerHTML = '✅ Kesimpulan: Tidak Terindikasi Cyberbullying';
|
||
}
|
||
}
|
||
|
||
$('#barPos').style.width = posPct + '%';
|
||
$('#barNeg').style.width = negPct + '%';
|
||
$('#lblPos').textContent = pct(posPct);
|
||
$('#lblNeg').textContent = pct(negPct);
|
||
$('#resCleaned').textContent = res.preprocessing || '(Kosong / emote & simbol terhapus)';
|
||
}
|
||
|
||
// Fitur Dominan
|
||
const featsWrap = $('#resFeatures');
|
||
if (res.sentimen === 'GAGAL DIANALISIS') {
|
||
featsWrap.innerHTML = '<span style="color:#fbbf24;font-size:12.5px">⚠️ Tidak ada fitur kata yang tersisa untuk dihitung TF-IDF karena seluruh token terhapus pada tahap preprocessing.</span>';
|
||
} else if (res.sentimen === 'FITUR TIDAK DITEMUKAN' || res.fitur_ditemukan === false || (!res.fitur_dominan || !res.fitur_dominan.length)) {
|
||
featsWrap.innerHTML = `
|
||
<div style="background:rgba(168,85,247,0.12);border:1px solid rgba(168,85,247,0.35);border-radius:10px;padding:12px 14px;color:#c084fc;font-size:13px;display:flex;align-items:flex-start;gap:10px">
|
||
<span style="font-size:16px;line-height:1.2">ℹ️</span>
|
||
<div>
|
||
<b style="color:#c084fc">Keterangan: Fitur Tidak Ditemukan</b>
|
||
<div style="font-size:12px;color:rgba(255,255,255,0.7);margin-top:3px">Seluruh kata pada teks ini tidak terdapat dalam kosakata TF-IDF model (Out-of-Vocabulary), sehingga tidak dipaksakan ke sentimen POSITIF/NEGATIF dan ditandai sebagai FITUR TIDAK DITEMUKAN.</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else if (res.fitur_dominan && res.fitur_dominan.length) {
|
||
featsWrap.innerHTML = res.fitur_dominan.map(f => {
|
||
const isPos = f.weight > 0;
|
||
return `<span class="feat-tag ${isPos ? 'pos' : 'neg'}">
|
||
<b>${esc(f.token)}</b>
|
||
<small style="opacity:0.75">TF-IDF: ${f.tfidf} | W: ${f.weight}</small>
|
||
</span>`;
|
||
}).join('');
|
||
} else {
|
||
featsWrap.innerHTML = '<span style="color:var(--muted);font-size:12.5px">Tidak ada n-gram dominan pada teks ini.</span>';
|
||
}
|
||
|
||
$('#resElapsed').textContent = `Waktu Inferensi: ${res.elapsed_ms} ms`;
|
||
|
||
} catch (err) {
|
||
msg.textContent = err.message;
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
};
|
||
}
|
||
|
||
// ==========================================
|
||
// 4. RIWAYAT ANALISIS (HISTORY)
|
||
// ==========================================
|
||
async function historyView() {
|
||
view.innerHTML = `
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;flex-wrap:wrap;gap:12px">
|
||
<div>
|
||
<h1>Riwayat Analisis</h1>
|
||
<p class="sub" style="margin-bottom:0">Daftar seluruh riwayat video dan input komentar yang pernah dianalisis.</p>
|
||
</div>
|
||
<a href="#/" class="chip on">+ Analisis Baru</a>
|
||
</div>
|
||
<div id="histList"><div class="spin" style="margin:40px auto"></div></div>
|
||
`;
|
||
|
||
try {
|
||
const { data } = await api('/analyses');
|
||
const list = $('#histList');
|
||
|
||
if (!data || !data.length) {
|
||
list.innerHTML = '<div class="card empty">Belum ada riwayat analisis tersimpan. Mulai dari Dashboard dengan memasukkan link video atau komentar mandiri.</div>';
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = '<div class="hist">' + data.map(a => `
|
||
<div class="hist-card">
|
||
<h3>${esc(shortUrl(a))}</h3>
|
||
<div class="date">${tgl(a.created_at)}</div>
|
||
|
||
${a.status === 'done' || a.status === 'scraped' || a.status === 'manual' ? `
|
||
<div class="row"><span>Total Komentar</span><b>${num(a.total_comments)}</b></div>
|
||
<div class="row"><span>Sentimen Positif</span><b class="p">${num(a.positive_count)} (${pct(a.positive_percentage)})</b></div>
|
||
<div class="row"><span>Sentimen Negatif</span><b class="n">${num(a.negative_count)} (${pct(a.negative_percentage)})</b></div>
|
||
${(a.total_comments - a.positive_count - a.negative_count > 0) ? `
|
||
<div class="row"><span>Gagal Dianalisis</span><b style="color:#fbbf24">${num(a.total_comments - a.positive_count - a.negative_count)} (${pct((a.total_comments - a.positive_count - a.negative_count) / a.total_comments * 100)})</b></div>
|
||
` : ''}
|
||
|
||
<div class="bar">
|
||
<i class="p" style="width:${a.positive_percentage}%"></i>
|
||
<i class="n" style="width:${a.negative_percentage}%"></i>
|
||
${(a.total_comments - a.positive_count - a.negative_count > 0) ? `
|
||
<i style="width:${((a.total_comments - a.positive_count - a.negative_count) / a.total_comments * 100)}%;background:#f59e0b"></i>
|
||
` : ''}
|
||
</div>
|
||
|
||
<div class="hist-conclusion ${a.negative_count > a.positive_count ? 'bullying' : (a.positive_count > a.negative_count ? 'safe' : 'neutral')}">
|
||
<span class="hc-dot"></span>
|
||
<span>Kesimpulan: <b>${a.negative_count > a.positive_count ? 'Terindikasi Cyberbullying' : (a.positive_count > a.negative_count ? 'Tidak Cyberbullying' : 'Seimbang')}</b></span>
|
||
</div>
|
||
` : `
|
||
<p class="sub"><span class="pill ${a.status}">${statusText(a.status)}</span> ${esc(a.error || '')}</p>
|
||
`}
|
||
|
||
<div class="actions">
|
||
<a class="chip on" href="#/analysis/${a.id}">Lihat Detail</a>
|
||
<button class="chip danger" data-del="${a.id}">Hapus</button>
|
||
</div>
|
||
</div>
|
||
`).join('') + '</div>';
|
||
|
||
document.querySelectorAll('[data-del]').forEach(btn => {
|
||
btn.onclick = async () => {
|
||
if (!confirm('Apakah Anda yakin ingin menghapus riwayat analisis ini beserta seluruh datanya?')) return;
|
||
try {
|
||
await api('/analyses/' + btn.dataset.del, { method: 'DELETE' });
|
||
historyView();
|
||
} catch (e) {
|
||
alert(e.message);
|
||
}
|
||
};
|
||
});
|
||
|
||
} catch (e) {
|
||
// Handled
|
||
}
|
||
}
|
||
|
||
// ==========================================
|
||
// 5. DETAIL ANALISIS + PAGINATION + FILTER
|
||
// ==========================================
|
||
function donutSvg(p, n) {
|
||
const C = 2 * Math.PI * 54;
|
||
const pa = C * (p / 100);
|
||
const na = C * (n / 100);
|
||
return `
|
||
<svg width="170" height="170" viewBox="0 0 140 140" role="img" aria-label="Positif ${pct(p)}, Negatif ${pct(n)}">
|
||
<circle cx="70" cy="70" r="54" fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="18"/>
|
||
<circle cx="70" cy="70" r="54" fill="none" stroke="#10b981" stroke-width="18" stroke-dasharray="${pa} ${C - pa}"/>
|
||
<circle cx="70" cy="70" r="54" fill="none" stroke="#f43f5e" stroke-width="18" stroke-dasharray="${na} ${C - na}" stroke-dashoffset="${-pa}"/>
|
||
</svg>
|
||
`;
|
||
}
|
||
|
||
async function detailView(id) {
|
||
let a;
|
||
try {
|
||
a = (await api('/analyses/' + id)).data;
|
||
} catch (e) {
|
||
view.innerHTML = `<div class="card empty">${esc(e.message)}<br><a class="chip" href="#/history" style="margin-top:14px">Kembali ke Riwayat</a></div>`;
|
||
return;
|
||
}
|
||
|
||
const posCount = Number(a.positive_count) || 0;
|
||
const negCount = Number(a.negative_count) || 0;
|
||
const totalComments = Number(a.total_comments) || 0;
|
||
const isBullying = negCount > posCount;
|
||
const isBalanced = posCount === negCount && totalComments > 0;
|
||
|
||
let verdictStatus = '';
|
||
let verdictBadgeClass = '';
|
||
let verdictIcon = '';
|
||
let dominantSentimen = '';
|
||
let dominantPct = 0;
|
||
let narrativeText = '';
|
||
|
||
if (totalComments === 0 || (posCount === 0 && negCount === 0)) {
|
||
verdictStatus = 'TIDAK DAPAT DISIMPULKAN';
|
||
verdictBadgeClass = 'neutral';
|
||
verdictIcon = '⚠️';
|
||
dominantSentimen = '-';
|
||
dominantPct = 0;
|
||
narrativeText = 'Data komentar belum memiliki fitur yang cukup untuk menarik kesimpulan sentimen.';
|
||
} else if (isBullying) {
|
||
verdictStatus = 'TERINDIKASI CYBERBULLYING';
|
||
verdictBadgeClass = 'bullying';
|
||
verdictIcon = '🚨';
|
||
dominantSentimen = 'NEGATIF';
|
||
dominantPct = a.negative_percentage;
|
||
narrativeText = `Berdasarkan hasil analisis sentimen terhadap ${num(totalComments)} komentar pada video TikTok ini menggunakan representasi fitur TF-IDF dan model Logistic Regression, diperoleh sebanyak ${num(negCount)} komentar (${pct(a.negative_percentage)}) bersentimen Negatif dan ${num(posCount)} komentar (${pct(a.positive_percentage)}) bersentimen Positif. Mengingat sentimen negatif lebih dominan dibandingkan sentimen positif (> 50%), maka dapat ditarik kesimpulan bahwa kolom komentar video ini TERINDIKASI CYBERBULLYING.`;
|
||
} else if (isBalanced) {
|
||
verdictStatus = 'SEIMBANG (NETRAL)';
|
||
verdictBadgeClass = 'neutral';
|
||
verdictIcon = '⚖️';
|
||
dominantSentimen = 'SEIMBANG';
|
||
dominantPct = 50.0;
|
||
narrativeText = `Berdasarkan hasil analisis sentimen terhadap ${num(totalComments)} komentar pada video TikTok ini, diperoleh proporsi yang seimbang antara sentimen Positif (${num(posCount)} komentar / ${pct(a.positive_percentage)}) dan sentimen Negatif (${num(negCount)} komentar / ${pct(a.negative_percentage)}). Dengan demikian, kolom komentar video ini belum menunjukkan dominasi cyberbullying.`;
|
||
} else {
|
||
verdictStatus = 'TIDAK TERINDIKASI CYBERBULLYING';
|
||
verdictBadgeClass = 'safe';
|
||
verdictIcon = '✅';
|
||
dominantSentimen = 'POSITIF';
|
||
dominantPct = a.positive_percentage;
|
||
narrativeText = `Berdasarkan hasil analisis sentimen terhadap ${num(totalComments)} komentar pada video TikTok ini menggunakan representasi fitur TF-IDF dan model Logistic Regression, diperoleh sebanyak ${num(posCount)} komentar (${pct(a.positive_percentage)}) bersentimen Positif dan ${num(negCount)} komentar (${pct(a.negative_percentage)}) bersentimen Negatif. Mengingat sentimen positif lebih dominan dibandingkan sentimen negatif (> 50%), maka dapat ditarik kesimpulan bahwa kolom komentar video ini TIDAK TERINDIKASI CYBERBULLYING (Kondusif dan Bebas Perundungan Siber).`;
|
||
}
|
||
|
||
view.innerHTML = `
|
||
<div style="margin-bottom:16px">
|
||
<a href="#/history" style="font-size:13.5px;display:inline-flex;align-items:center;gap:6px">
|
||
← Kembali ke Riwayat
|
||
</a>
|
||
</div>
|
||
|
||
<!-- DONUT SUMMARY CARD -->
|
||
<div class="card">
|
||
<div class="donut-wrap">
|
||
<div class="donut">
|
||
${donutSvg(a.positive_percentage, a.negative_percentage)}
|
||
<div class="c">
|
||
<b>${num(a.total_comments)}</b>
|
||
<small>Komentar</small>
|
||
</div>
|
||
</div>
|
||
<div class="legend">
|
||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||
<span class="url">${esc(a.tiktok_url)}</span>
|
||
${a.status === 'scraped' ? `
|
||
<span class="pill" style="background:rgba(37,244,238,0.12);color:#25f4ee;border:1px solid rgba(37,244,238,0.3)">
|
||
🌐 Scraping Komentar Asli TikTok
|
||
</span>
|
||
` : a.video_id === 'manual' || a.status === 'manual' ? `
|
||
<span class="pill" style="background:rgba(59,130,246,0.12);color:#60a5fa;border:1px solid rgba(59,130,246,0.3)">
|
||
📝 Komentar Mandiri / Upload
|
||
</span>
|
||
` : `
|
||
<span class="pill" style="background:rgba(245,158,11,0.12);color:#fbbf24;border:1px solid rgba(245,158,11,0.3)">
|
||
🧪 Dataset Sampel
|
||
</span>
|
||
`}
|
||
</div>
|
||
<div><span class="dot" style="background:#10b981"></span>Sentimen Positif: <b>${num(a.positive_count)}</b> (${pct(a.positive_percentage)})</div>
|
||
<div><span class="dot" style="background:#f43f5e"></span>Sentimen Negatif: <b>${num(a.negative_count)}</b> (${pct(a.negative_percentage)})</div>
|
||
${(a.total_comments - a.positive_count - a.negative_count > 0) ? `
|
||
<div><span class="dot" style="background:#f59e0b"></span>Gagal Dianalisis: <b>${num(a.total_comments - a.positive_count - a.negative_count)}</b> (${pct((a.total_comments - a.positive_count - a.negative_count) / a.total_comments * 100)})</div>
|
||
` : ''}
|
||
<div class="date" style="margin:0">Dianalisis pada: ${tgl(a.created_at)}</div>
|
||
${a.error ? `<div style="font-size:12px;color:#fbbf24;background:rgba(245,158,11,0.08);padding:6px 10px;border-radius:8px;border:1px solid rgba(245,158,11,0.2)">⚠️ Catatan: ${esc(a.error)}</div>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CARD KESIMPULAN ANALISIS CYBERBULLYING -->
|
||
<div class="conclusion-card ${verdictBadgeClass}" style="margin-top:20px">
|
||
<div class="conclusion-header">
|
||
<div class="conclusion-badge ${verdictBadgeClass}">
|
||
<span class="conclusion-icon">${verdictIcon}</span>
|
||
<span>KESIMPULAN: ${verdictStatus}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="conclusion-body">
|
||
<div class="conclusion-metric">
|
||
<div class="cm-stat">
|
||
<span class="cm-label">Sentimen Dominan</span>
|
||
<b class="cm-val ${dominantSentimen === 'NEGATIF' ? 'neg' : (dominantSentimen === 'POSITIF' ? 'pos' : '')}">
|
||
${dominantSentimen} ${dominantPct ? `(${pct(dominantPct)})` : ''}
|
||
</b>
|
||
</div>
|
||
<div class="cm-stat">
|
||
<span class="cm-label">Rasio Positif : Negatif</span>
|
||
<b class="cm-val"><span style="color:#34d399">${num(posCount)} Positif</span> : <span style="color:#fb7185">${num(negCount)} Negatif</span></b>
|
||
</div>
|
||
<div class="cm-stat">
|
||
<span class="cm-label">Status Kolom Komentar</span>
|
||
<b class="cm-val ${isBullying ? 'neg' : 'pos'}">${verdictStatus}</b>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="conclusion-text-box">
|
||
<div class="conclusion-text-title">📝 Ringkasan Penarikan Kesimpulan (Berdasarkan Hasil Analisis):</div>
|
||
<p class="conclusion-narrative" id="conclusionNarrative">
|
||
${narrativeText}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TABLE KOMENTAR CARD -->
|
||
<div class="card" style="margin-top:20px">
|
||
<div class="filters-bar">
|
||
<h2>Daftar Komentar (${num(a.total_comments)})</h2>
|
||
|
||
<div class="filters" id="filters">
|
||
<button class="chip on" data-f="">Semua (${num(a.total_comments)})</button>
|
||
<button class="chip" data-f="POSITIF">Positif (${num(a.positive_count)})</button>
|
||
<button class="chip" data-f="NEGATIF">Negatif (${num(a.negative_count)})</button>
|
||
${(a.total_comments - a.positive_count - a.negative_count > 0) ? `
|
||
<button class="chip" data-f="GAGAL DIANALISIS" style="border-color:rgba(245,158,11,0.4);color:#fbbf24">Gagal Dianalisis</button>
|
||
<button class="chip" data-f="FITUR TIDAK DITEMUKAN" style="border-color:rgba(168,85,247,0.45);color:#c084fc">Fitur Tidak Ditemukan</button>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="table-wrap">
|
||
<div id="tblContent"><div class="spin" style="margin:30px auto"></div></div>
|
||
</div>
|
||
|
||
<div class="pager" id="pagerContent"></div>
|
||
</div>
|
||
`;
|
||
|
||
let filter = '';
|
||
let page = 1;
|
||
|
||
async function loadTable() {
|
||
try {
|
||
const res = await api(`/analyses/${id}/comments?page=${page}&limit=50${filter ? '&sentiment=' + filter : ''}`);
|
||
const off = (res.page - 1) * res.limit;
|
||
|
||
if (!res.data || !res.data.length) {
|
||
$('#tblContent').innerHTML = '<div class="empty">Tidak ada komentar yang cocok dengan filter ini.</div>';
|
||
$('#pagerContent').innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
$('#tblContent').innerHTML = `
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th style="width:46px">#</th>
|
||
<th>Komentar Asli</th>
|
||
<th>Hasil Preprocessing</th>
|
||
<th style="text-align:right;width:120px">Sentimen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${res.data.map((c, i) => `
|
||
<tr>
|
||
<td class="no">${off + i + 1}</td>
|
||
<td class="t">${esc(c.original_comment)}</td>
|
||
<td class="cleaned-cell">
|
||
${c.cleaned_comment && c.cleaned_comment.trim() ? `
|
||
<div>${esc(c.cleaned_comment)}</div>
|
||
${c.keterangan === 'Fitur tidak ditemukan' || c.fitur_ditemukan === false ? `
|
||
<div style="color:#fbbf24;font-size:11px;margin-top:3px;font-style:italic">⚠️ Keterangan: Fitur tidak ditemukan</div>
|
||
` : ''}
|
||
` : '<span style="color:#fbbf24;font-style:italic;font-size:12px">⚠️ (Preprocessing kosong / hanya simbol atau emotikon)</span>'}
|
||
</td>
|
||
<td class="s">
|
||
<span class="sent-badge ${c.sentiment === 'FITUR TIDAK DITEMUKAN' ? 'FITUR' : (c.sentiment === 'GAGAL DIANALISIS' ? 'GAGAL' : c.sentiment)}">${c.sentiment}</span>
|
||
<small>${(c.sentiment === 'GAGAL DIANALISIS' || c.sentiment === 'FITUR TIDAK DITEMUKAN') ? '-' : pct(c.probability * 100)}</small>
|
||
</td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
|
||
$('#pagerContent').innerHTML = pager(res.page, res.total_pages);
|
||
$('#pagerContent').querySelectorAll('[data-p]').forEach(b => {
|
||
b.onclick = () => {
|
||
page = +b.dataset.p;
|
||
loadTable();
|
||
$('#tblContent').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
};
|
||
});
|
||
|
||
} catch (e) {
|
||
$('#tblContent').innerHTML = `<div class="empty">${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
$('#filters').onclick = e => {
|
||
const btn = e.target.closest('[data-f]');
|
||
if (!btn) return;
|
||
filter = btn.dataset.f;
|
||
page = 1;
|
||
document.querySelectorAll('#filters .chip').forEach(b => b.classList.toggle('on', b === btn));
|
||
loadTable();
|
||
};
|
||
|
||
loadTable();
|
||
|
||
// Listener Salin Kesimpulan untuk Skripsi
|
||
const btnCopy = $('#btnCopyConclusion');
|
||
if (btnCopy) {
|
||
btnCopy.onclick = () => {
|
||
navigator.clipboard.writeText(narrativeText).then(() => {
|
||
const txt = $('#copyBtnText');
|
||
if (txt) {
|
||
const old = txt.textContent;
|
||
txt.textContent = '✅ Berhasil Disalin!';
|
||
setTimeout(() => { txt.textContent = old; }, 2500);
|
||
}
|
||
}).catch(() => {
|
||
alert('Gagal menyalin otomatis. Silakan salin teks secara manual.');
|
||
});
|
||
};
|
||
}
|
||
}
|
||
|
||
// ==========================================
|
||
// 6. PENGUJIAN MODEL & CONFUSION MATRIX (ADMIN)
|
||
// ==========================================
|
||
|
||
// ==========================================
|
||
// 6. PENGUJIAN MODEL & CONFUSION MATRIX (ADMIN)
|
||
// ==========================================
|
||
|
||
let evalCachedResult = null;
|
||
let currentScrapedComments = [];
|
||
let currentVideoSourceTitle = "";
|
||
|
||
function downloadEvaluationResultsCsv(details, summary) {
|
||
let csv = "No,Komentar Asli,Hasil Preprocessing,Label Sebenarnya,Prediksi Model,Status,Kategori Matriks,Keyakinan (%)\n";
|
||
details.forEach(d => {
|
||
const orig = '"' + d.original_comment.replace(/"/g, '""') + '"';
|
||
const clean = '"' + (d.cleaned_comment || '').replace(/"/g, '""') + '"';
|
||
const status = d.is_correct ? 'BENAR' : 'SALAH';
|
||
csv += `${d.index},${orig},${clean},${d.actual_label},${d.predicted_label},${status},${d.matrix_type},${d.confidence}%\n`;
|
||
});
|
||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `laporan_pengujian_confusion_matrix_${Date.now()}.csv`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
async function evaluationView() {
|
||
view.innerHTML = `
|
||
<!-- HEADER -->
|
||
<div class="eval-header">
|
||
<div>
|
||
<div class="eval-badge-header">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
|
||
<span>Admin Model Testing & Evaluation</span>
|
||
</div>
|
||
<h1>Pengujian Model (Confusion Matrix)</h1>
|
||
<p class="sub" style="margin-bottom:0">
|
||
Uji akurasi, presisi, recall, dan matriks kebingungan (Confusion Matrix) model Logistic Regression & TF-IDF langsung menggunakan tautan video TikTok.
|
||
</p>
|
||
<div class="eval-meta-bar">
|
||
<span class="eval-meta-chip">Algoritma: <b>Logistic Regression</b></span>
|
||
<span class="eval-meta-chip">Fitur: <b>TF-IDF</b></span>
|
||
<span class="eval-meta-chip">Sumber: <b>Link Video TikTok</b></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- INPUT LINK TIKTOK CARD -->
|
||
<div class="card" style="margin-bottom:24px">
|
||
<h2>
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||
Masukkan Link Video TikTok untuk Pengujian
|
||
</h2>
|
||
<p class="sub">
|
||
Sistem akan mengambil komentar nyata dari video TikTok secara otomatis, menjalankan pra-pemrosesan NLP dan prediksi sentimen, lalu mengevaluasi Confusion Matrix.
|
||
</p>
|
||
|
||
<form id="fEvalTiktok" novalidate>
|
||
<label for="evalTiktokUrl">Link Video TikTok</label>
|
||
<input id="evalTiktokUrl" type="text" placeholder="https://www.tiktok.com/@creator/video/7123456789012345678" required>
|
||
|
||
<div style="margin-top:14px;padding:12px 14px;background:rgba(255,255,255,0.02);border:1px solid var(--border);border-radius:12px">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:8px">
|
||
<label for="evalTargetComments" style="margin:0;font-size:13px;color:var(--ink);display:flex;align-items:center;gap:6px">
|
||
<span>🎯 Target Jumlah Komentar:</span>
|
||
<span class="hint-tag" style="font-weight:400">(Bisa ditentukan bebas)</span>
|
||
</label>
|
||
<div style="display:flex;align-items:center;gap:6px">
|
||
<input type="number" id="evalTargetComments" min="1" max="1000" step="5" value="100"
|
||
style="width:90px;padding:6px 10px;font-size:13px;font-weight:700;text-align:center;border-radius:8px;background:rgba(0,0,0,0.4);border:1px solid rgba(37,244,238,0.45);color:#25f4ee">
|
||
<span style="font-size:12px;color:var(--ink-soft)">komentar</span>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px">
|
||
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
|
||
<span style="font-size:11px;color:var(--muted)">Opsi Cepat:</span>
|
||
<button type="button" class="preset-chip eval-target-preset" data-val="30">30</button>
|
||
<button type="button" class="preset-chip eval-target-preset" data-val="50">50</button>
|
||
<button type="button" class="preset-chip eval-target-preset active" data-val="100">100</button>
|
||
<button type="button" class="preset-chip eval-target-preset" data-val="150">150</button>
|
||
<button type="button" class="preset-chip eval-target-preset" data-val="200">200</button>
|
||
<button type="button" class="preset-chip eval-target-preset" data-val="300">300</button>
|
||
</div>
|
||
<!-- Opsi Cepat dari Riwayat Analisis Database -->
|
||
<div style="display:flex;align-items:center;gap:8px" id="evalRecentDropdownWrap">
|
||
<select id="evalSelectRecent" style="width:auto;padding:6px 12px;font-size:12px;border-radius:8px;background:rgba(255,255,255,0.05);color:var(--ink);border:1px solid var(--border)">
|
||
<option value="">-- Atau Pilih Video dari Riwayat Analisis --</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="msg" id="evalTiktokMsg" style="margin-top:12px"></div>
|
||
|
||
<button class="btn" id="btnScrapeAndTest" style="margin-top:16px">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||
<span>Tarik Komentar TikTok & Mulai Pengujian</span>
|
||
</button>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- AREA VERIFIKASI GROUND TRUTH (MUNCUL SETELAH SCRAPE) -->
|
||
<div id="evalVerificationSection" style="display:none;margin-bottom:24px">
|
||
<div class="card">
|
||
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:14px;flex-wrap:wrap;gap:12px">
|
||
<div>
|
||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
|
||
<h2 style="margin-bottom:0">Verifikasi Komentar & Ground Truth</h2>
|
||
<span class="pill" id="scrapedCountBadge" style="background:rgba(37,244,238,0.12);color:#25f4ee;border:1px solid rgba(37,244,238,0.3)">0 Komentar</span>
|
||
</div>
|
||
<p class="sub" style="margin-bottom:0" id="scrapedVideoTitle">-</p>
|
||
</div>
|
||
|
||
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
||
<button class="chip" id="btnResetGroundTruth" type="button">🔄 Samakan Semua dgn Prediksi</button>
|
||
<button class="btn" id="btnCalculateMatrix" style="padding:9px 20px;margin-top:0">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>
|
||
<span>Hitung Confusion Matrix</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="background:rgba(59,130,246,0.08);border:1px solid rgba(59,130,246,0.25);border-radius:10px;padding:12px 16px;font-size:12.5px;color:#93c5fd;margin-bottom:16px;display:flex;align-items:center;gap:10px">
|
||
<span style="font-size:18px">💡</span>
|
||
<div>
|
||
<b>Panduan Pengujian:</b> Untuk membentuk Confusion Matrix (TP, TN, FP, FN), diperlukan perbandingan antara <b>Prediksi Model</b> dan <b>Label Sebenarnya (Ground Truth)</b>. Sistem telah mengisikan label awal sesuai hasil prediksi model. Anda (Admin/Peneliti) dapat mengubah label komentar jika ada yang keliru, lalu klik tombol <b>Hitung Confusion Matrix</b> di atas.
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TABEL KOMENTAR UNTUK VERIFIKASI -->
|
||
<div class="table-wrap" style="max-height:420px;overflow-y:auto">
|
||
<table style="width:100%;font-size:12.5px">
|
||
<thead>
|
||
<tr>
|
||
<th style="width:40px">#</th>
|
||
<th>Komentar Asli TikTok</th>
|
||
<th>Hasil Preprocessing NLP</th>
|
||
<th style="width:130px;text-align:center">Prediksi Model</th>
|
||
<th style="width:170px;text-align:center">Label Sebenarnya (Ground Truth)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="verifyTableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- HASIL EVALUASI CONTAINER -->
|
||
<div id="evalResultSection">
|
||
<div id="evalPlaceholderCard" class="card empty" style="padding:48px 20px;text-align:center">
|
||
<svg width="54" height="54" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="margin:0 auto 12px;color:var(--muted)">
|
||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||
</svg>
|
||
<h3 style="color:#ffffff;margin-bottom:6px">Menunggu Input Video TikTok</h3>
|
||
<p class="sub" style="max-width:540px;margin:0 auto">
|
||
Masukkan tautan video TikTok di atas untuk menarik komentar nyata dan mengevaluasi matriks kebingungan (Confusion Matrix) model secara otomatis.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- KONTEN AKTIF EVALUASI -->
|
||
<div id="evalActiveContent" style="display:none"></div>
|
||
</div>
|
||
`;
|
||
|
||
// --- POPULATE RECENT VIDEOS DROPDOWN ---
|
||
try {
|
||
const recentRes = await api('/model-test/analyses-list');
|
||
const list = recentRes.data || [];
|
||
const select = $('#evalSelectRecent');
|
||
if (list.length && select) {
|
||
list.forEach(a => {
|
||
const opt = document.createElement('option');
|
||
opt.value = a.id;
|
||
const shortTitle = a.tiktok_url.length > 36 ? a.tiktok_url.substring(0, 36) + '...' : a.tiktok_url;
|
||
opt.textContent = `${shortTitle} (${a.total_comments} komen)`;
|
||
opt.dataset.url = a.tiktok_url;
|
||
opt.dataset.total = a.total_comments;
|
||
opt.dataset.videoId = a.video_id || '';
|
||
select.appendChild(opt);
|
||
});
|
||
select.onchange = () => {
|
||
if (select.value) {
|
||
const opt = select.selectedOptions[0];
|
||
if (opt && opt.dataset.url) {
|
||
$('#evalTiktokUrl').value = opt.dataset.url;
|
||
}
|
||
}
|
||
};
|
||
$('#evalTiktokUrl').oninput = () => {
|
||
if (select) select.value = '';
|
||
};
|
||
}
|
||
} catch (e) {}
|
||
|
||
// Preset & Input Bebas Target Komentar Pengujian
|
||
document.querySelectorAll('.eval-target-preset').forEach(btn => {
|
||
btn.onclick = () => {
|
||
document.querySelectorAll('.eval-target-preset').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
const input = $('#evalTargetComments');
|
||
if (input) input.value = btn.dataset.val;
|
||
};
|
||
});
|
||
$('#evalTargetComments')?.addEventListener('input', e => {
|
||
const v = e.target.value;
|
||
document.querySelectorAll('.eval-target-preset').forEach(b => {
|
||
b.classList.toggle('active', b.dataset.val === v);
|
||
});
|
||
});
|
||
|
||
// --- SUBMIT TIKTOK URL FOR SCRAPING & PREDICTION ---
|
||
$('#fEvalTiktok').onsubmit = async e => {
|
||
e.preventDefault();
|
||
const url = $('#evalTiktokUrl').value.trim();
|
||
const msg = $('#evalTiktokMsg');
|
||
const btn = $('#btnScrapeAndTest');
|
||
const select = $('#evalSelectRecent');
|
||
msg.textContent = '';
|
||
msg.style.display = 'none';
|
||
|
||
if (!url) {
|
||
msg.textContent = 'Harap masukkan tautan video TikTok.';
|
||
msg.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
const customTargetVal = parseInt($('#evalTargetComments')?.value, 10);
|
||
const maxComments = (!isNaN(customTargetVal) && customTargetVal > 0) ? customTargetVal : 100;
|
||
|
||
btn.disabled = true;
|
||
btn.innerHTML = `
|
||
<div class="spin" style="width:16px;height:16px;border-width:2px;margin:0"></div>
|
||
<span>Sedang mengambil ${maxComments} komentar asli TikTok & menjalankan prediksi NLP...</span>
|
||
`;
|
||
|
||
try {
|
||
let res;
|
||
// Jika dipilih dari analisis database yang sama dan jumlah komentar database mencukupi target
|
||
const selectedDbTotal = +(select?.selectedOptions[0]?.dataset?.total || 0);
|
||
if (select && select.value && select.selectedOptions[0]?.dataset.url === url && selectedDbTotal >= maxComments) {
|
||
res = await api(`/model-test/load-analysis/${select.value}`);
|
||
} else {
|
||
// Scrape langsung ke TikTok untuk mendapatkan jumlah komentar sesuai target (misal: 100)
|
||
res = await api('/model-test/scrape-tiktok', {
|
||
method: 'POST',
|
||
body: { tiktok_url: url, max_comments: maxComments }
|
||
});
|
||
}
|
||
|
||
currentScrapedComments = res.comments || [];
|
||
const author = res.meta?.author ? `@${res.meta.author}` : '';
|
||
currentVideoSourceTitle = `${url} ${author ? '(' + author + ')' : ''}`;
|
||
|
||
renderVerificationTable(res);
|
||
$('#evalVerificationSection').style.display = 'block';
|
||
$('#evalVerificationSection').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
|
||
} catch (err) {
|
||
msg.textContent = err.message;
|
||
msg.style.display = 'block';
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.innerHTML = `
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||
<span>Tarik Komentar TikTok & Mulai Pengujian</span>
|
||
`;
|
||
}
|
||
};
|
||
|
||
function renderVerificationTable(res) {
|
||
$('#scrapedCountBadge').textContent = `${currentScrapedComments.length} Komentar Berhasil Di-scrape`;
|
||
const title = res.meta?.title || currentVideoSourceTitle;
|
||
$('#scrapedVideoTitle').textContent = title;
|
||
|
||
const tbody = $('#verifyTableBody');
|
||
tbody.innerHTML = currentScrapedComments.map((c, idx) => `
|
||
<tr>
|
||
<td class="no">${idx + 1}</td>
|
||
<td class="t">${esc(c.original_comment)}</td>
|
||
<td class="cleaned-cell">
|
||
${c.cleaned_comment && c.cleaned_comment.trim() ? `
|
||
<div>${esc(c.cleaned_comment)}</div>
|
||
${c.keterangan === 'Fitur tidak ditemukan' || c.fitur_ditemukan === false ? `
|
||
<div style="color:#fbbf24;font-size:11px;margin-top:2px;font-style:italic">⚠️ Keterangan: Fitur tidak ditemukan</div>
|
||
` : ''}
|
||
` : '<span style="color:#fbbf24;font-style:italic;font-size:12px">⚠️ (Preprocessing kosong)</span>'}
|
||
</td>
|
||
<td style="text-align:center">
|
||
<span class="sent-badge ${c.predicted_label === 'FITUR TIDAK DITEMUKAN' ? 'FITUR' : (c.predicted_label === 'GAGAL DIANALISIS' ? 'GAGAL' : c.predicted_label)}">${c.predicted_label}</span>
|
||
<div style="font-size:11px;color:var(--muted);margin-top:2px">${(c.predicted_label === 'GAGAL DIANALISIS' || c.predicted_label === 'FITUR TIDAK DITEMUKAN') ? '-' : c.confidence + '%'}</div>
|
||
</td>
|
||
<td style="text-align:center">
|
||
<select class="gt-select" data-idx="${idx}" style="padding:5px 10px;border-radius:8px;font-size:12px;font-weight:700;background:rgba(255,255,255,0.06);color:var(--ink);border:1px solid var(--border)">
|
||
<option value="POSITIF" ${c.actual_label === 'POSITIF' ? 'selected' : ''}>POSITIF</option>
|
||
<option value="NEGATIF" ${c.actual_label === 'NEGATIF' ? 'selected' : ''}>NEGATIF</option>
|
||
</select>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
// Listener saat Admin mengubah ground truth
|
||
tbody.querySelectorAll('.gt-select').forEach(sel => {
|
||
sel.onchange = () => {
|
||
const idx = +sel.dataset.idx;
|
||
currentScrapedComments[idx].actual_label = sel.value;
|
||
};
|
||
});
|
||
|
||
// Reset button
|
||
$('#btnResetGroundTruth').onclick = () => {
|
||
currentScrapedComments.forEach(c => { c.actual_label = c.predicted_label; });
|
||
tbody.querySelectorAll('.gt-select').forEach(sel => {
|
||
const idx = +sel.dataset.idx;
|
||
sel.value = currentScrapedComments[idx].predicted_label;
|
||
});
|
||
};
|
||
|
||
// Trigger hitung confusion matrix
|
||
$('#btnCalculateMatrix').onclick = async () => {
|
||
const btn = $('#btnCalculateMatrix');
|
||
btn.disabled = true;
|
||
btn.innerHTML = `<div class="spin" style="width:14px;height:14px;border-width:2px;margin:0"></div> Menghitung...`;
|
||
|
||
try {
|
||
const payload = currentScrapedComments.map(c => ({
|
||
comment: c.original_comment,
|
||
label: c.actual_label
|
||
}));
|
||
|
||
const evalRes = await api('/model-test/evaluate', {
|
||
method: 'POST',
|
||
body: { dataset: payload }
|
||
});
|
||
|
||
evalCachedResult = evalRes;
|
||
$('#evalPlaceholderCard').style.display = 'none';
|
||
$('#evalActiveContent').style.display = 'block';
|
||
renderEvaluationResults(evalRes, currentVideoSourceTitle);
|
||
$('#evalResultSection').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
|
||
} catch (err) {
|
||
alert('Gagal mengevaluasi model: ' + err.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.innerHTML = `
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>
|
||
<span>Hitung Confusion Matrix</span>
|
||
`;
|
||
}
|
||
};
|
||
}
|
||
|
||
// Jika sebelumnya sudah ada hasil yang tersimpan, tampilkan langsung
|
||
if (evalCachedResult) {
|
||
$('#evalPlaceholderCard').style.display = 'none';
|
||
$('#evalActiveContent').style.display = 'block';
|
||
renderEvaluationResults(evalCachedResult, currentVideoSourceTitle || 'Pengujian Terakhir');
|
||
}
|
||
}
|
||
|
||
// ==========================================
|
||
// RENDER EVALUATION RESULTS & CONFUSION MATRIX
|
||
// ==========================================
|
||
function renderEvaluationResults(res, sourceTitle) {
|
||
const activeBox = $('#evalActiveContent');
|
||
if (!activeBox) return;
|
||
const s = res?.summary || {};
|
||
const cm = res?.confusion_matrix || {};
|
||
const rep = res?.classification_report || {};
|
||
const details = res?.details || [];
|
||
const pctObj = cm.percentages || {};
|
||
const repPos = rep.positif || {};
|
||
const repNeg = rep.negatif || {};
|
||
const repMacro = rep.macro_avg || {};
|
||
const repWeighted = rep.weighted_avg || {};
|
||
|
||
activeBox.innerHTML = `
|
||
<!-- PRINT DOCUMENT HEADER (HANYA MUNCUL DI PDF / CETAKAN) -->
|
||
<div class="print-doc-header">
|
||
<h2>LAPORAN EVALUASI & PENGUJIAN MODEL SENTIMEN TIKTOK</h2>
|
||
<p>Metode: <b>TF-IDF N-Grams + Logistic Regression</b> • Sumber: <b>${esc(sourceTitle || 'Video TikTok')}</b> • Total Data Uji: <b>${num(s.total_samples)} Komentar</b> • Akurasi: <b>${pct(s.accuracy)}</b></p>
|
||
</div>
|
||
|
||
<!-- ACTION BAR -->
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;flex-wrap:wrap;gap:12px">
|
||
<div>
|
||
<h2 style="margin-bottom:2px">Hasil Pengujian & Confusion Matrix</h2>
|
||
<p class="sub" style="margin-bottom:0">Sumber: <b>${esc(sourceTitle || 'Tautan TikTok')}</b> • Waktu Komputasi: <b>${res.elapsed_ms || 0} ms</b></p>
|
||
</div>
|
||
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
||
<button class="chip on btn-print" id="btnPrintReport">🖨️ Cetak / Simpan PDF</button>
|
||
<button class="chip" id="btnExportCsv">📥 Unduh CSV Hasil Uji</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 5 METRIC CARDS -->
|
||
<div class="metrics-grid">
|
||
<div class="metric-card acc">
|
||
<div class="metric-top">
|
||
<span class="metric-label">Akurasi (Accuracy)</span>
|
||
<span class="metric-icon">🎯</span>
|
||
</div>
|
||
<div class="metric-value">${pct(s.accuracy)}</div>
|
||
<div class="metric-sub">${num(s.correct_count)} dari ${num(s.total_samples)} prediksi tepat</div>
|
||
</div>
|
||
|
||
<div class="metric-card prec">
|
||
<div class="metric-top">
|
||
<span class="metric-label">Presisi (Macro)</span>
|
||
<span class="metric-icon">🔍</span>
|
||
</div>
|
||
<div class="metric-value">${pct(s.macro_precision)}</div>
|
||
<div class="metric-sub">Positif: ${pct(repPos.precision)} | Negatif: ${pct(repNeg.precision)}</div>
|
||
</div>
|
||
|
||
<div class="metric-card rec">
|
||
<div class="metric-top">
|
||
<span class="metric-label">Recall (Sensitivitas)</span>
|
||
<span class="metric-icon">📡</span>
|
||
</div>
|
||
<div class="metric-value">${pct(s.macro_recall)}</div>
|
||
<div class="metric-sub">Positif: ${pct(repPos.recall)} | Negatif: ${pct(repNeg.recall)}</div>
|
||
</div>
|
||
|
||
<div class="metric-card f1">
|
||
<div class="metric-top">
|
||
<span class="metric-label">F1-Score (Harmonis)</span>
|
||
<span class="metric-icon">⚖️</span>
|
||
</div>
|
||
<div class="metric-value">${pct(s.macro_f1)}</div>
|
||
<div class="metric-sub">Positif: ${pct(repPos.f1_score)} | Negatif: ${pct(repNeg.f1_score)}</div>
|
||
</div>
|
||
|
||
<div class="metric-card total">
|
||
<div class="metric-top">
|
||
<span class="metric-label">Total Data Uji</span>
|
||
<span class="metric-icon">📦</span>
|
||
</div>
|
||
<div class="metric-value">${num(s.total_samples)}</div>
|
||
<div class="metric-sub">${num(s.correct_count)} Sesuai / ${num(s.error_count)} Galat</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CONFUSION MATRIX SECTION (GRID & FORMULAS) -->
|
||
<div class="cm-section-wrap">
|
||
<!-- 2X2 HEATMAP CARD -->
|
||
<div class="cm-matrix-card">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
|
||
<h3 style="margin-bottom:0">Matriks Kebingungan (Confusion Matrix)</h3>
|
||
<span class="pill" style="background:rgba(56,189,248,0.12);color:#38bdf8;border:1px solid rgba(56,189,248,0.3)">2 × 2 Grid</span>
|
||
</div>
|
||
|
||
<div class="cm-container">
|
||
<div class="cm-top-header">Kelas Prediksi (Predicted Class) →</div>
|
||
|
||
<div class="cm-grid-box">
|
||
<!-- Header Row -->
|
||
<div></div>
|
||
<div class="cm-col-label">POSITIF</div>
|
||
<div class="cm-col-label">NEGATIF</div>
|
||
|
||
<!-- Row 1: Actual POSITIF -->
|
||
<div class="cm-row-label">AKTUAL POSITIF</div>
|
||
<div class="cm-cell tp" title="True Positive: Aktual Positif diprediksi Positif">
|
||
<span class="cm-cell-badge">True Positive (TP)</span>
|
||
<div class="cm-cell-val">${num(cm.tp)}</div>
|
||
<div class="cm-cell-pct">${pct(pctObj.tp || 0)}</div>
|
||
<div class="cm-cell-desc">Aktual Positif diprediksi Positif</div>
|
||
</div>
|
||
<div class="cm-cell fn" title="False Negative: Aktual Positif keliru diprediksi Negatif (Galat Tipe II)">
|
||
<span class="cm-cell-badge">False Negative (FN)</span>
|
||
<div class="cm-cell-val">${num(cm.fn)}</div>
|
||
<div class="cm-cell-pct">${pct(pctObj.fn || 0)}</div>
|
||
<div class="cm-cell-desc">Aktual Positif diprediksi Negatif (Galat II)</div>
|
||
</div>
|
||
|
||
<!-- Row 2: Actual NEGATIF -->
|
||
<div class="cm-row-label">AKTUAL NEGATIF</div>
|
||
<div class="cm-cell fp" title="False Positive: Aktual Negatif keliru diprediksi Positif (Galat Tipe I)">
|
||
<span class="cm-cell-badge">False Positive (FP)</span>
|
||
<div class="cm-cell-val">${num(cm.fp)}</div>
|
||
<div class="cm-cell-pct">${pct(pctObj.fp || 0)}</div>
|
||
<div class="cm-cell-desc">Aktual Negatif diprediksi Positif (Galat I)</div>
|
||
</div>
|
||
<div class="cm-cell tn" title="True Negative: Aktual Negatif diprediksi Negatif">
|
||
<span class="cm-cell-badge">True Negative (TN)</span>
|
||
<div class="cm-cell-val">${num(cm.tn)}</div>
|
||
<div class="cm-cell-pct">${pct(pctObj.tn || 0)}</div>
|
||
<div class="cm-cell-desc">Aktual Negatif diprediksi Negatif</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- FORMULAS CARD -->
|
||
<div class="card" style="padding:22px">
|
||
<h3 style="margin-bottom:12px">Interpretasi & Rumus Matematis</h3>
|
||
<div class="cm-formulas">
|
||
<div class="formula-card">
|
||
<div class="formula-card-title">
|
||
<span>Akurasi (Accuracy)</span>
|
||
<span style="color:#34d399;font-weight:800">${pct(s.accuracy)}</span>
|
||
</div>
|
||
<div class="formula-card-code">Acc = (TP + TN) / (TP + TN + FP + FN) = (${cm.tp || 0} + ${cm.tn || 0}) / ${s.total_samples || 0}</div>
|
||
<div class="formula-card-desc">Persentase total prediksi benar secara keseluruhan terhadap semua sampel uji.</div>
|
||
</div>
|
||
|
||
<div class="formula-card">
|
||
<div class="formula-card-title">
|
||
<span>Presisi Kelas Positif (Precision)</span>
|
||
<span style="color:#38bdf8;font-weight:800">${pct(repPos.precision)}</span>
|
||
</div>
|
||
<div class="formula-card-code">Prec = TP / (TP + FP) = ${cm.tp || 0} / (${cm.tp || 0} + ${cm.fp || 0})</div>
|
||
<div class="formula-card-desc">Tingkat ketepatan saat model memprediksi kelas Positif.</div>
|
||
</div>
|
||
|
||
<div class="formula-card">
|
||
<div class="formula-card-title">
|
||
<span>Recall / Sensitivitas Positif</span>
|
||
<span style="color:#a855f7;font-weight:800">${pct(repPos.recall)}</span>
|
||
</div>
|
||
<div class="formula-card-code">Rec = TP / (TP + FN) = ${cm.tp || 0} / (${cm.tp || 0} + ${cm.fn || 0})</div>
|
||
<div class="formula-card-desc">Kemampuan model dalam menjaring seluruh komentar yang sebenarnya Positif.</div>
|
||
</div>
|
||
|
||
<div class="formula-card">
|
||
<div class="formula-card-title">
|
||
<span>F1-Score Positif</span>
|
||
<span style="color:#fbbf24;font-weight:800">${pct(repPos.f1_score)}</span>
|
||
</div>
|
||
<div class="formula-card-code">F1 = 2 × (Prec × Rec) / (Prec + Rec)</div>
|
||
<div class="formula-card-desc">Rata-rata harmonis antara nilai Presisi dan Recall.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CLASSIFICATION REPORT TABLE CARD -->
|
||
<div class="card" style="margin-bottom:24px">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||
<h3 style="margin-bottom:0">Tabel Classification Report (Standar Scikit-Learn)</h3>
|
||
<span class="pill" style="font-size:11px">Evaluasi Formal</span>
|
||
</div>
|
||
|
||
<div class="report-table-wrap">
|
||
<table class="report-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Kelas Evaluasi</th>
|
||
<th style="text-align:right">Precision</th>
|
||
<th style="text-align:right">Recall</th>
|
||
<th style="text-align:right">F1-Score</th>
|
||
<th style="text-align:right">Support (Sampel)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td><span class="sent-badge POSITIF">POSITIF</span></td>
|
||
<td style="text-align:right;font-family:var(--font-mono)">${pct(repPos.precision)}</td>
|
||
<td style="text-align:right;font-family:var(--font-mono)">${pct(repPos.recall)}</td>
|
||
<td style="text-align:right;font-family:var(--font-mono)">${pct(repPos.f1_score)}</td>
|
||
<td style="text-align:right;font-family:var(--font-mono)">${num(repPos.support)}</td>
|
||
</tr>
|
||
<tr>
|
||
<td><span class="sent-badge NEGATIF">NEGATIF</span></td>
|
||
<td style="text-align:right;font-family:var(--font-mono)">${pct(repNeg.precision)}</td>
|
||
<td style="text-align:right;font-family:var(--font-mono)">${pct(repNeg.recall)}</td>
|
||
<td style="text-align:right;font-family:var(--font-mono)">${pct(repNeg.f1_score)}</td>
|
||
<td style="text-align:right;font-family:var(--font-mono)">${num(repNeg.support)}</td>
|
||
</tr>
|
||
<tr class="total-row">
|
||
<td><b>Accuracy (Akurasi Keseluruhan)</b></td>
|
||
<td style="text-align:right;color:var(--muted)">-</td>
|
||
<td style="text-align:right;color:var(--muted)">-</td>
|
||
<td style="text-align:right;font-family:var(--font-mono);color:#34d399"><b>${pct(rep.accuracy || s.accuracy)}</b></td>
|
||
<td style="text-align:right;font-family:var(--font-mono)"><b>${num(s.total_samples)}</b></td>
|
||
</tr>
|
||
<tr class="total-row">
|
||
<td><b>Macro Average</b></td>
|
||
<td style="text-align:right;font-family:var(--font-mono)">${pct(repMacro.precision)}</td>
|
||
<td style="text-align:right;font-family:var(--font-mono)">${pct(repMacro.recall)}</td>
|
||
<td style="text-align:right;font-family:var(--font-mono)">${pct(repMacro.f1_score)}</td>
|
||
<td style="text-align:right;font-family:var(--font-mono)">${num(repMacro.support)}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- DETAIL PREDIKSI PER SAMPEL TABLE CARD -->
|
||
<div class="card">
|
||
<div class="details-toolbar">
|
||
<div>
|
||
<h3 style="margin-bottom:2px">Rincian Hasil Prediksi Sampel (${num(details.length)})</h3>
|
||
<p class="sub" style="margin-bottom:0">Telusuri hasil prediksi per komentar untuk menganalisis misklasifikasi dan kata dominan.</p>
|
||
</div>
|
||
|
||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
||
<div class="view-toggle-wrap" style="display:flex;gap:4px">
|
||
<button class="chip on" id="btnModeTable" type="button" title="Tampilan Tabel Standar">📑 Tabel</button>
|
||
<button class="chip" id="btnModeCards" type="button" title="Tampilan Mengalir ke Bawah">📋 Mengalir ke Bawah</button>
|
||
</div>
|
||
<input type="text" class="search-box-input" id="searchEvalInput" placeholder="Cari teks komentar...">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- FILTER CHIPS -->
|
||
<div class="filters" id="evalDetailFilters" style="margin-bottom:16px;flex-wrap:wrap">
|
||
<button class="chip on" data-filter="ALL">Semua (${num(details.length)})</button>
|
||
<button class="chip" data-filter="CORRECT">✅ Prediksi Benar (${num(s.correct_count)})</button>
|
||
<button class="chip" data-filter="ERROR">❌ Miskalsifikasi (${num(s.error_count)})</button>
|
||
<button class="chip" data-filter="TP">TP (${num(cm.tp)})</button>
|
||
<button class="chip" data-filter="TN">TN (${num(cm.tn)})</button>
|
||
<button class="chip" data-filter="FP">FP (${num(cm.fp)})</button>
|
||
<button class="chip" data-filter="FN">FN (${num(cm.fn)})</button>
|
||
</div>
|
||
|
||
<!-- TABLE & CARDS WRAP (LAYAR INTERAKTIF) -->
|
||
<div class="table-wrap screen-only">
|
||
<div id="evalDetailTableContent"></div>
|
||
</div>
|
||
|
||
<div class="pager screen-only" id="evalDetailPager"></div>
|
||
|
||
<!-- DEDICATED PRINT DETAILS (TAMPIL LENGKAP SELURUH DATA MENGALIR KE BAWAH SAAT CETAK / SIMPAN PDF) -->
|
||
<div class="print-all-samples" id="evalPrintAllSamples">
|
||
<div class="print-section-header">
|
||
<h3>Daftar Lengkap Seluruh Sampel Prediksi (${num(details.length)} Data)</h3>
|
||
<p>Seluruh sampel disajikan mengalir ke bawah dengan teks lengkap tanpa pemotongan margin.</p>
|
||
</div>
|
||
|
||
<div class="print-samples-list">
|
||
${details.map(d => `
|
||
<div class="print-sample-card ${d.is_correct ? 'ok' : 'err'}">
|
||
<div class="psc-header">
|
||
<span class="psc-no">#${d.index}</span>
|
||
<div class="psc-badges">
|
||
<span>Aktual: <b>${d.actual_label}</b></span>
|
||
<span class="psc-arrow">→</span>
|
||
<span>Prediksi: <b>${d.predicted_label}</b></span>
|
||
<span class="psc-status ${d.is_correct ? 'ok' : 'err'}">
|
||
${d.is_correct ? '✅ Sesuai' : '❌ Galat'} (${d.matrix_type})
|
||
</span>
|
||
<span class="psc-conf">Keyakinan: <b>${d.confidence}%</b></span>
|
||
</div>
|
||
</div>
|
||
<div class="psc-body">
|
||
<div class="psc-row">
|
||
<span class="psc-lbl">Komentar Asli TikTok:</span>
|
||
<div class="psc-val orig">${esc(d.original_comment)}</div>
|
||
</div>
|
||
<div class="psc-row">
|
||
<span class="psc-lbl">Hasil Preprocessing (NLP):</span>
|
||
<div class="psc-val prep">${d.cleaned_comment ? esc(d.cleaned_comment) : '<i style="color:#666">- (hanya emote / simbol)</i>'}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Print & Export Handlers
|
||
$('#btnPrintReport').onclick = () => window.print();
|
||
$('#btnExportCsv').onclick = () => downloadEvaluationResultsCsv(details, s);
|
||
|
||
// Table Filtering, Display Mode & Pagination
|
||
let activeFilter = 'ALL';
|
||
let searchTerm = '';
|
||
let currentPage = 1;
|
||
let currentDisplayMode = 'table';
|
||
const pageSize = 25;
|
||
|
||
const btnModeTable = $('#btnModeTable');
|
||
const btnModeCards = $('#btnModeCards');
|
||
if (btnModeTable && btnModeCards) {
|
||
btnModeTable.onclick = () => {
|
||
currentDisplayMode = 'table';
|
||
btnModeTable.classList.add('on');
|
||
btnModeCards.classList.remove('on');
|
||
renderDetailsTable();
|
||
};
|
||
btnModeCards.onclick = () => {
|
||
currentDisplayMode = 'cards';
|
||
btnModeCards.classList.add('on');
|
||
btnModeTable.classList.remove('on');
|
||
renderDetailsTable();
|
||
};
|
||
}
|
||
|
||
function filterDetails() {
|
||
return details.filter(d => {
|
||
// Filter category
|
||
let matchCat = true;
|
||
if (activeFilter === 'CORRECT') matchCat = d.is_correct;
|
||
else if (activeFilter === 'ERROR') matchCat = !d.is_correct;
|
||
else if (activeFilter === 'TP') matchCat = d.matrix_type === 'TP';
|
||
else if (activeFilter === 'TN') matchCat = d.matrix_type === 'TN';
|
||
else if (activeFilter === 'FP') matchCat = d.matrix_type === 'FP';
|
||
else if (activeFilter === 'FN') matchCat = d.matrix_type === 'FN';
|
||
|
||
if (!matchCat) return false;
|
||
|
||
// Filter search
|
||
if (searchTerm) {
|
||
const text = (d.original_comment + ' ' + (d.cleaned_comment || '')).toLowerCase();
|
||
if (!text.includes(searchTerm.toLowerCase())) return false;
|
||
}
|
||
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function renderDetailsTable() {
|
||
const filtered = filterDetails();
|
||
const totalFiltered = filtered.length;
|
||
const totalPages = Math.max(1, Math.ceil(totalFiltered / pageSize));
|
||
if (currentPage > totalPages) currentPage = totalPages;
|
||
|
||
const startIdx = (currentPage - 1) * pageSize;
|
||
const pageItems = filtered.slice(startIdx, startIdx + pageSize);
|
||
|
||
const tblContent = $('#evalDetailTableContent');
|
||
if (!pageItems.length) {
|
||
tblContent.innerHTML = '<div class="empty">Tidak ada sampel komentar yang cocok dengan filter atau kata pencarian.</div>';
|
||
$('#evalDetailPager').innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
if (currentDisplayMode === 'cards') {
|
||
tblContent.innerHTML = `
|
||
<div class="stream-cards-list">
|
||
${pageItems.map((d, i) => `
|
||
<div class="sample-stream-card ${d.is_correct ? 'ok' : 'err'}">
|
||
<div class="ssc-header">
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<span style="font-weight:800;font-size:14px;color:var(--ink)">#${startIdx + i + 1}</span>
|
||
<span class="status-badge ${d.predicted_label === 'FITUR TIDAK DITEMUKAN' ? 'oov' : (d.predicted_label === 'GAGAL DIANALISIS' ? 'gagal' : (d.is_correct ? 'ok' : 'err'))}">
|
||
${d.predicted_label === 'FITUR TIDAK DITEMUKAN' ? 'ℹ️ Fitur Tidak Ditemukan' : (d.predicted_label === 'GAGAL DIANALISIS' ? '⚠️ Gagal Dianalisis' : (d.is_correct ? '✅ Sesuai' : '❌ Galat'))}
|
||
</span>
|
||
<span class="matrix-tag ${d.matrix_type}">${d.matrix_type}</span>
|
||
</div>
|
||
<div class="ssc-badges">
|
||
<span>Aktual: <span class="sent-badge ${d.actual_label}">${d.actual_label}</span></span>
|
||
<span style="color:var(--muted)">→</span>
|
||
<span>Prediksi: <span class="sent-badge ${d.predicted_label === 'FITUR TIDAK DITEMUKAN' ? 'FITUR' : (d.predicted_label === 'GAGAL DIANALISIS' ? 'GAGAL' : d.predicted_label)}">${d.predicted_label}</span></span>
|
||
<span style="font-family:var(--font-mono);font-weight:700;color:${(d.predicted_label === 'GAGAL DIANALISIS' || d.predicted_label === 'FITUR TIDAK DITEMUKAN') ? '#c084fc' : (d.is_correct ? '#34d399' : '#fb7185')}">
|
||
Keyakinan: ${(d.predicted_label === 'GAGAL DIANALISIS' || d.predicted_label === 'FITUR TIDAK DITEMUKAN') ? '-' : d.confidence + '%'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="ssc-body">
|
||
<div class="ssc-row">
|
||
<span class="ssc-label">Komentar Asli TikTok:</span>
|
||
<div class="ssc-text">${esc(d.original_comment)}</div>
|
||
</div>
|
||
<div class="ssc-row">
|
||
<span class="ssc-label">Hasil Preprocessing NLP:</span>
|
||
<div class="ssc-text clean">
|
||
${d.cleaned_comment && d.cleaned_comment.trim() ? `
|
||
<div>${esc(d.cleaned_comment)}</div>
|
||
${d.keterangan === 'Fitur tidak ditemukan' || d.fitur_ditemukan === false ? `
|
||
<div style="color:#fbbf24;font-size:11.5px;margin-top:3px;font-style:italic">⚠️ Keterangan: Fitur tidak ditemukan</div>
|
||
` : ''}
|
||
` : '<span style="color:#fbbf24;font-style:italic">⚠️ (Hasil preprocessing kosong)</span>'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
} else {
|
||
tblContent.innerHTML = `
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th style="width:44px">#</th>
|
||
<th>Komentar Asli</th>
|
||
<th>Hasil Preprocessing NLP</th>
|
||
<th style="width:105px;text-align:center">Aktual</th>
|
||
<th style="width:105px;text-align:center">Prediksi</th>
|
||
<th style="width:130px;text-align:center">Status Evaluasi</th>
|
||
<th style="width:90px;text-align:right">Keyakinan</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${pageItems.map((d, i) => `
|
||
<tr>
|
||
<td class="no">${startIdx + i + 1}</td>
|
||
<td class="t">${esc(d.original_comment)}</td>
|
||
<td class="cleaned-cell">
|
||
${d.cleaned_comment && d.cleaned_comment.trim() ? `
|
||
<div>${esc(d.cleaned_comment)}</div>
|
||
${d.keterangan === 'Fitur tidak ditemukan' || d.fitur_ditemukan === false ? `
|
||
<div style="color:#fbbf24;font-size:11px;margin-top:2px;font-style:italic">⚠️ Keterangan: Fitur tidak ditemukan</div>
|
||
` : ''}
|
||
` : '<span style="color:#fbbf24;font-style:italic">⚠️ (Preprocessing kosong)</span>'}
|
||
</td>
|
||
<td style="text-align:center"><span class="sent-badge ${d.actual_label}">${d.actual_label}</span></td>
|
||
<td style="text-align:center"><span class="sent-badge ${d.predicted_label === 'FITUR TIDAK DITEMUKAN' ? 'FITUR' : (d.predicted_label === 'GAGAL DIANALISIS' ? 'GAGAL' : d.predicted_label)}">${d.predicted_label}</span></td>
|
||
<td style="text-align:center">
|
||
<span class="status-badge ${d.predicted_label === 'FITUR TIDAK DITEMUKAN' ? 'oov' : (d.predicted_label === 'GAGAL DIANALISIS' ? 'gagal' : (d.is_correct ? 'ok' : 'err'))}">
|
||
${d.predicted_label === 'FITUR TIDAK DITEMUKAN' ? 'ℹ️ Fitur Tdk Ditemukan' : (d.predicted_label === 'GAGAL DIANALISIS' ? '⚠️ Gagal' : (d.is_correct ? '✅ Sesuai' : '❌ Galat'))}
|
||
</span>
|
||
<span class="matrix-tag ${d.matrix_type}">${d.matrix_type}</span>
|
||
</td>
|
||
<td style="text-align:right;font-family:var(--font-mono);font-size:12.5px;color:${(d.predicted_label === 'GAGAL DIANALISIS' || d.predicted_label === 'FITUR TIDAK DITEMUKAN') ? '#c084fc' : (d.is_correct ? '#34d399' : '#fb7185')}">
|
||
${(d.predicted_label === 'GAGAL DIANALISIS' || d.predicted_label === 'FITUR TIDAK DITEMUKAN') ? '-' : d.confidence + '%'}
|
||
</td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
}
|
||
|
||
$('#evalDetailPager').innerHTML = pager(currentPage, totalPages);
|
||
$('#evalDetailPager').querySelectorAll('[data-p]').forEach(btn => {
|
||
btn.onclick = () => {
|
||
currentPage = +btn.dataset.p;
|
||
renderDetailsTable();
|
||
tblContent.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
};
|
||
});
|
||
}
|
||
|
||
// Filter Buttons Event
|
||
$('#evalDetailFilters').onclick = e => {
|
||
const btn = e.target.closest('[data-filter]');
|
||
if (!btn) return;
|
||
activeFilter = btn.dataset.filter;
|
||
currentPage = 1;
|
||
document.querySelectorAll('#evalDetailFilters .chip').forEach(b => b.classList.toggle('on', b === btn));
|
||
renderDetailsTable();
|
||
};
|
||
|
||
// Search Input Event
|
||
$('#searchEvalInput').oninput = e => {
|
||
searchTerm = e.target.value.trim();
|
||
currentPage = 1;
|
||
renderDetailsTable();
|
||
};
|
||
|
||
renderDetailsTable();
|
||
}
|
||
|
||
// Inisialisasi Routing saat aplikasi dimuat
|
||
route(); |