sentimeter / js /shared.js
rhmnsae's picture
up
3cc3895
/* ═══════════════════════════════════════════════════
SentiMeter β€” shared.js
Shared Engine: IndoBERT API Β· Cleaning Β· Store Β· Nav
═══════════════════════════════════════════════════ */
'use strict';
// ─── 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,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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');
}
});