Spaces:
Running
Running
| ; | |
| // ββ 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]) => `<div class="legend-item"><div class="legend-dot" style="background:${c}"></div>${l} (${v})</div>`).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) => ` | |
| <tr> | |
| <td class="at-rank">${i+1}</td> | |
| <td class="at-label"><span class="at-hashtag">${SM.esc(tag)}</span></td> | |
| <td class="at-num">${cnt}</td> | |
| <td class="at-bar-cell"> | |
| <div class="at-bar-track"><div class="at-bar-fill" style="width:${(cnt/maxV*100).toFixed(1)}%;background:${C.a1}"></div></div> | |
| </td> | |
| </tr>`).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) => ` | |
| <tr> | |
| <td class="at-rank">${i+1}</td> | |
| <td class="at-label at-topic-text" title="${SM.esc(topic)}">${SM.esc(topic.length>55?topic.slice(0,55)+'β¦':topic)}</td> | |
| <td class="at-num">${v.tot}</td> | |
| <td class="at-num" style="color:var(--pos)">${v.pos}</td> | |
| <td class="at-num" style="color:var(--neg)">${v.neg}</td> | |
| <td class="at-num" style="color:var(--neu)">${v.neu}</td> | |
| </tr>`).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 `<tr> | |
| <td class="at-rank">${i+1}</td> | |
| <td class="at-label"><span class="at-hashtag">${SM.esc(tag)}</span></td> | |
| <td class="at-num">${v.tot}</td> | |
| <td class="at-num" style="color:var(--pos)">${v.pos}</td> | |
| <td class="at-num" style="color:var(--neg)">${v.neg}</td> | |
| <td class="at-num" style="color:var(--neu)">${v.neu}</td> | |
| <td><span class="badge badge-${dominated==='Positif'?'pos':dominated==='Negatif'?'neg':'neu'}">${dominated}</span></td> | |
| <td class="at-bar-cell"> | |
| <div class="at-bar-track at-bar-multi"> | |
| <div style="width:${pPct}%;background:var(--pos);height:100%;transition:width .4s"></div> | |
| <div style="width:${nPct}%;background:var(--neg);height:100%;transition:width .4s"></div> | |
| <div style="width:${Math.max(neuPct,0)}%;background:var(--neu);height:100%;transition:width .4s"></div> | |
| </div> | |
| </td> | |
| </tr>`; | |
| }).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 = ` | |
| <div class="empty-state" style="min-height:80px;margin:0;padding:24px 10px;border:none;background:transparent"> | |
| <div class="empty-state-title" style="font-size:12px;color:var(--tx3)">Tidak ada data kata umum</div> | |
| </div>`; | |
| 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 `<span class="word-tag" style="font-size:${size}px;opacity:${alpha.toFixed(2)};background:${baseColor}22;color:${baseColor};border-color:${baseColor}44" title="${f} kali">${SM.esc(w)}</span>`; | |
| }).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 | |