Spaces:
Running
Running
| /* ββββββββββββββββββββββββββββββββββββββββββ | |
| SentiMeter β app.js | |
| IndoBERT Sentiment Analysis Engine | |
| ββββββββββββββββββββββββββββββββββββββββββ */ | |
| ; | |
| // ββββββββββββββββββββββββββββββββββββββββββ | |
| // 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,'&') | |
| .replace(/</g,'<') | |
| .replace(/>/g,'>') | |
| .replace(/"/g,'"'); | |
| } | |
| 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(); | |
| }); | |
| }); | |
| }); | |