Spaces:
Running
Running
File size: 38,203 Bytes
04b72bb 3cc3895 04b72bb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 | /* βββββββββββββββββββββββββββββββββββββββββββββββββββ
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,'&').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');
}
});
|