TIF_E41253394/frontend/app.js

1967 lines
89 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const $ = (s, r = document) => r.querySelector(s);
const view = $('#app');
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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}">&larr; 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 &rarr;</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">
&larr; 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> &bull; Sumber: <b>${esc(sourceTitle || 'Video TikTok')}</b> &bull; Total Data Uji: <b>${num(s.total_samples)} Komentar</b> &bull; 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> &bull; 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 &times; 2 Grid</span>
</div>
<div class="cm-container">
<div class="cm-top-header">Kelas Prediksi (Predicted Class) &rarr;</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 &times; (Prec &times; 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">&rarr;</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)">&rarr;</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();