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 += `← Prev `;
}
const delta = 2;
const start = Math.max(1, current - delta);
const end = Math.min(total, current + delta);
if (start > 1) {
html += `1 `;
if (start > 2) html += `... `;
}
for (let p = start; p <= end; p++) {
html += `${p} `;
}
if (end < total) {
if (end < total - 1) html += `... `;
html += `${total} `;
}
if (current < total) {
html += `Next → `;
}
html += `Hal. ${current} / ${total} `;
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 = `
${reg ? 'Buat Akun Baru' : 'Selamat Datang'}
${reg ? 'Daftar untuk menyimpan riwayat analisis sentimen Anda.' : 'Masuk untuk mulai menganalisis komentar TikTok.'}
${!reg ? `
Akun Default: admin@tiktok.com (pw: admin123)
Isi Otomatis
` : ''}
${reg ? 'Sudah memiliki akun? Masuk di sini ' : 'Belum memiliki akun? Daftar sekarang '}
`;
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 = `
Mulai Analisis Sentimen Baru
Link Video TikTok
Input Kata Langsung (Mandiri)
`;
// 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 = `
${num(stats.total_analyses)}
${num(stats.total_comments)}
${num(stats.positive)}
${num(stats.negative)}
`;
// Render Recent
const recents = analyses.data || [];
$('#recentList').innerHTML = recents.length ? recents.slice(0, 5).map(a => `
${pct(a.positive_percentage)} /
${pct(a.negative_percentage)}
${a.negative_count > a.positive_count ? '🚨 Cyberbullying' : (a.positive_count > a.negative_count ? '✅ Aman' : '⚖️ Seimbang')}
${num(a.total_comments)} komen
`).join('') : 'Belum ada riwayat analisis. Silakan masukkan data di samping untuk memulai.
';
} 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 = `
Sedang men-scrape ${max_comments} komentar asli TikTok & menganalisis sentimen...
`;
} 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 = `
Sedang memproses ${lineCounter.textContent}...
`;
}
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 = `
${activeTab === 'tab-url' ? 'Mulai Analisis & Scraping Video' : 'Analisis Komentar Mandiri'}
`;
}
};
}
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 = `
Uji Cepat Sentimen (NLP Playground)
Uji kata atau kalimat langsung untuk melihat proses pembersihan teks (preprocessing), prediksi sentimen, dan bobot fitur TF-IDF secara instan.
Masukkan Teks Komentar
Teks Komentar / Kata
Uji dengan Contoh Cepat:
+ Positif Lembut
+ Positif Gaul
+ Negatif Sarkas
+ Negatif Keras
+ OOV (Fitur Tidak Ditemukan)
+ Preprocessing Kosong (Emotikon & Simbol)
Analisis Kata / Kalimat Sekarang
Hasil Analisis Inferensi
Ketik teks di sebelah kiri lalu klik tombol analisis untuk melihat hasil pembacaan model.
POSITIF
Tingkat Keyakinan Model: 0%
Positif: 0%
Negatif: 0%
Hasil Preprocessing (9 Tahapan NLP)
-
Fitur Dominan (Kata Kunci & Bobot Model)
Waktu Inferensi: 0 ms
`;
// 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 = '⚠️ (Hasil preprocessing kosong - teks hanya berisi emotikon, angka, simbol, atau kata henti tanpa makna sentimen) ';
} 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 = '⚠️ Tidak ada fitur kata yang tersisa untuk dihitung TF-IDF karena seluruh token terhapus pada tahap preprocessing. ';
} else if (res.sentimen === 'FITUR TIDAK DITEMUKAN' || res.fitur_ditemukan === false || (!res.fitur_dominan || !res.fitur_dominan.length)) {
featsWrap.innerHTML = `
ℹ️
Keterangan: Fitur Tidak Ditemukan
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.
`;
} else if (res.fitur_dominan && res.fitur_dominan.length) {
featsWrap.innerHTML = res.fitur_dominan.map(f => {
const isPos = f.weight > 0;
return `
${esc(f.token)}
TF-IDF: ${f.tfidf} | W: ${f.weight}
`;
}).join('');
} else {
featsWrap.innerHTML = 'Tidak ada n-gram dominan pada teks ini. ';
}
$('#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 = `
Riwayat Analisis
Daftar seluruh riwayat video dan input komentar yang pernah dianalisis.
+ Analisis Baru
`;
try {
const { data } = await api('/analyses');
const list = $('#histList');
if (!data || !data.length) {
list.innerHTML = 'Belum ada riwayat analisis tersimpan. Mulai dari Dashboard dengan memasukkan link video atau komentar mandiri.
';
return;
}
list.innerHTML = '' + data.map(a => `
${esc(shortUrl(a))}
${tgl(a.created_at)}
${a.status === 'done' || a.status === 'scraped' || a.status === 'manual' ? `
Total Komentar ${num(a.total_comments)}
Sentimen Positif ${num(a.positive_count)} (${pct(a.positive_percentage)})
Sentimen Negatif ${num(a.negative_count)} (${pct(a.negative_percentage)})
${(a.total_comments - a.positive_count - a.negative_count > 0) ? `
Gagal Dianalisis ${num(a.total_comments - a.positive_count - a.negative_count)} (${pct((a.total_comments - a.positive_count - a.negative_count) / a.total_comments * 100)})
` : ''}
${(a.total_comments - a.positive_count - a.negative_count > 0) ? `
` : ''}
Kesimpulan: ${a.negative_count > a.positive_count ? 'Terindikasi Cyberbullying' : (a.positive_count > a.negative_count ? 'Tidak Cyberbullying' : 'Seimbang')}
` : `
${statusText(a.status)} ${esc(a.error || '')}
`}
`).join('') + '
';
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 `
`;
}
async function detailView(id) {
let a;
try {
a = (await api('/analyses/' + id)).data;
} catch (e) {
view.innerHTML = ``;
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 = `
${donutSvg(a.positive_percentage, a.negative_percentage)}
${num(a.total_comments)}
Komentar
${esc(a.tiktok_url)}
${a.status === 'scraped' ? `
🌐 Scraping Komentar Asli TikTok
` : a.video_id === 'manual' || a.status === 'manual' ? `
📝 Komentar Mandiri / Upload
` : `
🧪 Dataset Sampel
`}
Sentimen Positif: ${num(a.positive_count)} (${pct(a.positive_percentage)})
Sentimen Negatif: ${num(a.negative_count)} (${pct(a.negative_percentage)})
${(a.total_comments - a.positive_count - a.negative_count > 0) ? `
Gagal Dianalisis: ${num(a.total_comments - a.positive_count - a.negative_count)} (${pct((a.total_comments - a.positive_count - a.negative_count) / a.total_comments * 100)})
` : ''}
Dianalisis pada: ${tgl(a.created_at)}
${a.error ? `
⚠️ Catatan: ${esc(a.error)}
` : ''}
Sentimen Dominan
${dominantSentimen} ${dominantPct ? `(${pct(dominantPct)})` : ''}
Rasio Positif : Negatif
${num(posCount)} Positif : ${num(negCount)} Negatif
Status Kolom Komentar
${verdictStatus}
📝 Ringkasan Penarikan Kesimpulan (Berdasarkan Hasil Analisis):
${narrativeText}
Daftar Komentar (${num(a.total_comments)})
Semua (${num(a.total_comments)})
Positif (${num(a.positive_count)})
Negatif (${num(a.negative_count)})
${(a.total_comments - a.positive_count - a.negative_count > 0) ? `
Gagal Dianalisis
Fitur Tidak Ditemukan
` : ''}
`;
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 = 'Tidak ada komentar yang cocok dengan filter ini.
';
$('#pagerContent').innerHTML = '';
return;
}
$('#tblContent').innerHTML = `
#
Komentar Asli
Hasil Preprocessing
Sentimen
${res.data.map((c, i) => `
${off + i + 1}
${esc(c.original_comment)}
${c.cleaned_comment && c.cleaned_comment.trim() ? `
${esc(c.cleaned_comment)}
${c.keterangan === 'Fitur tidak ditemukan' || c.fitur_ditemukan === false ? `
⚠️ Keterangan: Fitur tidak ditemukan
` : ''}
` : '⚠️ (Preprocessing kosong / hanya simbol atau emotikon) '}
${c.sentiment}
${(c.sentiment === 'GAGAL DIANALISIS' || c.sentiment === 'FITUR TIDAK DITEMUKAN') ? '-' : pct(c.probability * 100)}
`).join('')}
`;
$('#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 = `${esc(e.message)}
`;
}
}
$('#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 = `
Masukkan Link Video TikTok untuk Pengujian
Sistem akan mengambil komentar nyata dari video TikTok secara otomatis, menjalankan pra-pemrosesan NLP dan prediksi sentimen, lalu mengevaluasi Confusion Matrix.
Link Video TikTok
🎯 Target Jumlah Komentar:
(Bisa ditentukan bebas)
komentar
Opsi Cepat:
30
50
100
150
200
300
-- Atau Pilih Video dari Riwayat Analisis --
Tarik Komentar TikTok & Mulai Pengujian
Verifikasi Komentar & Ground Truth
0 Komentar
-
🔄 Samakan Semua dgn Prediksi
Hitung Confusion Matrix
💡
Panduan Pengujian: Untuk membentuk Confusion Matrix (TP, TN, FP, FN), diperlukan perbandingan antara Prediksi Model dan Label Sebenarnya (Ground Truth) . Sistem telah mengisikan label awal sesuai hasil prediksi model. Anda (Admin/Peneliti) dapat mengubah label komentar jika ada yang keliru, lalu klik tombol Hitung Confusion Matrix di atas.
#
Komentar Asli TikTok
Hasil Preprocessing NLP
Prediksi Model
Label Sebenarnya (Ground Truth)
Menunggu Input Video TikTok
Masukkan tautan video TikTok di atas untuk menarik komentar nyata dan mengevaluasi matriks kebingungan (Confusion Matrix) model secara otomatis.
`;
// --- 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 = `
Sedang mengambil ${maxComments} komentar asli TikTok & menjalankan prediksi NLP...
`;
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 = `
Tarik Komentar TikTok & Mulai Pengujian
`;
}
};
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) => `
${idx + 1}
${esc(c.original_comment)}
${c.cleaned_comment && c.cleaned_comment.trim() ? `
${esc(c.cleaned_comment)}
${c.keterangan === 'Fitur tidak ditemukan' || c.fitur_ditemukan === false ? `
⚠️ Keterangan: Fitur tidak ditemukan
` : ''}
` : '⚠️ (Preprocessing kosong) '}
${c.predicted_label}
${(c.predicted_label === 'GAGAL DIANALISIS' || c.predicted_label === 'FITUR TIDAK DITEMUKAN') ? '-' : c.confidence + '%'}
POSITIF
NEGATIF
`).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 = `
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 = `
Hitung Confusion Matrix
`;
}
};
}
// 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 = `
Hasil Pengujian & Confusion Matrix
Sumber: ${esc(sourceTitle || 'Tautan TikTok')} • Waktu Komputasi: ${res.elapsed_ms || 0} ms
🖨️ Cetak / Simpan PDF
📥 Unduh CSV Hasil Uji
Akurasi (Accuracy)
🎯
${pct(s.accuracy)}
${num(s.correct_count)} dari ${num(s.total_samples)} prediksi tepat
Presisi (Macro)
🔍
${pct(s.macro_precision)}
Positif: ${pct(repPos.precision)} | Negatif: ${pct(repNeg.precision)}
Recall (Sensitivitas)
📡
${pct(s.macro_recall)}
Positif: ${pct(repPos.recall)} | Negatif: ${pct(repNeg.recall)}
F1-Score (Harmonis)
⚖️
${pct(s.macro_f1)}
Positif: ${pct(repPos.f1_score)} | Negatif: ${pct(repNeg.f1_score)}
Total Data Uji
📦
${num(s.total_samples)}
${num(s.correct_count)} Sesuai / ${num(s.error_count)} Galat
Matriks Kebingungan (Confusion Matrix)
2 × 2 Grid
POSITIF
NEGATIF
AKTUAL POSITIF
True Positive (TP)
${num(cm.tp)}
${pct(pctObj.tp || 0)}
Aktual Positif diprediksi Positif
False Negative (FN)
${num(cm.fn)}
${pct(pctObj.fn || 0)}
Aktual Positif diprediksi Negatif (Galat II)
AKTUAL NEGATIF
False Positive (FP)
${num(cm.fp)}
${pct(pctObj.fp || 0)}
Aktual Negatif diprediksi Positif (Galat I)
True Negative (TN)
${num(cm.tn)}
${pct(pctObj.tn || 0)}
Aktual Negatif diprediksi Negatif
Interpretasi & Rumus Matematis
Tabel Classification Report (Standar Scikit-Learn)
Evaluasi Formal
Kelas Evaluasi
Precision
Recall
F1-Score
Support (Sampel)
POSITIF
${pct(repPos.precision)}
${pct(repPos.recall)}
${pct(repPos.f1_score)}
${num(repPos.support)}
NEGATIF
${pct(repNeg.precision)}
${pct(repNeg.recall)}
${pct(repNeg.f1_score)}
${num(repNeg.support)}
Accuracy (Akurasi Keseluruhan)
-
-
${pct(rep.accuracy || s.accuracy)}
${num(s.total_samples)}
Macro Average
${pct(repMacro.precision)}
${pct(repMacro.recall)}
${pct(repMacro.f1_score)}
${num(repMacro.support)}
Semua (${num(details.length)})
✅ Prediksi Benar (${num(s.correct_count)})
❌ Miskalsifikasi (${num(s.error_count)})
TP (${num(cm.tp)})
TN (${num(cm.tn)})
FP (${num(cm.fp)})
FN (${num(cm.fn)})
${details.map(d => `
Komentar Asli TikTok:
${esc(d.original_comment)}
Hasil Preprocessing (NLP):
${d.cleaned_comment ? esc(d.cleaned_comment) : '- (hanya emote / simbol) '}
`).join('')}
`;
// 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 = 'Tidak ada sampel komentar yang cocok dengan filter atau kata pencarian.
';
$('#evalDetailPager').innerHTML = '';
return;
}
if (currentDisplayMode === 'cards') {
tblContent.innerHTML = `
${pageItems.map((d, i) => `
Komentar Asli TikTok:
${esc(d.original_comment)}
Hasil Preprocessing NLP:
${d.cleaned_comment && d.cleaned_comment.trim() ? `
${esc(d.cleaned_comment)}
${d.keterangan === 'Fitur tidak ditemukan' || d.fitur_ditemukan === false ? `
⚠️ Keterangan: Fitur tidak ditemukan
` : ''}
` : '
⚠️ (Hasil preprocessing kosong) '}
`).join('')}
`;
} else {
tblContent.innerHTML = `
#
Komentar Asli
Hasil Preprocessing NLP
Aktual
Prediksi
Status Evaluasi
Keyakinan
${pageItems.map((d, i) => `
${startIdx + i + 1}
${esc(d.original_comment)}
${d.cleaned_comment && d.cleaned_comment.trim() ? `
${esc(d.cleaned_comment)}
${d.keterangan === 'Fitur tidak ditemukan' || d.fitur_ditemukan === false ? `
⚠️ Keterangan: Fitur tidak ditemukan
` : ''}
` : '⚠️ (Preprocessing kosong) '}
${d.actual_label}
${d.predicted_label}
${d.predicted_label === 'FITUR TIDAK DITEMUKAN' ? 'ℹ️ Fitur Tdk Ditemukan' : (d.predicted_label === 'GAGAL DIANALISIS' ? '⚠️ Gagal' : (d.is_correct ? '✅ Sesuai' : '❌ Galat'))}
${d.matrix_type}
${(d.predicted_label === 'GAGAL DIANALISIS' || d.predicted_label === 'FITUR TIDAK DITEMUKAN') ? '-' : d.confidence + '%'}
`).join('')}
`;
}
$('#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();