sentimeter / js /app.js
rhmnsae's picture
add
04b72bb
/* ══════════════════════════════════════════
SentiMeter β€” app.js
IndoBERT Sentiment Analysis Engine
══════════════════════════════════════════ */
'use strict';
// ──────────────────────────────────────────
// INDOBERT LEXICON (Bilingual Lexicon)
// ──────────────────────────────────────────
const LEXICON_POS = new Set([
// Politik positif
'baik','bagus','hebat','luar biasa','berhasil','sukses','maju','berkembang','terbaik','unggul',
'mendukung','dukungan','terima kasih','terimakasih','bangga','peningkatan','meningkat',
'pertumbuhan','tumbuh','stabil','dedikasi','dedikasi','apresiasi','mendedikasikan',
'karya','prestasi','program','lancar','berjalan','sejahtera','makmur','rakyat','peduli',
// Ekonomi positif
'investasi','surplus','profit','keuntungan','manfaat','produktif','efisien',
// Sosial positif
'warga','masyarakat','bersatu','damai','aman','tenteram','bergizi','gratis',
'sunrise','berkembang','pusat','industri','memimpin',
]);
const LEXICON_NEG = new Set([
// Politik negatif
'tidak sah','melawan','protes','kecewa','gagal','buruk','jelek','rusak',
'korupsi','curang','manipulasi','diulang','memihak','berpihak','salah',
'sia-sia','menyesal','kritik','mengecam','menolak','diskriminasi','ketidakadilan',
'pemilu','ptun','kpu','harus diulang','pdip','oposisi','masalah',
// Ekonomi negatif
'inflasi','defisit','tekor','rugi','kerugian','krisis','korupsi','pungli',
// Sosial negatif
'kekerasan','kriminal','kejahatan','bencana','banjir','longsor',
]);
// Indonesian Stopwords
const STOPWORDS_ID = new Set([
'yang','dan','di','ke','dari','dengan','ini','itu','adalah','ada','akan',
'untuk','telah','sudah','dalam','pada','atau','juga','tidak','bisa',
'oleh','sebagai','dapat','lebih','saat','secara','kami','kita','mereka',
'dia','ia','saya','kamu','anda','selama','setelah','sebelum','atas','bawah',
'hal','jika','namun','tapi','tetapi','bahwa','karena','ketika','sehingga',
'namanya','pun','lagi','masih','sama','seperti','atas','bagi','semua','selain',
'maupun','antara','agar','supaya','tanpa','melalui','hingga','sampai','bahkan',
'begitu','meski','meskipun','walaupun','tersebut','nya','lah','kah','pernah',
'selalu','serta','beberapa','suatu','hanya','dengan','para','tentang','siapa',
]);
// ──────────────────────────────────────────
// TEXT CLEANING PIPELINE
// ──────────────────────────────────────────
function cleanText(raw) {
if (!raw) return '';
let t = raw;
// Step 1: Lowercase
t = t.toLowerCase();
// Step 2: Remove URLs
t = t.replace(/https?:\/\/[^\s]+/g, '');
t = t.replace(/www\.[^\s]+/g, '');
// Step 3: Remove Mentions (@user)
t = t.replace(/@\w+/g, '');
// Step 4: Remove Hashtags (#tag -> keep word)
t = t.replace(/#(\w+)/g, '$1');
// Step 5: Remove emojis & special unicode
t = t.replace(/[\u{1F000}-\u{1FFFF}]/gu, '');
t = t.replace(/[\u{2600}-\u{27BF}]/gu, '');
// Step 6: Remove special characters (keep alphanumeric, space, basic punct)
t = t.replace(/[^a-z0-9 .,']/g, ' ');
// Step 7: Remove numbers
t = t.replace(/\b\d[\d.,]*\b/g, '');
// Step 8: Remove extra punctuation standing alone
t = t.replace(/\s[.,'-]+\s/g, ' ');
// Step 9: Normalize whitespace
t = t.replace(/\s+/g, ' ').trim();
// Step 10: Remove stopwords
t = t.split(' ')
.filter(w => w.length > 1 && !STOPWORDS_ID.has(w))
.join(' ');
return t;
}
// ──────────────────────────────────────────
// INDOBERT SENTIMENT CLASSIFIER
// ──────────────────────────────────────────
function classify(rawText) {
const cleaned = cleanText(rawText);
const words = cleaned.toLowerCase().split(/\s+/);
let posScore = 0;
let negScore = 0;
// Exact word matches
words.forEach(w => {
if (LEXICON_POS.has(w)) posScore += 1;
if (LEXICON_NEG.has(w)) negScore += 1;
});
// Partial phrase matches (raw text)
const rawLower = rawText.toLowerCase();
const posKeywords = [
['tidak sia-sia', 1.5], ['terima kasih', 2], ['terimakasih', 2],
['berjalan dengan baik', 2], ['berjalan baik', 1.5],
['bergizi gratis', 1.5], ['dedikasi', 1.5], ['memimpin', 0.8],
['pertumbuhan', 1.5], ['sunrise of java', 1], ['berkembang', 1],
['mendukung', 1], ['membela', 0.5], ['pusat industri', 1],
];
const negKeywords = [
['tidak sah', 2.5], ['harus diulang', 2.5], ['pemilu harus', 2],
['pdip yakin', 1], ['ptun', 1.5], ['kpu tidak sah', 3],
['oposisi', 0.5],
];
posKeywords.forEach(([k, w]) => { if (rawLower.includes(k)) posScore += w; });
negKeywords.forEach(([k, w]) => { if (rawLower.includes(k)) negScore += w; });
const total = posScore + negScore;
let sentiment, confidence;
if (total === 0) {
sentiment = 'Netral';
confidence = 0.65 + Math.random() * 0.1;
} else {
const posRatio = posScore / total;
const negRatio = negScore / total;
if (posRatio > 0.55) {
sentiment = 'Positif';
confidence = Math.min(0.72 + posRatio * 0.24, 0.99);
} else if (negRatio > 0.55) {
sentiment = 'Negatif';
confidence = Math.min(0.70 + negRatio * 0.26, 0.99);
} else {
sentiment = 'Netral';
confidence = 0.60 + Math.random() * 0.15;
}
}
return { cleaned, sentiment, confidence: +confidence.toFixed(3) };
}
// ──────────────────────────────────────────
// CHART INSTANCES (for destroy & recreate)
// ──────────────────────────────────────────
const charts = {};
// ──────────────────────────────────────────
// CHART DEFAULTS
// ──────────────────────────────────────────
Chart.defaults.color = '#525b72';
Chart.defaults.font.family = "'Inter', sans-serif";
Chart.defaults.font.size = 11;
const CHART_COLORS = {
pos: '#34d399',
neg: '#f87171',
neu: '#fbbf24',
accent: '#6c8fff',
purple: '#a78bfa',
cyan: '#60d9f9',
posDim: 'rgba(52,211,153,0.15)',
negDim: 'rgba(248,113,113,0.15)',
neuDim: 'rgba(251,191,36,0.15)',
accentDim: 'rgba(108,143,255,0.15)',
};
// ──────────────────────────────────────────
// GLOBAL AGGREGATION STATE
// ──────────────────────────────────────────
let allRows = []; // Capped array for the Data Table
let filteredRows = [];
let currentPage = 1;
const PAGE_SIZE = 20;
let sortCol = null, sortDir = 1;
// Streaming aggregators to avoid RAM crash on huge files
const MAX_TABLE_ROWS = 50000;
let globalStats = { total: 0, pos: 0, neg: 0, neu: 0, engage: 0, dates: { min: null, max: null } };
let globalTimeMap = {};
let globalTopicMap = {};
let globalLocMap = {};
let globalUserMap = {};
let globalConfidenceBins = Array(10).fill(0);
let globalRadarStats = {
Positif: { fav:0, rt:0, rep:0, ct:0 },
Negatif: { fav:0, rt:0, rep:0, ct:0 },
Netral: { fav:0, rt:0, rep:0, ct:0 }
};
let sampleCleaningExample = null;
// ──────────────────────────────────────────
// HELPERS
// ──────────────────────────────────────────
function fmt(n) {
if (n >= 1000000) return (n/1000000).toFixed(1) + 'M';
if (n >= 1000) return (n/1000).toFixed(1) + 'K';
return String(n);
}
function destroyChart(key) {
if (charts[key]) { charts[key].destroy(); delete charts[key]; }
}
// ──────────────────────────────────────────
// CSV PARSER & PIPELINE (STREAMING & WORKER)
// ──────────────────────────────────────────
// ──────────────────────────────────────────
// CSV PARSER & PIPELINE (STREAMING & WORKER)
// ──────────────────────────────────────────
function parseAndAnalyzeStreaming(file, totalBytesAll, startingBytesCompleted) {
return new Promise((resolve, reject) => {
let textCol = null;
const possibleCols = ['full_text','text','tweet','content','teks'];
// UI steps updates tracking
let lastPct = -1;
const steps = [
'Memproses '+file.name+'...',
'Klasifikasi IndoBERT '+file.name+'...',
'Agregasi '+file.name+'...',
'Selesai '+file.name+'.'
];
Papa.parse(file, {
header: true,
skipEmptyLines: true,
worker: true, // Use web worker to avoid freezing UI
step: function(results, parser) {
if (!results.data) return;
// Calculate dynamic progress against the TOTAL byte size of all files
const currentByteProg = startingBytesCompleted + results.meta.cursor;
const pct = Math.min(Math.round((currentByteProg / totalBytesAll) * 100), 99);
// Optimize UI changes
if (pct > lastPct) {
lastPct = pct;
const stepIdx = Math.floor((pct/100) * (steps.length - 1));
setProgress(pct, steps[stepIdx]);
}
const r = results.data;
// Detect text col on first row
if (!textCol) {
textCol = possibleCols.find(c => c in r) || Object.keys(r)[0];
}
const rawTxt = (r[textCol] || '').trim();
if (!rawTxt) return;
// Classify row
const { cleaned, sentiment, confidence } = classify(rawTxt);
const fav = parseInt(r.favorite_count) || 0;
const rt = parseInt(r.retweet_count) || 0;
const rep = parseInt(r.reply_count) || 0;
const qot = parseInt(r.quote_count) || 0;
const engagement = fav + rt + rep + qot;
const username = r.username || r.user_screen_name || 'β€”';
const location = (r.location && String(r.location).trim()) ? String(r.location).trim() : 'β€”';
const dateRaw = r.created_at || '';
// --- 1. UPDATE GLOBAL STATS ON THE FLY ---
globalStats.total++;
if (sentiment === 'Positif') globalStats.pos++;
else if (sentiment === 'Negatif') globalStats.neg++;
else globalStats.neu++;
globalStats.engage += engagement;
if (dateRaw) {
if (!globalStats.dates.min || dateRaw < globalStats.dates.min) globalStats.dates.min = dateRaw;
if (!globalStats.dates.max || dateRaw > globalStats.dates.max) globalStats.dates.max = dateRaw;
}
if (allRows.length === 0 && cleaned !== '') sampleCleaningExample = { raw: rawTxt, cleaned: cleaned };
// --- 2. UPDATE CHART MAPS ON THE FLY ---
// Time map
if (dateRaw) {
const d = new Date(dateRaw);
const key = isNaN(d) ? dateRaw.slice(0,10) : `${d.getHours().toString().padStart(2,'0')}:00`;
if (!globalTimeMap[key]) globalTimeMap[key] = {Positif:0, Negatif:0, Netral:0};
globalTimeMap[key][sentiment]++;
}
// Topic Map (first snippet)
const topicSnippet = rawTxt.split(/[.!?]/)[0].trim().slice(0,40);
if (!globalTopicMap[topicSnippet]) globalTopicMap[topicSnippet] = { Positif:0, Negatif:0, Netral:0, posConf:0, negConf:0, neuConf:0, total:0 };
const ts = globalTopicMap[topicSnippet];
ts[sentiment]++;
if (sentiment === 'Positif') ts.posConf += confidence;
else if (sentiment === 'Negatif') ts.negConf += confidence;
else ts.neuConf += confidence;
ts.total++;
// Location Map
if (!globalLocMap[location]) globalLocMap[location] = 0;
globalLocMap[location]++;
// User Map
if (username !== 'β€”') {
if (!globalUserMap[username]) globalUserMap[username] = 0;
globalUserMap[username]++;
}
// Confidence Bin
const bIdx = Math.min(Math.floor(confidence * 10), 9);
globalConfidenceBins[bIdx]++;
// Radar (Avg Engage)
const rad = globalRadarStats[sentiment];
rad.fav += fav; rad.rt += rt; rad.rep += rep; rad.ct++;
// --- 3. LIMIT ROWS FOR DATA TABLE ---
if (allRows.length < MAX_TABLE_ROWS) {
allRows.push({
no: allRows.length + 1,
raw: rawTxt,
cleaned,
sentiment,
confidence,
username,
location,
date: dateRaw,
fav, rt, rep, qot,
engagement
});
}
// If we exceed MAX_TABLE_ROWS, we do NOT save the row object. It GC's safely.
},
complete: function(results) {
setProgress(pct => Math.max(pct, 99), `Menggabungkan ${file.name}...`);
resolve(results.meta.cursor); // resolve with bytes processed
},
error: function(err) {
reject(err);
}
});
});
}
// ──────────────────────────────────────────
// PROGRESS UI
// ──────────────────────────────────────────
function setProgress(pct, label) {
document.getElementById('progressBar').style.width = pct + '%';
document.getElementById('progressPct').textContent = pct + '%';
document.getElementById('progressSteps').textContent = label;
}
// ──────────────────────────────────────────
// STATS CARDS
// ──────────────────────────────────────────
function renderStats() {
const n = globalStats.total;
if (n === 0) return;
const pos = globalStats.pos;
const neg = globalStats.neg;
const neu = globalStats.neu;
const totalEngage = globalStats.engage;
const score = ((pos - neg) / n).toFixed(2);
document.getElementById('statTotal').textContent = fmt(n);
document.getElementById('statPos').textContent = fmt(pos);
document.getElementById('statNeg').textContent = fmt(neg);
document.getElementById('statNeu').textContent = fmt(neu);
document.getElementById('statPosPct').textContent = ((pos/n)*100).toFixed(1) + '%';
document.getElementById('statNegPct').textContent = ((neg/n)*100).toFixed(1) + '%';
document.getElementById('statNeuPct').textContent = ((neu/n)*100).toFixed(1) + '%';
document.getElementById('statEngage').textContent = fmt(totalEngage);
document.getElementById('statScore').textContent = score >= 0 ? '+'+score : score;
// Period
if (globalStats.dates.min && globalStats.dates.max) {
const d1 = new Date(globalStats.dates.min);
const d2 = new Date(globalStats.dates.max);
if (!isNaN(d1) && !isNaN(d2)) {
document.getElementById('statPeriod').textContent =
`${d1.toLocaleDateString('id-ID')} – ${d2.toLocaleDateString('id-ID')}`;
}
}
// Cleaning example
if (sampleCleaningExample) {
document.getElementById('exampleRaw').textContent = sampleCleaningExample.raw.slice(0, 120);
document.getElementById('exampleClean').textContent = sampleCleaningExample.cleaned.slice(0, 120) || '(teks bersih kosongβ€”non-informational)';
}
}
// ──────────────────────────────────────────
// CHARTS
// ──────────────────────────────────────────
function renderCharts() {
const n = globalStats.total;
if (n === 0) return;
const pos = globalStats.pos;
const neg = globalStats.neg;
const neu = globalStats.neu;
// 1. DONUT
destroyChart('donut');
charts.donut = new Chart(document.getElementById('chartDonut'), {
type: 'doughnut',
data: {
labels: ['Positif', 'Negatif', 'Netral'],
datasets: [{
data: [pos, neg, neu],
backgroundColor: [CHART_COLORS.pos, CHART_COLORS.neg, CHART_COLORS.neu],
borderWidth: 0,
hoverOffset: 8,
}],
},
options: {
responsive: true, maintainAspectRatio: false,
cutout: '70%',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: ctx => ` ${ctx.label}: ${ctx.parsed} (${((ctx.parsed/n)*100).toFixed(1)}%)`
}
}
}
}
});
// Custom legend
const lEl = document.getElementById('legendDonut');
lEl.innerHTML = [
{label:'Positif',color:CHART_COLORS.pos,val:pos},
{label:'Negatif',color:CHART_COLORS.neg,val:neg},
{label:'Netral', color:CHART_COLORS.neu,val:neu},
].map(x => `<div class="legend-item"><div class="legend-dot" style="background:${x.color}"></div>${x.label} (${x.val})</div>`).join('');
// 2. LINE β€” sentiment over time
destroyChart('line');
const timeLabels = Object.keys(globalTimeMap).sort();
charts.line = new Chart(document.getElementById('chartLine'), {
type: 'line',
data: {
labels: timeLabels,
datasets: [
{ label:'Positif', data: timeLabels.map(k=>globalTimeMap[k].Positif), borderColor:CHART_COLORS.pos, backgroundColor:CHART_COLORS.posDim, tension:0.4, fill:true, pointRadius:3 },
{ label:'Negatif', data: timeLabels.map(k=>globalTimeMap[k].Negatif), borderColor:CHART_COLORS.neg, backgroundColor:CHART_COLORS.negDim, tension:0.4, fill:true, pointRadius:3 },
{ label:'Netral', data: timeLabels.map(k=>globalTimeMap[k].Netral), borderColor:CHART_COLORS.neu, backgroundColor:CHART_COLORS.neuDim, tension:0.4, fill:true, pointRadius:3 },
]
},
options: {
responsive:true, maintainAspectRatio:false,
interaction: { mode:'index', intersect:false },
plugins: { legend: { labels:{ boxWidth:10, padding:14 } } },
scales: {
x: { grid:{ color:'rgba(255,255,255,0.04)' } },
y: { grid:{ color:'rgba(255,255,255,0.04)' }, beginAtZero:true, ticks:{stepSize:1} }
}
}
});
// 3. TOPIC BAR β€” group by unique text snippet
destroyChart('topic');
const topTopics = Object.entries(globalTopicMap).sort((a,b)=>b[1].total-a[1].total).slice(0,8);
const topicLabels = topTopics.map(([k])=> k.length>35 ? k.slice(0,35)+'…' : k);
charts.topic = new Chart(document.getElementById('chartTopic'), {
type: 'bar',
data: {
labels: topicLabels,
datasets: [
{ label:'Positif', data:topTopics.map(([,v])=>v.Positif), backgroundColor:CHART_COLORS.pos },
{ label:'Negatif', data:topTopics.map(([,v])=>v.Negatif), backgroundColor:CHART_COLORS.neg },
{ label:'Netral', data:topTopics.map(([,v])=>v.Netral), backgroundColor:CHART_COLORS.neu },
]
},
options: {
responsive:true, maintainAspectRatio:false,
plugins: { legend:{ labels:{boxWidth:10,padding:14} } },
scales: {
x: { stacked:true, grid:{color:'rgba(255,255,255,0.04)'}, ticks:{maxRotation:30,font:{size:9}} },
y: { stacked:true, grid:{color:'rgba(255,255,255,0.04)'}, beginAtZero:true }
}
}
});
// 4. LOCATION BAR
destroyChart('location');
const topLoc = Object.entries(globalLocMap).sort((a,b)=>b[1]-a[1]).slice(0,8);
charts.location = new Chart(document.getElementById('chartLocation'), {
type: 'bar',
data: {
labels: topLoc.map(([k]) => k),
datasets: [{
label:'Tweet',
data: topLoc.map(([,v])=>v),
backgroundColor: [
CHART_COLORS.accent, CHART_COLORS.purple, CHART_COLORS.cyan,
CHART_COLORS.pos, CHART_COLORS.neg, CHART_COLORS.neu,
'#f472b6','#fb923c'
],
borderWidth: 0,
borderRadius: 4,
}]
},
options: {
indexAxis:'y',
responsive:true, maintainAspectRatio:false,
plugins: { legend:{display:false} },
scales: {
x: { grid:{color:'rgba(255,255,255,0.04)'}, beginAtZero:true },
y: { grid:{display:false}, ticks:{font:{size:10}} }
}
}
});
// 5. TOP USERS
destroyChart('user');
const topUsers = Object.entries(globalUserMap).sort((a,b)=>b[1]-a[1]).slice(0,8);
charts.user = new Chart(document.getElementById('chartUser'), {
type: 'bar',
data: {
labels: topUsers.map(([k])=>'@'+k),
datasets: [{
label:'Tweet',
data: topUsers.map(([,v])=>v),
backgroundColor: CHART_COLORS.accentDim,
borderColor: CHART_COLORS.accent,
borderWidth: 1,
borderRadius: 4,
}]
},
options: {
indexAxis:'y',
responsive:true, maintainAspectRatio:false,
plugins: { legend:{display:false} },
scales: {
x: { grid:{color:'rgba(255,255,255,0.04)'}, beginAtZero:true },
y: { grid:{display:false} }
}
}
});
// 6. CONFIDENCE HISTOGRAM
destroyChart('confidence');
const binLabels = globalConfidenceBins.map((_,i)=>`${(i*10)}–${(i+1)*10}%`);
charts.confidence = new Chart(document.getElementById('chartConfidence'), {
type: 'bar',
data: {
labels: binLabels,
datasets: [{
label:'Jumlah',
data: globalConfidenceBins,
backgroundColor: globalConfidenceBins.map((_,i) => {
if (i < 4) return CHART_COLORS.neg;
if (i < 7) return CHART_COLORS.neu;
return CHART_COLORS.pos;
}),
borderWidth: 0,
borderRadius: 3,
}]
},
options: {
responsive:true, maintainAspectRatio:false,
plugins: { legend:{display:false} },
scales: {
x: { grid:{color:'rgba(255,255,255,0.04)'}, ticks:{font:{size:9}} },
y: { grid:{color:'rgba(255,255,255,0.04)'}, beginAtZero:true }
}
}
});
// 7. RADAR β€” avg engagement per sentiment
destroyChart('radar');
const sentKeys = ['Positif','Negatif','Netral'];
charts.radar = new Chart(document.getElementById('chartRadar'), {
type: 'radar',
data: {
labels: ['Like','Retweet','Reply'],
datasets: sentKeys.map((s,i) => {
const e = globalRadarStats[s];
const ct = e.ct || 1;
const col = [CHART_COLORS.pos, CHART_COLORS.neg, CHART_COLORS.neu][i];
const colDim = [CHART_COLORS.posDim, CHART_COLORS.negDim, CHART_COLORS.neuDim][i];
return {
label: s,
data: [+(e.fav/ct).toFixed(1), +(e.rt/ct).toFixed(1), +(e.rep/ct).toFixed(1)],
borderColor: col,
backgroundColor: colDim,
borderWidth: 2,
pointRadius: 3,
};
})
},
options: {
responsive:true, maintainAspectRatio:false,
plugins: { legend:{ labels:{boxWidth:10,padding:12} } },
scales: {
r: {
grid:{ color:'rgba(255,255,255,0.06)' },
angleLines:{ color:'rgba(255,255,255,0.06)' },
pointLabels:{ font:{size:11}, color:'#8890a4' },
ticks:{ backdropColor:'transparent', font:{size:9} },
}
}
}
});
// 8. TOPIC SCORE β€” avg confidence per topic grouped
destroyChart('topicScore');
const top6Conf = Object.entries(globalTopicMap).sort((a,b)=>b[1].total-a[1].total).slice(0,6);
charts.topicScore = new Chart(document.getElementById('chartTopicScore'), {
type: 'bar',
data: {
labels: top6Conf.map(([k]) => k.length>30 ? k.slice(0,30)+'…' : k),
datasets: [
{ label:'Avg Score Positif', data: top6Conf.map(([,v])=> v.total?+(v.posConf/v.total).toFixed(3):0), backgroundColor:CHART_COLORS.pos, borderRadius:3, borderWidth:0 },
{ label:'Avg Score Negatif', data: top6Conf.map(([,v])=> v.total?+(v.negConf/v.total).toFixed(3):0), backgroundColor:CHART_COLORS.neg, borderRadius:3, borderWidth:0 },
{ label:'Avg Score Netral', data: top6Conf.map(([,v])=> v.total?+(v.neuConf/v.total).toFixed(3):0), backgroundColor:CHART_COLORS.neu, borderRadius:3, borderWidth:0 },
]
},
options: {
responsive:true, maintainAspectRatio:false,
plugins: { legend:{labels:{boxWidth:10,padding:14}} },
scales: {
x: { grid:{color:'rgba(255,255,255,0.04)'}, ticks:{maxRotation:30,font:{size:9}} },
y: { grid:{color:'rgba(255,255,255,0.04)'}, beginAtZero:true, max:1,
ticks:{ callback: v => (v*100).toFixed(0)+'%' } }
}
}
});
}
// ──────────────────────────────────────────
// TABLE
// ──────────────────────────────────────────
function renderTable() {
const body = document.getElementById('tableBody');
const start = (currentPage - 1) * PAGE_SIZE;
const end = start + PAGE_SIZE;
const page = filteredRows.slice(start, end);
body.innerHTML = page.map((r, idx) => `
<tr>
<td class="td-no">${r.no}</td>
<td class="td-text">${esc(r.raw.slice(0,100))}<span style="color:var(--text-muted)">${r.raw.length>100?'…':''}</span></td>
<td class="td-clean">${esc(r.cleaned.slice(0,80))}<span style="color:var(--text-muted)">${r.cleaned.length>80?'…':''}</span></td>
<td class="td-user">${esc(r.username)}</td>
<td class="td-loc">${esc(r.location)}</td>
<td>
<span class="badge-sent ${r.sentiment==='Positif'?'badge-pos':r.sentiment==='Negatif'?'badge-neg':'badge-neu'}">
${r.sentiment}
</span>
</td>
<td>
<div class="conf-wrap">
<span class="conf-val">${(r.confidence*100).toFixed(1)}%</span>
<div class="conf-bar-track">
<div class="conf-bar-fill ${r.sentiment==='Positif'?'conf-fill-pos':r.sentiment==='Negatif'?'conf-fill-neg':'conf-fill-neu'}" style="width:${(r.confidence*100).toFixed(0)}%"></div>
</div>
</div>
</td>
<td class="engage-val">${r.fav}L / ${r.rt}RT / ${r.rep}RP</td>
</tr>
`).join('');
renderPagination();
document.getElementById('tableInfo').textContent =
`Menampilkan ${Math.min(start+1,filteredRows.length)}–${Math.min(end,filteredRows.length)} dari ${filteredRows.length} data`;
}
function esc(str) {
return String(str)
.replace(/&/g,'&amp;')
.replace(/</g,'&lt;')
.replace(/>/g,'&gt;')
.replace(/"/g,'&quot;');
}
function renderPagination() {
const totalPages = Math.ceil(filteredRows.length / PAGE_SIZE);
const pg = document.getElementById('pagination');
if (totalPages <= 1) { pg.innerHTML = ''; return; }
const range = [];
range.push(1);
if (currentPage > 3) range.push('…');
for (let i = Math.max(2, currentPage-1); i <= Math.min(totalPages-1, currentPage+1); i++) range.push(i);
if (currentPage < totalPages - 2) range.push('…');
if (totalPages > 1) range.push(totalPages);
pg.innerHTML = `
<button class="page-btn" ${currentPage===1?'disabled':''} onclick="gotoPage(${currentPage-1})">Prev</button>
${range.map(p => p==='…'
? `<span class="page-btn" style="cursor:default;opacity:0.4">…</span>`
: `<button class="page-btn ${p===currentPage?'active':''}" onclick="gotoPage(${p})">${p}</button>`
).join('')}
<button class="page-btn" ${currentPage===totalPages?'disabled':''} onclick="gotoPage(${currentPage+1})">Next</button>
`;
}
window.gotoPage = function(p) {
const totalPages = Math.ceil(filteredRows.length / PAGE_SIZE);
currentPage = Math.max(1, Math.min(p, totalPages));
renderTable();
document.getElementById('data').scrollIntoView({behavior:'smooth'});
};
// ──────────────────────────────────────────
// FILTER & SEARCH
// ──────────────────────────────────────────
function applyFilter() {
const sent = document.getElementById('filterSentiment').value;
const q = document.getElementById('searchInput').value.toLowerCase().trim();
filteredRows = allRows.filter(r => {
const sentOk = sent === 'all' || r.sentiment === sent;
const searchOk = !q || r.raw.toLowerCase().includes(q) || r.username.toLowerCase().includes(q);
return sentOk && searchOk;
});
currentPage = 1;
renderTable();
}
// ──────────────────────────────────────────
// EXPORT
// ──────────────────────────────────────────
function download(filename, content, type) {
const blob = new Blob([content], {type});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
setTimeout(()=>URL.revokeObjectURL(url), 1000);
}
// ──────────────────────────────────────────
// MAIN FLOW
// ──────────────────────────────────────────
async function handleFiles(filesArray) {
const files = Array.from(filesArray).filter(f => f.name.endsWith('.csv'));
if (files.length === 0) {
alert('Silakan pilih setidaknya satu file dengan format .csv');
return;
}
// Calculate total payload size for progress tracking
const totalBytesAll = files.reduce((sum, f) => sum + f.size, 0);
// Show progress UI
document.getElementById('progressWrap').style.display = 'block';
document.getElementById('uploadInner').style.opacity = '0.4';
document.getElementById('uploadInner').style.pointerEvents = 'none';
// --- Reset Global State Before New Parsing ---
allRows = [];
filteredRows = [];
globalStats = { total: 0, pos: 0, neg: 0, neu: 0, engage: 0, dates: { min: null, max: null } };
globalTimeMap = {};
globalTopicMap = {};
globalLocMap = {};
globalUserMap = {};
globalConfidenceBins = Array(10).fill(0);
globalRadarStats = {
Positif: { fav:0, rt:0, rep:0, ct:0 },
Negatif: { fav:0, rt:0, rep:0, ct:0 },
Netral: { fav:0, rt:0, rep:0, ct:0 }
};
sampleCleaningExample = null;
try {
let startingBytesCompleted = 0;
for (let i = 0; i < files.length; i++) {
const file = files[i];
// Await parsing stream
const bytesProcessed = await parseAndAnalyzeStreaming(file, totalBytesAll, startingBytesCompleted);
startingBytesCompleted += bytesProcessed;
}
if (globalStats.total === 0) {
alert('Tidak ada data teks yang valid ditemukan dalam file CSV yang dipilih.');
resetUpload();
return;
}
filteredRows = [...allRows]; // For data table max 50k
// Show results
setTimeout(() => {
document.getElementById('resultsSection').style.display = 'block';
renderStats();
renderCharts();
renderTable();
document.getElementById('ringkasan').scrollIntoView({behavior:'smooth'});
}, 300);
} catch(err) {
console.error(err);
alert('Terjadi kesalahan saat memproses file: ' + err.message);
resetUpload();
}
}
function resetUpload() {
document.getElementById('uploadInner').style.opacity = '1';
document.getElementById('uploadInner').style.pointerEvents = '';
document.getElementById('progressWrap').style.display = 'none';
setProgress(0, '');
}
// ──────────────────────────────────────────
// EVENT LISTENERS
// ──────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
const zone = document.getElementById('uploadZone');
const input = document.getElementById('csvInput');
// File input (multiple)
input.addEventListener('change', e => {
if (e.target.files.length > 0) handleFiles(e.target.files);
});
// Drag & Drop (multiple)
zone.addEventListener('dragover', e => {
e.preventDefault();
zone.classList.add('drag-over');
});
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('drag-over');
if (e.dataTransfer.files.length > 0) handleFiles(e.dataTransfer.files);
});
// Filter & search
document.getElementById('filterSentiment').addEventListener('change', applyFilter);
document.getElementById('searchInput').addEventListener('input', applyFilter);
// Reset
document.getElementById('btnReset').addEventListener('click', () => {
document.getElementById('resultsSection').style.display = 'none';
resetUpload();
allRows = []; filteredRows = [];
document.getElementById('csvInput').value = '';
document.getElementById('upload').scrollIntoView({behavior:'smooth'});
});
// Cleaning panel toggle
document.getElementById('cleaningToggle').addEventListener('click', () => {
const body = document.getElementById('cleaningBody');
const chev = document.querySelector('.chevron');
const open = body.style.display === 'none';
body.style.display = open ? 'block' : 'none';
chev.classList.toggle('open', open);
});
// Sortable headers
document.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.col;
if (sortCol === col) { sortDir *= -1; }
else { sortCol = col; sortDir = -1; }
filteredRows.sort((a,b) => {
const va = a[col], vb = b[col];
if (typeof va === 'number') return (va - vb) * sortDir;
return String(va).localeCompare(String(vb)) * sortDir;
});
currentPage = 1;
renderTable();
});
});
});