'use strict'; // ── Guard: stop cleanly if no data ── const _store = SM.loadData(); if (!_store) { window.location.replace('upload'); throw new Error('No data — redirecting'); } SM.injectLayout('nav-analytics'); document.addEventListener('DOMContentLoaded', function () { SM.setChartDefaults(); const { rows, meta } = _store; document.getElementById('topbarMeta').textContent = `${meta.filename} — ${rows.length} tweets`; const n = rows.length; const pos = rows.filter(r => r.sentiment === 'Positif'); const neg = rows.filter(r => r.sentiment === 'Negatif'); const neu = rows.filter(r => r.sentiment === 'Netral'); const {C, gridColor: G} = SM; function safe(id, fn) { try { fn(); } catch(e) { console.error('Chart/section ' + id + ' failed:', e); } } // ── 1. DONUT ── safe('c1', () => { SM.mkChart('c1', { type: 'doughnut', data: { labels: ['Positif','Negatif','Netral'], datasets: [{ data:[pos.length,neg.length,neu.length], backgroundColor:[C.pos,C.neg,C.neu], borderWidth:0, hoverOffset:12 }] }, options: { responsive:true, maintainAspectRatio:false, cutout:'68%', plugins:{ legend:{display:false}, tooltip:{callbacks:{label: c => ` ${c.label}: ${c.parsed} (${((c.parsed/n)*100).toFixed(1)}%)`}}}} }); document.getElementById('legend1').innerHTML = [['Positif',C.pos,pos.length],['Negatif',C.neg,neg.length],['Netral',C.neu,neu.length]] .map(([l,c,v]) => `
${l} (${v})
`).join(''); }); // ── 2. TIME TREND ── safe('c2', () => { const tMap = {}; rows.forEach(r => { if (!r.date) return; const d = new Date(r.date); const k = isNaN(d.getTime()) ? r.date.slice(0,10) : `${String(d.getHours()).padStart(2,'0')}:00`; if (!tMap[k]) tMap[k] = {Positif:0, Negatif:0, Netral:0}; tMap[k][r.sentiment]++; }); const tL = Object.keys(tMap).sort(); SM.mkChart('c2', { type: 'line', data: { labels: tL, datasets: [ {label:'Positif',data:tL.map(k=>tMap[k].Positif),borderColor:C.pos,backgroundColor:C.posDim,tension:.4,fill:true,pointRadius:3}, {label:'Negatif',data:tL.map(k=>tMap[k].Negatif),borderColor:C.neg,backgroundColor:C.negDim,tension:.4,fill:true,pointRadius:3}, {label:'Netral', data:tL.map(k=>tMap[k].Netral), borderColor:C.neu,backgroundColor:C.neuDim,tension:.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:G}}, y:{grid:{color:G},beginAtZero:true} } } }); }); // ── 3. STACKED TOPIC BAR ── safe('c3', () => { const topicMap = {}; rows.forEach(r => { const t = r.raw.split(/[.!?]/)[0].trim().slice(0,45) || 'Lainnya'; if (!topicMap[t]) topicMap[t] = {Positif:0, Negatif:0, Netral:0, tot:0}; topicMap[t][r.sentiment]++; topicMap[t].tot++; }); const topTopics = Object.entries(topicMap).sort((a,b) => b[1].tot-a[1].tot).slice(0,8); SM.mkChart('c3', { type: 'bar', data: { labels: topTopics.map(([k]) => k.length>38 ? k.slice(0,38)+'…' : k), datasets: [ {label:'Positif',data:topTopics.map(([,v])=>v.Positif),backgroundColor:C.pos,borderRadius:3}, {label:'Negatif',data:topTopics.map(([,v])=>v.Negatif),backgroundColor:C.neg,borderRadius:3}, {label:'Netral', data:topTopics.map(([,v])=>v.Netral), backgroundColor:C.neu,borderRadius:3}, ] }, options: { responsive:true, maintainAspectRatio:false, plugins:{legend:{labels:{boxWidth:9,padding:12}}}, scales:{ x:{stacked:true,grid:{color:G},ticks:{maxRotation:30,font:{size:9}}}, y:{stacked:true,grid:{color:G},beginAtZero:true} } } }); }); // ── 4. LOCATION HORIZ ── safe('c4', () => { const locMap = {}; rows.forEach(r => { const l = (r.location||'').trim()||'Tidak Diketahui'; locMap[l] = (locMap[l]||0)+1; }); const topLoc = Object.entries(locMap).sort((a,b) => b[1]-a[1]).slice(0,8); SM.mkChart('c4', { type: 'bar', data: { labels: topLoc.map(([k]) => k), datasets: [{ label:'Tweet', data: topLoc.map(([,v]) => v), backgroundColor: topLoc.map((_,i) => C.palette[i % C.palette.length]), borderWidth:0, borderRadius:4 }] }, options: { indexAxis:'y', responsive:true, maintainAspectRatio:false, plugins:{legend:{display:false}}, scales:{ x:{grid:{color:G},beginAtZero:true}, y:{grid:{display:false}} } } }); }); // ── 5. TOP USERS ── safe('c5', () => { const userMap = {}; rows.forEach(r => { userMap[r.username] = (userMap[r.username]||0)+1; }); const topUsers = Object.entries(userMap).sort((a,b) => b[1]-a[1]).slice(0,8); SM.mkChart('c5', { type: 'bar', data: { labels: topUsers.map(([k]) => '@'+k), datasets: [{ label:'Tweet', data: topUsers.map(([,v]) => v), backgroundColor:C.a1d, borderColor:C.a1, borderWidth:1, borderRadius:4 }] }, options: { indexAxis:'y', responsive:true, maintainAspectRatio:false, plugins:{legend:{display:false}}, scales:{ x:{grid:{color:G},beginAtZero:true}, y:{grid:{display:false}} } } }); }); // ── 6. GROUPED CONFIDENCE per TOPIC ── safe('c8', () => { const ts = {}; rows.forEach(r => { const t = r.raw.split(/[.!?]/)[0].trim().slice(0,35) || 'Lainnya'; if (!ts[t]) ts[t] = {pos:[],neg:[],neu:[]}; if (r.sentiment==='Positif') ts[t].pos.push(r.confidence); else if (r.sentiment==='Negatif') ts[t].neg.push(r.confidence); else ts[t].neu.push(r.confidence); }); const ts6 = Object.entries(ts) .sort((a,b) => (b[1].pos.length+b[1].neg.length+b[1].neu.length)-(a[1].pos.length+a[1].neg.length+a[1].neu.length)) .slice(0,6); SM.mkChart('c8', { type: 'bar', data: { labels: ts6.map(([k]) => k.length>28 ? k.slice(0,28)+'…' : k), datasets: [ {label:'Positif',data:ts6.map(([,v]) => v.pos.length ? +(SM.avg(v.pos)*100).toFixed(1) : 0),backgroundColor:C.pos,borderRadius:3,borderWidth:0}, {label:'Negatif',data:ts6.map(([,v]) => v.neg.length ? +(SM.avg(v.neg)*100).toFixed(1) : 0),backgroundColor:C.neg,borderRadius:3,borderWidth:0}, {label:'Netral', data:ts6.map(([,v]) => v.neu.length ? +(SM.avg(v.neu)*100).toFixed(1) : 0),backgroundColor:C.neu,borderRadius:3,borderWidth:0}, ] }, options: { responsive:true, maintainAspectRatio:false, plugins:{legend:{labels:{boxWidth:9,padding:12}}}, scales:{ x:{grid:{color:G},ticks:{maxRotation:30,font:{size:9}}}, y:{grid:{color:G},beginAtZero:true,max:100,ticks:{callback: v => v+'%'}} } } }); }); // ── 7. HOURLY ACTIVITY BAR ── safe('c12', () => { const hourly = Array(24).fill(0); rows.forEach(r => { if (!r.date) return; const d = new Date(r.date); if (!isNaN(d.getTime())) hourly[d.getHours()]++; }); SM.mkChart('c12', { type: 'bar', data: { labels: Array.from({length:24}, (_,i) => `${String(i).padStart(2,'0')}:00`), datasets: [{ label:'Tweet', data: hourly, backgroundColor: hourly.map((_,i) => i>=7 && i<=21 ? C.a1d : C.a2d), borderColor: hourly.map((_,i) => i>=7 && i<=21 ? C.a1 : C.a2), borderWidth:1, borderRadius:3 }] }, options: { responsive:true, maintainAspectRatio:false, plugins:{ legend:{display:false}, tooltip:{callbacks:{title: c=>`Jam ${c[0].label}`, label: c=>` ${c.parsed.y} tweet`}}}, scales:{ x:{grid:{color:G},ticks:{font:{size:9}}}, y:{grid:{color:G},beginAtZero:true} } } }); }); // ══════════════════════════════════════════ // ── 8. TOP 10 HASHTAG TABLE ── // ══════════════════════════════════════════ safe('tblHashtag', () => { const htMap = {}; rows.forEach(r => { const tags = (r.raw.match(/#(\w+)/g) || []).map(t => t.toLowerCase()); tags.forEach(t => { htMap[t] = (htMap[t]||0)+1; }); }); const top10 = Object.entries(htMap).sort((a,b) => b[1]-a[1]).slice(0,10); const tbody = document.getElementById('tblHashtagBody'); if (top10.length === 0) { document.getElementById('tblHashtagEmpty').style.display = 'block'; return; } const maxV = top10[0][1]; tbody.innerHTML = top10.map(([tag, cnt], i) => ` ${i+1} ${SM.esc(tag)} ${cnt}
`).join(''); }); // ══════════════════════════════════════════ // ── 9. TOP 10 TOPIC TABLE ── // ══════════════════════════════════════════ safe('tblTopic', () => { const topicMap = {}; rows.forEach(r => { const t = r.raw.split(/[.!?]/)[0].trim().slice(0,60) || 'Lainnya'; if (!topicMap[t]) topicMap[t] = {pos:0,neg:0,neu:0,tot:0}; topicMap[t][r.sentiment==='Positif'?'pos':r.sentiment==='Negatif'?'neg':'neu']++; topicMap[t].tot++; }); const top10 = Object.entries(topicMap).sort((a,b) => b[1].tot-a[1].tot).slice(0,10); const tbody = document.getElementById('tblTopicBody'); tbody.innerHTML = top10.map(([topic, v], i) => ` ${i+1} ${SM.esc(topic.length>55?topic.slice(0,55)+'…':topic)} ${v.tot} ${v.pos} ${v.neg} ${v.neu} `).join(''); }); // ══════════════════════════════════════════ // ── 10. SENTIMENT PER HASHTAG TABLE ── // ══════════════════════════════════════════ safe('tblHashtagSent', () => { const htSent = {}; rows.forEach(r => { const tags = (r.raw.match(/#(\w+)/g) || []).map(t => t.toLowerCase()); tags.forEach(tag => { if (!htSent[tag]) htSent[tag] = {pos:0,neg:0,neu:0,tot:0}; htSent[tag][r.sentiment==='Positif'?'pos':r.sentiment==='Negatif'?'neg':'neu']++; htSent[tag].tot++; }); }); const top10 = Object.entries(htSent).sort((a,b) => b[1].tot-a[1].tot).slice(0,10); const tbody = document.getElementById('tblHashtagSentBody'); if (top10.length === 0) { document.getElementById('tblHashtagSentEmpty').style.display = 'block'; return; } tbody.innerHTML = top10.map(([tag, v], i) => { const dominated = v.pos>=v.neg && v.pos>=v.neu ? 'Positif' : v.neg>v.pos && v.neg>=v.neu ? 'Negatif' : 'Netral'; const domColor = dominated==='Positif'?'var(--pos)':dominated==='Negatif'?'var(--neg)':'var(--neu)'; const pPct = (v.pos/v.tot*100).toFixed(0); const nPct = (v.neg/v.tot*100).toFixed(0); const neuPct = (100 - +pPct - +nPct); return ` ${i+1} ${SM.esc(tag)} ${v.tot} ${v.pos} ${v.neg} ${v.neu} ${dominated}
`; }).join(''); }); // ══════════════════════════════════════════ // ── 11. LANGUAGE CHART ── // ══════════════════════════════════════════ safe('cLang', () => { const langMap = {}; const LANG_NAMES = { 'in':'Indonesia','id':'Indonesia','en':'English','ms':'Malay', 'ar':'Arab','zh':'Chinese','ja':'Japanese','ko':'Korean', 'fr':'French','de':'German','es':'Spanish','pt':'Portuguese','nl':'Dutch','tr':'Turkish', 'tl':'Filipino','th':'Thai','vi':'Vietnamese','hi':'Hindi','und':'Lainnya' }; rows.forEach(r => { const l = (r.lang||'und').toLowerCase(); const name = LANG_NAMES[l] || l.toUpperCase(); if (!langMap[name]) langMap[name] = {Positif:0,Negatif:0,Netral:0,tot:0}; langMap[name][r.sentiment]++; langMap[name].tot++; }); const topLangs = Object.entries(langMap).sort((a,b) => b[1].tot-a[1].tot).slice(0,12); SM.mkChart('cLang', { type:'bar', data:{ labels:topLangs.map(([l])=>l), datasets:[ {label:'Positif',data:topLangs.map(([,v])=>v.Positif),backgroundColor:C.pos,borderRadius:4,borderWidth:0}, {label:'Negatif',data:topLangs.map(([,v])=>v.Negatif),backgroundColor:C.neg,borderRadius:4,borderWidth:0}, {label:'Netral', data:topLangs.map(([,v])=>v.Netral), backgroundColor:C.neu,borderRadius:4,borderWidth:0}, ] }, options:{responsive:true,maintainAspectRatio:false, plugins:{legend:{labels:{boxWidth:9,padding:14}}}, scales:{ x:{stacked:true,grid:{color:G}}, y:{stacked:true,grid:{color:G},beginAtZero:true} } } }); }); // ══════════════════════════════════════════ // ── 12–14. COMMON WORDS PANELS ── // ══════════════════════════════════════════ function getTopWords(subset, topN = 30) { const freq = {}; subset.forEach(r => { const words = (r.cleaned || r.raw).toLowerCase() .replace(/[^a-z0-9\s]/g,' ').split(/\s+/) .filter(w => w.length > 2 && !SM.STOPWORDS.has(w)); words.forEach(w => { freq[w] = (freq[w]||0)+1; }); }); return Object.entries(freq).sort((a,b) => b[1]-a[1]).slice(0,topN); } function renderWordTags(containerId, words, baseColor) { const el = document.getElementById(containerId); if (!el) return; if (!words.length) { el.innerHTML = `
Tidak ada data kata umum
`; return; } const maxF = words[0][1]; el.innerHTML = words.map(([w, f]) => { const size = 11 + Math.round((f/maxF)*10); const alpha = 0.35 + (f/maxF)*0.65; return `${SM.esc(w)}`; }).join(''); } safe('wordsPos', () => renderWordTags('wordsPos', getTopWords(pos), C.pos)); safe('wordsNeu', () => renderWordTags('wordsNeu', getTopWords(neu), C.neu)); safe('wordsNeg', () => renderWordTags('wordsNeg', getTopWords(neg), C.neg)); }); // end DOMContentLoaded