Spaces:
Running
Running
| /* βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| SentiMeter β shared.js | |
| Shared Engine: IndoBERT API Β· Cleaning Β· Store Β· Nav | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| ; | |
| // βββ INDOBERT API CONFIG ββββββββββββββββββββββββββββ | |
| const INDOBERT_API_URL = '/api/sentiment'; | |
| const INDOBERT_HEALTH_URL = '/api/health'; | |
| const INDOBERT_BATCH_SIZE = 32; | |
| // βββ STOPWORDS INDONESIA ββββββββββββββββββββββββββββ | |
| const STOPWORDS = 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','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','para', | |
| 'tentang','siapa','apa','bagaimana','kapan','dimana','mengapa','kenapa', | |
| 'sebuah','seorang','berbagai','banyak','sedikit','lebih','kurang','sangat', | |
| 'amat','sekali','cukup','hampir','sudah','belum','sedang','pasti','mungkin', | |
| 'tentu','ya','tidak','bukan','jangan','bila','andai','seandainya','adapun', | |
| 'adapun','demikian','sehingga','akibat','maka','oleh karena','oleh sebab', | |
| ]); | |
| // βββ PIPELINE STEP LABELS βββββββββββββββββββββββββββ | |
| const PIPELINE_STEPS = [ | |
| { id:'lowercase', label:'1. Lowercase', desc:'Mengubah semua huruf menjadi huruf kecil.' }, | |
| { id:'url', label:'2. Hapus URL', desc:'Menghapus semua tautan http/https.' }, | |
| { id:'mention', label:'3. Hapus Mention', desc:'Menghapus @username.' }, | |
| { id:'hashtag', label:'4. Hapus Hashtag', desc:'Mengonversi #tag menjadi kata biasa.' }, | |
| { id:'emoji', label:'5. Hapus Emoji', desc:'Menghapus karakter emoji unicode.' }, | |
| { id:'special', label:'6. Hapus Karakter Khusus', desc:'Hanya huruf, angka, spasi dipertahankan.' }, | |
| { id:'number', label:'7. Hapus Angka', desc:'Menghapus angka yang berdiri sendiri.' }, | |
| { id:'whitespace', label:'8. Normalisasi Spasi', desc:'Menghilangkan spasi ganda/leading/trailing.' }, | |
| { id:'stopword', label:'9. Hapus Stopwords', desc:'Menghapus kata-kata umum tidak bermakna.' }, | |
| ]; | |
| // βββ TEXT CLEANER βββββββββββββββββββββββββββββββββββ | |
| function cleanStep(text, stepId) { | |
| switch (stepId) { | |
| case 'lowercase': return text.toLowerCase(); | |
| case 'url': return text.replace(/https?:\/\/[^\s]+/g,'').replace(/www\.[^\s]+/g,''); | |
| case 'mention': return text.replace(/@\w+/g,''); | |
| case 'hashtag': return text.replace(/#(\w+)/g,'$1'); | |
| case 'emoji': return text.replace(/[\u{1F000}-\u{1FFFF}]/gu,'').replace(/[\u{2600}-\u{27BF}]/gu,''); | |
| case 'special': return text.replace(/[^a-z0-9 .,]/g,' '); | |
| case 'number': return text.replace(/\b\d[\d.,]*\b/g,''); | |
| case 'whitespace': return text.replace(/\s+/g,' ').trim(); | |
| case 'stopword': return text.split(' ').filter(w=>w.length>1&&!STOPWORDS.has(w)).join(' '); | |
| default: return text; | |
| } | |
| } | |
| function cleanText(raw) { | |
| let t = raw || ''; | |
| for (const s of PIPELINE_STEPS) t = cleanStep(t, s.id); | |
| return t; | |
| } | |
| function cleanSteps(raw) { | |
| const steps = []; | |
| let t = raw || ''; | |
| for (const s of PIPELINE_STEPS) { | |
| const before = t; | |
| t = cleanStep(t, s.id); | |
| steps.push({ ...s, before, after: t }); | |
| } | |
| return steps; | |
| } | |
| // βββ CLASSIFY (IndoBERT API) ββββββββββββββββββββββββ | |
| /** | |
| * Check if the IndoBERT model backend is available. | |
| * Returns { ok: true/false, error: string|null } | |
| */ | |
| async function checkModelHealth() { | |
| try { | |
| const res = await fetch(INDOBERT_HEALTH_URL, { method: 'GET' }); | |
| const data = await res.json(); | |
| if (data.status === 'ok') return { ok: true, error: null }; | |
| return { ok: false, error: data.error || 'Model tidak tersedia' }; | |
| } catch (e) { | |
| return { ok: false, error: 'Server IndoBERT tidak dapat dijangkau. Pastikan server Python sedang berjalan.' }; | |
| } | |
| } | |
| /** | |
| * Send a batch of texts to the IndoBERT API for sentiment classification. | |
| * Returns array of { label: 'Positif'|'Netral'|'Negatif', score: number } | |
| * Throws an error if the API call fails. | |
| */ | |
| async function classifyBatch(texts) { | |
| const res = await fetch(INDOBERT_API_URL, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ texts }), | |
| }); | |
| if (!res.ok) { | |
| const errData = await res.json().catch(() => ({})); | |
| throw new Error(errData.message || `Server error: ${res.status}`); | |
| } | |
| return await res.json(); | |
| } | |
| // βββ HELPERS ββββββββββββββββββββββββββββββββββββββββ | |
| function fmt(n) { | |
| if (n >= 1e6) return (n/1e6).toFixed(1)+'M'; | |
| if (n >= 1e3) return (n/1e3).toFixed(1)+'K'; | |
| return String(n); | |
| } | |
| function esc(s) { | |
| return String(s||'') | |
| .replace(/&/g,'&').replace(/</g,'<') | |
| .replace(/>/g,'>').replace(/"/g,'"'); | |
| } | |
| function avg(arr) { return arr.length ? arr.reduce((a,b)=>a+b,0)/arr.length : 0; } | |
| // βββ DATA STORE (localStorage) βββββββββββββββββββ | |
| const STORE_KEY = 'sentimeter_data'; | |
| function saveData(rows, meta) { | |
| try { | |
| const dataObj = { rows, meta }; | |
| localStorage.setItem(STORE_KEY, JSON.stringify(dataObj)); | |
| // Also save to history | |
| saveToHistory(dataObj); | |
| } catch(e) { | |
| // quota exceeded β silently fail | |
| console.warn('localStorage full, using memory fallback', e); | |
| window._sentimeterData = { rows, meta }; | |
| } | |
| } | |
| function loadData() { | |
| try { | |
| const raw = localStorage.getItem(STORE_KEY); | |
| if (raw) return JSON.parse(raw); | |
| } catch(e) {} | |
| return window._sentimeterData || null; | |
| } | |
| function hasData() { return !!loadData(); } | |
| // βββ HISTORY STORE (localStorage) βββββββββββββββββ | |
| const HISTORY_KEY = 'sentimeter_history'; | |
| const MAX_HISTORY = 10; // Keep last 10 analyses | |
| function getHistory() { | |
| try { | |
| const raw = localStorage.getItem(HISTORY_KEY); | |
| if (raw) return JSON.parse(raw); | |
| } catch(e) {} | |
| return []; | |
| } | |
| function saveToHistory(dataObj) { | |
| try { | |
| let history = getHistory(); | |
| // Create a history item (we store the full dataObj to allow reloading) | |
| // To save space, we might compress or limit, but for now we store as is | |
| // since localStorage typically allows 5MB. We'll add a timestamp ID. | |
| const historyItem = { | |
| id: 'hist_' + Date.now(), | |
| savedAt: new Date().toISOString(), | |
| data: dataObj | |
| }; | |
| // Check if identical filename exists, remove old one | |
| history = history.filter(h => h.data.meta.filename !== dataObj.meta.filename); | |
| history.unshift(historyItem); | |
| // Enforce limit and memory size by checking quota | |
| while (history.length > MAX_HISTORY) { | |
| history.pop(); | |
| } | |
| // Attempt saving, if quota exceeded, remove oldest until it fits | |
| let saved = false; | |
| while (!saved && history.length > 0) { | |
| try { | |
| localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); | |
| saved = true; | |
| } catch (e) { | |
| history.pop(); // Remove oldest | |
| if (history.length === 0) { | |
| console.warn('History storage completely full.'); | |
| break; | |
| } | |
| } | |
| } | |
| } catch(e) { | |
| console.warn('Could not save to history', e); | |
| } | |
| } | |
| function deleteHistoryItem(id) { | |
| try { | |
| const history = getHistory(); | |
| const itemToDelete = history.find(h => h.id === id); | |
| if (!itemToDelete) return; | |
| // Check if this item is currently active in the dashboard | |
| const currentData = loadData(); | |
| if (currentData && currentData.meta && itemToDelete.data && | |
| currentData.meta.filename === itemToDelete.data.meta.filename && | |
| currentData.meta.processedAt === itemToDelete.data.meta.processedAt) { | |
| clearData(); | |
| } | |
| const newHistory = history.filter(h => h.id !== id); | |
| localStorage.setItem(HISTORY_KEY, JSON.stringify(newHistory)); | |
| } catch(e) {} | |
| } | |
| function clearData() { | |
| try { | |
| localStorage.removeItem(STORE_KEY); | |
| window._sentimeterData = null; | |
| } catch(e) {} | |
| } | |
| function loadHistoryItem(id) { | |
| const history = getHistory(); | |
| const item = history.find(h => h.id === id); | |
| if (item && item.data) { | |
| saveData(item.data.rows, item.data.meta); | |
| return true; | |
| } | |
| return false; | |
| } | |
| // βββ PARSE & PROCESS CSV (IndoBERT) βββββββββββββββββ | |
| /** | |
| * Process CSV text: parse, clean, and classify sentiment via IndoBERT API. | |
| * This is an async function that sends texts in batches to the backend. | |
| * @param {string} csvText - Raw CSV text content | |
| * @param {function} onProgress - Progress callback(done, total) | |
| * @returns {Promise<{rows: Array, meta: Object}|null>} - null if API error | |
| */ | |
| async function processCSV(csvText, onProgress) { | |
| // 1. First check if model is healthy | |
| const health = await checkModelHealth(); | |
| if (!health.ok) { | |
| throw new Error(health.error); | |
| } | |
| // 2. Parse CSV | |
| const result = Papa.parse(csvText, { header:true, skipEmptyLines:true }); | |
| const raw = result.data; | |
| if (!raw.length) return { rows: [], meta: {} }; | |
| const TEXT_COLS = ['full_text','text','tweet','content','teks','body']; | |
| const textCol = TEXT_COLS.find(c => c in raw[0]) || Object.keys(raw[0])[0]; | |
| // 3. Build preliminary rows (without sentiment) and collect texts to classify | |
| const preRows = []; | |
| const textsToClassify = []; | |
| const cleanedTexts = []; | |
| for (let i = 0; i < raw.length; i++) { | |
| const r = raw[i]; | |
| const rawTxt = (r[textCol]||'').trim(); | |
| if (!rawTxt) continue; | |
| const cleaned = cleanText(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 media_url = r.media_url || r.media_url_https || r.photo_url || r.image_url || ''; | |
| const tweetId = r.id_str || r.tweet_id || r.id || r.rest_id || ''; | |
| preRows.push({ | |
| tweetId, | |
| raw: rawTxt, | |
| cleaned, | |
| username: r.username||r.user_screen_name||'β', | |
| location: (r.location||'').trim()||'β', | |
| date: r.created_at||'', | |
| lang: r.lang||'in', | |
| fav, rt, rep, qot, | |
| engagement: fav + rt + rep + qot, | |
| media_url, | |
| wordsBefore: rawTxt.split(/\s+/).length, | |
| wordsAfter: cleaned.split(/\s+/).filter(Boolean).length, | |
| }); | |
| // Send original text to model for best accuracy | |
| textsToClassify.push(rawTxt); | |
| cleanedTexts.push(cleaned); | |
| } | |
| if (preRows.length === 0) return { rows: [], meta: {} }; | |
| // 4. Send texts in batches to IndoBERT API | |
| const total = textsToClassify.length; | |
| const sentimentResults = []; | |
| let processed = 0; | |
| for (let i = 0; i < total; i += INDOBERT_BATCH_SIZE) { | |
| const batch = textsToClassify.slice(i, i + INDOBERT_BATCH_SIZE); | |
| const batchResults = await classifyBatch(batch); | |
| sentimentResults.push(...batchResults); | |
| processed += batch.length; | |
| if (onProgress) onProgress(processed, total); | |
| // Small yield to allow UI updates | |
| await new Promise(r => setTimeout(r, 10)); | |
| } | |
| // 5. Merge sentiment results into rows | |
| const rows = preRows.map((r, idx) => ({ | |
| id: idx + 1, | |
| ...r, | |
| sentiment: sentimentResults[idx].label, | |
| confidence: sentimentResults[idx].score, | |
| })); | |
| // Meta | |
| const dates = rows.map(r=>r.date).filter(Boolean).sort(); | |
| const meta = { | |
| totalRows: rows.length, | |
| filename: window._uploadedFilename || 'data.csv', | |
| dateMin: dates[0]||'', | |
| dateMax: dates[dates.length-1]||'', | |
| processedAt: new Date().toISOString(), | |
| }; | |
| return { rows, meta }; | |
| } | |
| // βββ THEME MANAGER ββββββββββββββββββββββββββββββββββ | |
| const THEME_KEY = 'sentimeter_theme'; | |
| function applyTheme(theme) { | |
| document.documentElement.setAttribute('data-theme', theme); | |
| localStorage.setItem(THEME_KEY, theme); | |
| const btn = document.getElementById('themeToggleTrack'); | |
| if (btn) btn.classList.toggle('on', theme === 'light'); | |
| // Update Chart.js colors if charts exist | |
| if (window.chartInstances) { | |
| const gridCol = theme === 'light' ? 'rgba(0,0,0,0.06)' : 'rgba(255,255,255,0.05)'; | |
| const textCol = theme === 'light' ? '#4a5568' : '#525b72'; | |
| if (typeof Chart !== 'undefined') { | |
| Chart.defaults.color = textCol; | |
| } | |
| } | |
| } | |
| function initTheme() { | |
| const saved = localStorage.getItem(THEME_KEY) || 'dark'; | |
| document.documentElement.setAttribute('data-theme', saved); | |
| } | |
| // Apply theme IMMEDIATELY before any paint | |
| initTheme(); | |
| // βββ SVG ICONS βββββββββββββββββββββββββββββββ | |
| const I = { | |
| upload: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>`, | |
| intro: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>`, | |
| dash: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg>`, | |
| chart: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" x2="18" y1="20" y2="10"/><line x1="12" x2="12" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="14"/></svg>`, | |
| tweets: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`, | |
| table: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/></svg>`, | |
| lab: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72Z"/><path d="m14 7 3 3"/><path d="M5 6v4"/><path d="M19 14v4"/><path d="M10 2v2"/><path d="M7 8H3"/><path d="M21 16h-4"/><path d="M11 3H9"/></svg>`, | |
| sun: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>`, | |
| moon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>`, | |
| collapse: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/></svg>`, | |
| expand: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/></svg>`, | |
| check: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`, | |
| alert: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>`, | |
| menu: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>`, | |
| close: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`, | |
| history: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg>`, | |
| heart: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/></svg>` | |
| }; | |
| // βββ SIDEBAR INJECTOR βββββββββββββββββββββββββββββββ | |
| const NAV_ITEMS = [ | |
| { href:'/', label:'Pengenalan', id:'nav-intro', icon: I.intro, alwaysUnlocked: true }, | |
| { href:'upload', label:'Upload Data', id:'nav-upload', icon: I.upload, alwaysUnlocked: true }, | |
| { href:'dashboard', label:'Dashboard', id:'nav-dashboard', icon: I.dash }, | |
| { href:'analytics', label:'Analytics', id:'nav-analytics', icon: I.chart }, | |
| { href:'tweets', label:'Tweet List', id:'nav-tweets', icon: I.tweets }, | |
| { href:'data', label:'Data & Tabel', id:'nav-data', icon: I.table }, | |
| { href:'cleaning', label:'Cleaning Lab', id:'nav-cleaning', icon: I.lab }, | |
| { href:'history', label:'Riwayat Analisis', id:'nav-history', icon: I.history, alwaysUnlocked: true }, | |
| { href:'support', label:'Dukungan', id:'nav-support', icon: I.heart, alwaysUnlocked: true }, | |
| ]; | |
| function injectLayout(activePage) { | |
| const hasD = hasData(); | |
| const curTheme = localStorage.getItem(THEME_KEY) || 'dark'; | |
| const isLight = curTheme === 'light'; | |
| const isSidebarCollapsed = localStorage.getItem('sentimeter_sidebar') === 'collapsed'; | |
| if (isSidebarCollapsed) document.body.classList.add('sidebar-collapsed'); | |
| const navHTML = NAV_ITEMS.map(n => { | |
| const isActive = n.id === activePage; | |
| const isLocked = !n.alwaysUnlocked && n.id !== 'nav-upload' && !hasD; | |
| return `<a href="${isLocked?'#':n.href}" | |
| class="nav-item${isActive?' active':''}${isLocked?' locked':''}" | |
| ${isLocked?'title="Upload data terlebih dahulu"':''} title="${n.label}"> | |
| <span class="nav-icon">${n.icon}</span> | |
| <span class="nav-label">${n.label}</span> | |
| ${isLocked?'<span class="nav-lock">β</span>':''} | |
| </a>`; | |
| }).join(''); | |
| const sidebar = document.getElementById('sidebar'); | |
| if (sidebar) { | |
| sidebar.innerHTML = ` | |
| <div class="sidebar-brand"> | |
| <div class="brand-mark"></div> | |
| <div class="brand-text-wrap"> | |
| <div class="brand-name">SentiMeter</div> | |
| <div class="brand-sub">IndoBERT Β· ID</div> | |
| </div> | |
| </div> | |
| <nav class="sidebar-nav"> | |
| <div class="nav-section-label">Navigasi</div> | |
| ${navHTML} | |
| </nav> | |
| <div class="sidebar-footer"> | |
| <button class="theme-toggle" id="themeToggleBtn" title="Ganti tema"> | |
| <span class="theme-icon-wrap" style="display:flex;align-items:center;gap:10px"> | |
| <span class="nav-icon" id="themeIcon">${isLight ? I.sun : I.moon}</span> | |
| <span class="theme-text">${isLight ? 'Light Mode' : 'Dark Mode'}</span> | |
| </span> | |
| <div class="toggle-track${isLight?' on':''}" id="themeToggleTrack"> | |
| <div class="toggle-thumb"></div> | |
| </div> | |
| </button> | |
| <button class="theme-toggle sidebar-toggle-btn" style="margin-top:6px" id="sidebarToggleBtn" title="Toggle Sidebar"> | |
| <span class="theme-icon-wrap" style="display:flex;align-items:center;gap:10px"> | |
| <span class="nav-icon" id="collapseIcon" style="border:none;background:transparent;margin:0">${isSidebarCollapsed ? I.expand : I.collapse}</span> | |
| <span class="theme-text">Sembunyikan Menu</span> | |
| </span> | |
| </button> | |
| <div style="margin-top:8px"> | |
| ${hasD ? `<div class="data-badge" title="Data dimuat"> | |
| <span class="badge-icon">${I.check}</span> | |
| <span class="badge-text">Data dimuat</span> | |
| </div>` | |
| : `<div class="data-badge no-data" title="Belum ada data"> | |
| <span class="badge-icon">${I.alert}</span> | |
| <span class="badge-text">Belum ada data</span> | |
| </div>`} | |
| </div> | |
| </div> | |
| `; | |
| // Inject Mobile Menu Trigger to Topbar | |
| const topbar = document.querySelector('.topbar'); | |
| if (topbar && !document.getElementById('mobileMenuBtn')) { | |
| const menuBtn = document.createElement('button'); | |
| menuBtn.id = 'mobileMenuBtn'; | |
| menuBtn.className = 'mobile-menu-btn'; | |
| menuBtn.innerHTML = I.menu; | |
| topbar.prepend(menuBtn); | |
| menuBtn.addEventListener('click', () => { | |
| document.body.classList.add('sidebar-mobile-open'); | |
| }); | |
| } | |
| // Inject Overlay | |
| if (!document.getElementById('sidebarOverlay')) { | |
| const overlay = document.createElement('div'); | |
| overlay.id = 'sidebarOverlay'; | |
| overlay.className = 'sidebar-overlay'; | |
| document.body.appendChild(overlay); | |
| overlay.addEventListener('click', () => { | |
| document.body.classList.remove('sidebar-mobile-open'); | |
| }); | |
| } | |
| document.getElementById('themeToggleBtn').addEventListener('click', () => { | |
| const current = document.documentElement.getAttribute('data-theme') || 'dark'; | |
| const next = current === 'dark' ? 'light' : 'dark'; | |
| applyTheme(next); | |
| const spanText = document.querySelector('#themeToggleBtn .theme-text'); | |
| const iconWrap = document.querySelector('#themeIcon'); | |
| if (spanText) spanText.textContent = next === 'light' ? 'Light Mode' : 'Dark Mode'; | |
| if (iconWrap) iconWrap.innerHTML = next === 'light' ? I.sun : I.moon; | |
| }); | |
| document.getElementById('sidebarToggleBtn').addEventListener('click', () => { | |
| const isCollapsed = document.body.classList.toggle('sidebar-collapsed'); | |
| localStorage.setItem('sentimeter_sidebar', isCollapsed ? 'collapsed' : 'expanded'); | |
| const iconWrap = document.querySelector('#collapseIcon'); | |
| if (iconWrap) iconWrap.innerHTML = isCollapsed ? I.expand : I.collapse; | |
| }); | |
| } | |
| // Inject Toast Container | |
| if (!document.getElementById('toastContainer')) { | |
| const toastCont = document.createElement('div'); | |
| toastCont.id = 'toastContainer'; | |
| toastCont.className = 'toast-container'; | |
| document.body.appendChild(toastCont); | |
| } | |
| } | |
| // βββ CHART DEFAULTS βββββββββββββββββββββββββββββββββ | |
| function setChartDefaults() { | |
| if (typeof Chart === 'undefined') return; | |
| Chart.defaults.color = '#525b72'; | |
| Chart.defaults.font.family = "'Inter', sans-serif"; | |
| Chart.defaults.font.size = 11; | |
| Chart.defaults.plugins.tooltip.padding = 10; | |
| Chart.defaults.plugins.tooltip.cornerRadius = 8; | |
| Chart.defaults.plugins.tooltip.titleFont = { weight:'600' }; | |
| } | |
| // βββ COLOR PALETTE βββββββββββββββββββββββββββββββββββ | |
| const C = { | |
| pos:'#34d399', posDim:'rgba(52,211,153,0.15)', posMid:'rgba(52,211,153,0.6)', | |
| neg:'#f87171', negDim:'rgba(248,113,113,0.15)', negMid:'rgba(248,113,113,0.6)', | |
| neu:'#fbbf24', neuDim:'rgba(251,191,36,0.15)', neuMid:'rgba(251,191,36,0.6)', | |
| a1:'#6c8fff', a1d:'rgba(108,143,255,0.15)', | |
| a2:'#a78bfa', a2d:'rgba(167,139,250,0.15)', | |
| a3:'#60d9f9', a3d:'rgba(96,217,249,0.15)', | |
| a4:'#f472b6', a4d:'rgba(244,114,182,0.15)', | |
| a5:'#fb923c', a5d:'rgba(251,146,60,0.15)', | |
| palette: ['#6c8fff','#a78bfa','#60d9f9','#f472b6','#fb923c','#34d399','#f87171','#fbbf24'], | |
| }; | |
| // grid line shorthand | |
| const gridColor = 'rgba(255,255,255,0.05)'; | |
| // destroy helper | |
| const chartInstances = {}; | |
| function mkChart(id, config) { | |
| if (chartInstances[id]) { chartInstances[id].destroy(); } | |
| const ctx = document.getElementById(id); | |
| if (!ctx) return; | |
| chartInstances[id] = new Chart(ctx, config); | |
| return chartInstances[id]; | |
| } | |
| // βββ CUSTOM SELECT COMPONENT ββββββββββββββββββββββββ | |
| function initCustomSelect(sel, opts = {}) { | |
| if (!sel || sel._csdInit) return; | |
| sel._csdInit = true; | |
| const compact = opts.compact || false; | |
| const showDots = opts.showDots || null; // { value: cssColor } | |
| // Build wrapper | |
| const wrap = document.createElement('div'); | |
| wrap.className = 'csd-wrap' + (compact ? ' csd-compact' : ''); | |
| // Build trigger | |
| const trigger = document.createElement('button'); | |
| trigger.type = 'button'; | |
| trigger.className = 'csd-trigger'; | |
| const labelEl = document.createElement('span'); | |
| labelEl.className = 'csd-label'; | |
| const chevron = document.createElementNS('http://www.w3.org/2000/svg','svg'); | |
| chevron.setAttribute('viewBox','0 0 24 24'); | |
| chevron.setAttribute('fill','none'); | |
| chevron.setAttribute('stroke','currentColor'); | |
| chevron.setAttribute('stroke-width','2.2'); | |
| chevron.setAttribute('stroke-linecap','round'); | |
| chevron.setAttribute('stroke-linejoin','round'); | |
| chevron.classList.add('csd-chevron'); | |
| chevron.innerHTML = '<polyline points="6 9 12 15 18 9"/>'; | |
| trigger.appendChild(labelEl); | |
| trigger.appendChild(chevron); | |
| // Build panel | |
| const panel = document.createElement('div'); | |
| panel.className = 'csd-panel'; | |
| panel.style.display = 'none'; | |
| const searchWrap = document.createElement('div'); | |
| searchWrap.className = 'csd-search-wrap'; | |
| const searchInput = document.createElement('input'); | |
| searchInput.className = 'csd-search'; | |
| searchInput.type = 'text'; | |
| searchInput.placeholder = 'Cari...'; | |
| searchInput.autocomplete = 'off'; | |
| searchWrap.appendChild(searchInput); | |
| const list = document.createElement('div'); | |
| list.className = 'csd-list'; | |
| panel.appendChild(searchWrap); | |
| panel.appendChild(list); | |
| wrap.appendChild(trigger); | |
| wrap.appendChild(panel); | |
| // Insert before native select, hide it | |
| sel.parentNode.insertBefore(wrap, sel); | |
| sel.style.display = 'none'; | |
| wrap.appendChild(sel); // keep in DOM for value access | |
| function updateLabel() { | |
| const cur = sel.options[sel.selectedIndex]; | |
| labelEl.textContent = cur ? cur.text : 'β'; | |
| } | |
| function renderList(query) { | |
| const q = (query || '').toLowerCase().trim(); | |
| list.innerHTML = ''; | |
| let count = 0; | |
| Array.from(sel.options).forEach(o => { | |
| if (q && !o.text.toLowerCase().includes(q)) return; | |
| count++; | |
| const item = document.createElement('div'); | |
| item.className = 'csd-option' + (o.selected ? ' selected' : ''); | |
| item.dataset.value = o.value; | |
| if (showDots && showDots[o.value]) { | |
| const dot = document.createElement('span'); | |
| dot.className = 'csd-dot'; | |
| dot.style.background = showDots[o.value]; | |
| item.appendChild(dot); | |
| } | |
| const txt = document.createElement('span'); | |
| txt.textContent = o.text; | |
| item.appendChild(txt); | |
| // Check SVG | |
| const check = document.createElementNS('http://www.w3.org/2000/svg','svg'); | |
| check.setAttribute('viewBox','0 0 24 24'); | |
| check.setAttribute('fill','none'); | |
| check.setAttribute('stroke','currentColor'); | |
| check.setAttribute('stroke-width','3'); | |
| check.setAttribute('stroke-linecap','round'); | |
| check.setAttribute('stroke-linejoin','round'); | |
| check.classList.add('csd-check'); | |
| check.innerHTML = '<polyline points="20 6 9 17 4 12"/>'; | |
| item.appendChild(check); | |
| item.addEventListener('mousedown', (e) => { | |
| e.preventDefault(); | |
| sel.value = o.value; | |
| sel.dispatchEvent(new Event('change', { bubbles: true })); | |
| updateLabel(); | |
| closePanel(); | |
| }); | |
| list.appendChild(item); | |
| }); | |
| if (count === 0) { | |
| const em = document.createElement('div'); | |
| em.className = 'csd-empty'; | |
| em.textContent = 'Tidak ada hasil'; | |
| list.appendChild(em); | |
| } | |
| } | |
| function openPanel() { | |
| panel.style.display = 'block'; | |
| trigger.classList.add('open'); | |
| searchInput.value = ''; | |
| renderList(''); | |
| const selItem = list.querySelector('.selected'); | |
| if (selItem) selItem.scrollIntoView({ block: 'nearest' }); | |
| if (!compact) requestAnimationFrame(() => searchInput.focus()); | |
| } | |
| function closePanel() { | |
| panel.style.display = 'none'; | |
| trigger.classList.remove('open'); | |
| } | |
| trigger.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| panel.style.display === 'none' ? openPanel() : closePanel(); | |
| }); | |
| searchInput.addEventListener('input', () => renderList(searchInput.value)); | |
| searchInput.addEventListener('click', e => e.stopPropagation()); | |
| panel.addEventListener('click', e => e.stopPropagation()); | |
| document.addEventListener('click', (e) => { | |
| if (!wrap.contains(e.target)) closePanel(); | |
| }); | |
| trigger.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); panel.style.display === 'none' ? openPanel() : closePanel(); } | |
| if (e.key === 'Escape') closePanel(); | |
| }); | |
| // Sync when value changed externally (e.g. reset) | |
| const origChange = sel.onchange; | |
| sel.addEventListener('_csdRefresh', updateLabel); | |
| updateLabel(); | |
| return { open: openPanel, close: closePanel, refresh: updateLabel }; | |
| } | |
| // βββ CUSTOM NUMBER INPUT ββββββββββββββββββββββββββββ | |
| function initCustomNumber(inp) { | |
| if (!inp || inp._cniInit) return; | |
| inp._cniInit = true; | |
| const wrap = document.createElement('div'); | |
| wrap.className = 'cni-wrap'; | |
| inp.parentNode.insertBefore(wrap, inp); | |
| wrap.appendChild(inp); | |
| const arrows = document.createElement('div'); | |
| arrows.className = 'cni-arrows'; | |
| const btnUp = document.createElement('button'); | |
| btnUp.type = 'button'; | |
| btnUp.className = 'cni-btn'; | |
| // Up chevron | |
| btnUp.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>`; | |
| const btnDown = document.createElement('button'); | |
| btnDown.type = 'button'; | |
| btnDown.className = 'cni-btn'; | |
| // Down chevron | |
| btnDown.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`; | |
| arrows.appendChild(btnUp); | |
| arrows.appendChild(btnDown); | |
| wrap.appendChild(arrows); | |
| function step(dir) { | |
| let val = parseFloat(inp.value) || 0; | |
| let s = parseFloat(inp.step) || 1; | |
| let min = inp.hasAttribute('min') ? parseFloat(inp.min) : -Infinity; | |
| let max = inp.hasAttribute('max') ? parseFloat(inp.max) : Infinity; | |
| val += (dir * s); | |
| if (val < min) val = min; | |
| if (val > max) val = max; | |
| // Round to precision to avoid JS float precision issues (like .999999999) | |
| const decimals = (String(s).split('.')[1] || '').length; | |
| inp.value = decimals ? val.toFixed(decimals) : String(Math.round(val)); | |
| inp.dispatchEvent(new Event('input', { bubbles: true })); | |
| inp.dispatchEvent(new Event('change', { bubbles: true })); | |
| } | |
| btnUp.addEventListener('click', (e) => { e.preventDefault(); step(1); inp.focus(); }); | |
| btnDown.addEventListener('click', (e) => { e.preventDefault(); step(-1); inp.focus(); }); | |
| } | |
| // expose globals | |
| window.SM = { | |
| STOPWORDS, PIPELINE_STEPS, | |
| cleanText, cleanSteps, cleanStep, | |
| checkModelHealth, classifyBatch, | |
| fmt, esc, avg, | |
| saveData, loadData, hasData, clearData, processCSV, | |
| getHistory, saveToHistory, deleteHistoryItem, loadHistoryItem, | |
| injectLayout, setChartDefaults, mkChart, C, gridColor, | |
| initCustomSelect, initCustomNumber, | |
| showToast: function(message, type = 'success') { | |
| const container = document.getElementById('toastContainer'); | |
| if (!container) return; | |
| const toast = document.createElement('div'); | |
| toast.className = `toast toast-${type} toast-enter`; | |
| let icon = type === 'error' ? I.alert : I.check; | |
| toast.innerHTML = `<span class="toast-icon">${icon}</span><span class="toast-message">${message}</span>`; | |
| container.appendChild(toast); | |
| void toast.offsetWidth; | |
| toast.classList.remove('toast-enter'); | |
| setTimeout(() => { | |
| toast.classList.add('toast-exit'); | |
| toast.addEventListener('transitionend', () => toast.remove()); | |
| }, 3000); | |
| }, | |
| showModal: function(opts) { | |
| const { | |
| title, message, type = 'success', | |
| confirmText = 'OK, Lanjutkan', | |
| onConfirm, | |
| showCancel = false, | |
| cancelText = 'Batal', | |
| onCancel, | |
| isDanger = false | |
| } = opts; | |
| const overlay = document.createElement('div'); | |
| overlay.className = 'modal-overlay'; | |
| const modal = document.createElement('div'); | |
| modal.className = `modal-box modal-${type}`; | |
| let icon = type === 'error' ? I.alert : I.check; | |
| let btnsHTML = `<div class="modal-btns"> | |
| ${showCancel ? `<button class="btn modal-btn modal-btn-cancel">${cancelText}</button>` : ''} | |
| <button class="btn modal-btn ${isDanger ? 'modal-btn-danger' : 'btn-primary'}">${confirmText}</button> | |
| </div>`; | |
| modal.innerHTML = ` | |
| <div class="modal-icon-wrap">${icon}</div> | |
| <h3 class="modal-title">${title}</h3> | |
| <p class="modal-desc">${message}</p> | |
| ${btnsHTML} | |
| `; | |
| overlay.appendChild(modal); | |
| document.body.appendChild(overlay); | |
| // trigger animation | |
| void overlay.offsetWidth; | |
| overlay.classList.add('active'); | |
| // bind confirm | |
| const btnConfirm = modal.querySelector(isDanger ? '.modal-btn-danger' : '.btn-primary'); | |
| btnConfirm.addEventListener('click', () => { | |
| overlay.classList.remove('active'); | |
| setTimeout(() => { | |
| overlay.remove(); | |
| if (onConfirm) onConfirm(); | |
| }, 300); | |
| }); | |
| if (showCancel) { | |
| const btnCancel = modal.querySelector('.modal-btn-cancel'); | |
| btnCancel.addEventListener('click', () => { | |
| overlay.classList.remove('active'); | |
| setTimeout(() => { | |
| overlay.remove(); | |
| if (onCancel) onCancel(); | |
| }, 300); | |
| }); | |
| } | |
| } | |
| }; | |
| // Auto-trigger persistence modal check on page load | |
| document.addEventListener('DOMContentLoaded', () => { | |
| setTimeout(() => { | |
| if (window.SM && window.SM.hasData()) { | |
| let isReload = false; | |
| // 1. Basic modern performance check (works on desktop) | |
| if (window.performance) { | |
| const navEntries = performance.getEntriesByType('navigation'); | |
| if (navEntries.length > 0 && navEntries[0].type === 'reload') { | |
| isReload = true; | |
| } else if (performance.navigation && performance.navigation.type === 1) { | |
| isReload = true; | |
| } | |
| } | |
| // 2. Bulletproof Mobile Reload Detection (Pull-to-refresh bypasses performance API on iOS/Safari) | |
| // When a user clicks a link, we set a flag. If the flag isn't there on load, it was a direct hit/refresh. | |
| if (!sessionStorage.getItem('sm_internal_nav')) { | |
| // Only consider it a reload if they've been here before in this tab (not the very first click from Twitter) | |
| if (sessionStorage.getItem('sm_visited')) { | |
| isReload = true; | |
| } | |
| } | |
| // Mark that this tab session has visited the site | |
| sessionStorage.setItem('sm_visited', '1'); | |
| // Clear the internal nav flag so the NEXT load is assumed a refresh UNLESS a link is clicked | |
| sessionStorage.removeItem('sm_internal_nav'); | |
| if (isReload) { | |
| window.SM.showModal({ | |
| title: 'Data Tersimpan Secara Lokal', | |
| message: 'Sistem menemukan data dari sesi Anda sebelumnya. Data ini disimpan dengan aman di <i>Local Storage</i> perangkat Anda dan <b>tidak diunggah ke server mana pun</b>.<br><br><span style="color:var(--neg);font-size:13px"><b style="font-weight:700">Catatan Penting:</b> Menghapus <i>cache</i> atau <i>history browser</i> akan menghapus data ini secara permanen.</span>', | |
| type: 'success' | |
| }); | |
| } | |
| } | |
| }, 300); | |
| }); | |
| // Attach listener to all internal links to mark navigation vs refresh | |
| document.addEventListener('click', (e) => { | |
| const link = e.target.closest('a'); | |
| if (link && link.href && link.hostname === window.location.hostname) { | |
| sessionStorage.setItem('sm_internal_nav', '1'); | |
| } | |
| }); | |