Upload project aplikasi analisis sentimen S-Learn

This commit is contained in:
sindisukma 2026-11-09 06:54:35 +07:00
commit 0d5b112685
59 changed files with 30238 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
build/
dist/
.env
.env.local

0
backend/app/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

25
backend/app/database.py Normal file
View File

@ -0,0 +1,25 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
DATABASE_URL = "mysql+pymysql://root:@localhost/sentiment_slearn"
engine = create_engine(
DATABASE_URL,
echo=True
)
SessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=engine
)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

67
backend/app/main.py Normal file
View File

@ -0,0 +1,67 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .database import engine, Base
from . import models
from .routes import data
from .routes import preprocessing
from .routes import prediction
from .routes import dashboard
from .routes import tfidf
# =========================================================
# MEMBUAT TABEL DATABASE
# =========================================================
Base.metadata.create_all(bind=engine)
# =========================================================
# MEMBUAT APLIKASI FASTAPI
# =========================================================
app = FastAPI(
title="S-Learn Sentiment Analysis API",
description="Backend sistem analisis sentimen S-Learn",
version="1.0.0"
)
# =========================================================
# CORS
# =========================================================
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:3000",
"http://localhost:3001"
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# =========================================================
# ROUTER
# =========================================================
app.include_router(data.router)
app.include_router(preprocessing.router)
app.include_router(prediction.router)
app.include_router(dashboard.router)
app.include_router(tfidf.router)
# =========================================================
# ROOT
# =========================================================
@app.get("/")
def root():
return {
"message": "S-Learn Sentiment Analysis API berhasil dijalankan"
}

168
backend/app/ml_model.py Normal file
View File

@ -0,0 +1,168 @@
import os
import joblib
# =========================================================
# KONFIGURASI MODEL
# =========================================================
BASE_DIR = os.path.dirname(
os.path.dirname(
os.path.abspath(__file__)
)
)
MODEL_DIR = os.path.join(
BASE_DIR,
"models"
)
MODEL_PATH = os.path.join(
MODEL_DIR,
"model_naive_bayes.pkl"
)
# =========================================================
# LOAD MODEL
# =========================================================
if not os.path.exists(MODEL_PATH):
raise FileNotFoundError(
f"Model tidak ditemukan: {MODEL_PATH}"
)
try:
model_final = joblib.load(MODEL_PATH)
except Exception as e:
raise RuntimeError(
f"Gagal memuat model: {str(e)}"
)
# =========================================================
# VALIDASI PIPELINE
# =========================================================
if not hasattr(model_final, "named_steps"):
raise ValueError(
"Model harus berupa Pipeline "
"TF-IDF + MultinomialNB."
)
# Pastikan step TF-IDF tersedia
if "tfidf" not in model_final.named_steps:
raise ValueError(
"Pipeline tidak memiliki step 'tfidf'."
)
# Pastikan step Naïve Bayes tersedia
if "nb" not in model_final.named_steps:
raise ValueError(
"Pipeline tidak memiliki step 'nb'."
)
# =========================================================
# AMBIL KOMPONEN MODEL
# =========================================================
tfidf_vectorizer = model_final.named_steps["tfidf"]
nb_model = model_final.named_steps["nb"]
# =========================================================
# PREDIKSI SENTIMEN
# =========================================================
def predict_sentiment(text):
if not text or not str(text).strip():
raise ValueError(
"Teks komentar tidak boleh kosong."
)
text = str(text).strip()
# Prediksi
prediction = model_final.predict([text])[0]
# Probabilitas
probabilities = model_final.predict_proba([text])[0]
# Konversi label
sentiment = (
"Positif"
if int(prediction) == 1
else "Negatif"
)
# Probabilitas kelas yang diprediksi
probability = float(
probabilities[
list(model_final.classes_).index(prediction)
]
)
return {
"label": int(prediction),
"sentiment": sentiment,
"probability": round(
probability,
6
)
}
# =========================================================
# TF-IDF SATU KOMENTAR
# =========================================================
def get_tfidf(text):
if not text or not str(text).strip():
return []
text = str(text).strip()
# Transform menggunakan vectorizer
# yang SUDAH FIT dari model final
vector = tfidf_vectorizer.transform(
[text]
)
feature_names = (
tfidf_vectorizer
.get_feature_names_out()
)
hasil = []
# Karena sparse matrix,
# ambil hanya fitur yang nilainya > 0
row = vector[0]
for col_index, nilai in zip(
row.indices,
row.data
):
hasil.append({
"kata": feature_names[col_index],
"nilai": round(
float(nilai),
6
)
})
# Urutkan berdasarkan nilai TF-IDF terbesar
hasil.sort(
key=lambda x: x["nilai"],
reverse=True
)
return hasil

96
backend/app/models.py Normal file
View File

@ -0,0 +1,96 @@
from sqlalchemy import (
Column,
Integer,
Text,
String,
DateTime,
Float
)
from datetime import datetime
from .database import Base
class Komentar(Base):
__tablename__ = "komentar"
id = Column(
Integer,
primary_key=True,
index=True
)
komentar = Column(
Text,
nullable=False
)
case_folding = Column(
Text,
nullable=True
)
normalisasi = Column(
Text,
nullable=True
)
cleaning = Column(
Text,
nullable=True
)
tokenization = Column(
Text,
nullable=True
)
stopword_removal = Column(
Text,
nullable=True
)
stemming = Column(
Text,
nullable=True
)
text_preprocessed = Column(
Text,
nullable=True
)
tfidf = Column(
Text,
nullable=True
)
sentiment = Column(
String(20),
nullable=True
)
probability = Column(
Float,
nullable=True
)
created_at = Column(
DateTime,
default=datetime.utcnow
)

View File

@ -0,0 +1,715 @@
import re
import nltk
from nltk.tokenize import word_tokenize
from Sastrawi.Stemmer.StemmerFactory import (
StemmerFactory
)
from Sastrawi.StopWordRemover.StopWordRemoverFactory import (
StopWordRemoverFactory
)
# =========================================================
# NLTK
# =========================================================
nltk.download("punkt", quiet=True)
nltk.download("punkt_tab", quiet=True)
# =========================================================
# STEMMER
# =========================================================
factory_stemmer = StemmerFactory()
stemmer = (
factory_stemmer
.create_stemmer()
)
# =========================================================
# STOPWORD
# =========================================================
factory_stopword = StopWordRemoverFactory()
stopwords = set(
factory_stopword.get_stop_words()
)
# =========================================================
# KATA PENTING
# =========================================================
# Kata-kata ini dikeluarkan dari daftar stopword
# agar tetap dipertahankan karena memiliki konteks
# terhadap sentimen.
kata_penting = {
# -------------------------
# Negasi
# -------------------------
"tidak",
"bukan",
"belum",
"jangan",
"kurang",
# -------------------------
# Konteks waktu dan kondisi
# -------------------------
"masih",
"sudah",
"sering",
"jarang",
"kadang",
"lagi",
"sebelum",
"setelah",
"kembali",
# -------------------------
# Konjungsi yang membawa konteks
# -------------------------
"namun",
"tetapi",
"meskipun",
"walaupun",
# -------------------------
# Intensitas
# -------------------------
"sangat",
"cukup",
"terlalu",
# -------------------------
# Kondisi dan kemampuan
# -------------------------
"hanya",
"tanpa",
"harus",
"bisa",
"dapat",
"perlu",
"seharusnya",
# -------------------------
# Keinginan
# -------------------------
"ingin"
}
# Hapus kata penting dari stopword
stopwords = stopwords - kata_penting
# Kata tambahan yang tidak membawa informasi sentimen
stopwords.update({
"nya"
})
# =========================================================
# KAMUS NORMALISASI
# =========================================================
kamus_normalisasi = {
# -------------------------
# Singkatan
# -------------------------
"yg": "yang",
"yng": "yang",
"dgn": "dengan",
"utk": "untuk",
# -------------------------
# Negasi
# -------------------------
"tdk": "tidak",
"gak": "tidak",
"ga": "tidak",
"nggak": "tidak",
"ngga": "tidak",
"enggak": "tidak",
# -------------------------
# Kata umum
# -------------------------
"krn": "karena",
"karna": "karena",
"dr": "dari",
"jd": "jadi",
"jg": "juga",
"tp": "tetapi",
"sm": "sama",
"sdh": "sudah",
"udh": "sudah",
"udah": "sudah",
"blm": "belum",
"belom": "belum",
"bgt": "banget",
"aja": "saja",
"sbg": "sebagai",
"dpt": "dapat",
"bbrp": "beberapa",
# -------------------------
# Kata tanya
# -------------------------
"gmn": "bagaimana",
"gimana": "bagaimana",
# -------------------------
# Kata kondisi
# -------------------------
"klo": "kalau",
"kalo": "kalau",
"kalok": "kalau",
# -------------------------
# Kata informal
# -------------------------
"emg": "memang",
"emang": "memang",
"kayak": "seperti",
"kaya": "seperti",
# -------------------------
# Kata khusus penelitian
# -------------------------
"terkadang": "kadang"
}
# =========================================================
# CASE FOLDING
# =========================================================
def case_folding(text):
return str(text).lower()
# =========================================================
# NORMALISASI
# =========================================================
def normalisasi(text):
text = str(text).lower()
# =====================================================
# S-LEARN
# =====================================================
# S-Learn
# S Learn
# Slearn
# s-learn
text = re.sub(
r"\bs[\s-]*learn\b",
"slearn",
text
)
# =====================================================
# LOGIN
# =====================================================
# log in
# log-in
# login
text = re.sub(
r"\blog[\s-]*in\b",
"login",
text
)
# =====================================================
# LOGOUT
# =====================================================
# log out
# log-out
# logout
text = re.sub(
r"\blog[\s-]*out\b",
"logout",
text
)
# =====================================================
# CHECK IN
# =====================================================
# check in
# check-in
# checkin
text = re.sub(
r"\bcheck[\s-]+in\b",
"checkin",
text
)
# =====================================================
# HP-NYA
# =====================================================
text = re.sub(
r"\bhpnya\b",
"hp nya",
text
)
text = re.sub(
r"\bhp\s+nya\b",
"hp nya",
text
)
# =====================================================
# PENGULANGAN HURUF
# =====================================================
#
# bagusss -> bagus
# lamaaa -> lama
# lambattt -> lambat
#
# Hanya huruf yang berulang 3 kali atau lebih
# yang dikurangi.
text = re.sub(
r"([a-z])\1{2,}",
r"\1",
text
)
# =====================================================
# KAMUS NORMALISASI
# =====================================================
words = text.split()
words = [
kamus_normalisasi.get(
word,
word
)
for word in words
]
return " ".join(words)
# =========================================================
# CLEANING
# =========================================================
def cleaning(text):
text = str(text)
# =====================================================
# URL
# =====================================================
text = re.sub(
r"http\S+|www\S+",
" ",
text
)
# =====================================================
# MENTION
# =====================================================
text = re.sub(
r"@\w+",
" ",
text
)
# =====================================================
# HASHTAG
# =====================================================
text = re.sub(
r"#\w+",
" ",
text
)
# =====================================================
# ANGKA
# =====================================================
text = re.sub(
r"\d+",
" ",
text
)
# =====================================================
# SIMBOL DAN TANDA BACA
# =====================================================
text = re.sub(
r"[^a-zA-Z\s]",
" ",
text
)
# =====================================================
# SPASI BERLEBIH
# =====================================================
text = re.sub(
r"\s+",
" ",
text
).strip()
return text
# =========================================================
# TOKENIZATION
# =========================================================
def tokenisasi(text):
if not str(text).strip():
return []
return word_tokenize(
str(text)
)
# =========================================================
# STOPWORD REMOVAL
# =========================================================
def stopword_removal(tokens):
return [
word
for word in tokens
if word not in stopwords
]
# =========================================================
# ISTILAH TEKNIS
# =========================================================
# Istilah berikut tidak dilakukan stemming karena
# merupakan istilah teknis yang digunakan pada
# konteks aplikasi S-Learn.
istilah_teknis = {
"slearn",
"mobile",
"error",
"lag",
"bug",
"online",
"offline",
"deadline",
"server",
"loading",
"website",
"login",
"logout",
"ios",
"android",
"update",
"ui",
"ux",
"user",
"interface",
"upload",
"download",
"menu",
"button",
"file",
"link",
"feedback",
"friendly",
"dashboard",
"password",
"username",
"checkin",
"refresh",
"performance",
"responsive",
"submit",
"session",
"support"
}
# =========================================================
# KAMUS PENGECUALIAN STEMMING
# =========================================================
kamus_pengecualian_stemming = {
# =====================================================
# KATA YANG JANGAN DIUBAH
# KARENA STEMMING DAPAT MERUSAK MAKNA
# =====================================================
"terasa": "terasa",
"terdapat": "terdapat",
"pengalaman": "pengalaman",
"mengenai": "mengenai",
"pengumuman": "pengumuman",
"bersamaan": "bersamaan",
"berbagai": "berbagai",
"melalui": "melalui",
"sebelumnya": "sebelumnya",
"pembaruan": "pembaruan",
"kegiatan": "kegiatan",
"tersedia": "tersedia",
"tampilan": "tampilan",
"pengguna": "pengguna",
"ketergantungan": "ketergantungan",
"dibandingkan": "dibandingkan",
"menarik": "menarik",
"melakukan": "melakukan",
"setalah": "setelah",
"terjadi": "terjadi",
"digunakan": "digunakan",
"tampilannya": "tampilan",
"dikembangkan": "dikembangkan",
"melakukan": "melakukan",
"berikutnya": "berikutnya",
"diperbaiki": "diperbaiki",
"diperbarui": "diperbarui",
"dilakukan": "dilakukan",
"pengembangan": "pengembangan",
"sebagian": "sebagian",
"menggunakannya": "menggunakan",
"menggunakan": "menggunakan",
"semoga": "semoga",
"memadai": "memadai",
"lemot": "lemot",
# =====================================================
# KATA YANG MEMANG DIINGINKAN
# MENJADI BENTUK TERTENTU
# =====================================================
"berurutan": "urut",
"leluasa": "luas"
}
# =========================================================
# STEMMING
# =========================================================
def stemming(tokens):
hasil = []
for word in tokens:
# -------------------------------------------------
# 1. Istilah teknis Inggris
# -------------------------------------------------
if word in istilah_teknis:
hasil.append(
word
)
# -------------------------------------------------
# 2. Kata yang harus dipertahankan
# -------------------------------------------------
elif word in kamus_pengecualian_stemming:
hasil.append(
kamus_pengecualian_stemming[word]
)
# -------------------------------------------------
# 3. Stemming normal Sastrawi
# -------------------------------------------------
else:
hasil.append(
stemmer.stem(word)
)
return hasil
# =========================================================
# FULL PREPROCESSING
# =========================================================
def preprocess_text(text):
# =====================================================
# 1. CASE FOLDING
# =====================================================
hasil_case_folding = case_folding(
text
)
# =====================================================
# 2. NORMALISASI
# =====================================================
hasil_normalisasi = normalisasi(
hasil_case_folding
)
# =====================================================
# 3. CLEANING
# =====================================================
hasil_cleaning = cleaning(
hasil_normalisasi
)
# =====================================================
# 4. TOKENIZATION
# =====================================================
hasil_tokenization = tokenisasi(
hasil_cleaning
)
# =====================================================
# 5. STOPWORD REMOVAL
# =====================================================
hasil_stopword = stopword_removal(
hasil_tokenization
)
# =====================================================
# 6. STEMMING
# =====================================================
hasil_stemming = stemming(
hasil_stopword
)
# =====================================================
# 7. TEXT PREPROCESSED
# =====================================================
hasil_preprocessed = " ".join(
hasil_stemming
)
# =====================================================
# RETURN HASIL SETIAP TAHAP
# =====================================================
return {
"case_folding":
hasil_case_folding,
"normalisasi":
hasil_normalisasi,
"cleaning":
hasil_cleaning,
"tokenization":
" ".join(
hasil_tokenization
),
"stopword_removal":
" ".join(
hasil_stopword
),
"stemming":
" ".join(
hasil_stemming
),
"text_preprocessed":
hasil_preprocessed
}

View File

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,89 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from ..database import get_db
from ..models import Komentar
router = APIRouter(
prefix="/dashboard",
tags=["Dashboard"]
)
# =========================================================
# RINGKASAN DASHBOARD
# =========================================================
@router.get("/summary")
def get_summary(
db: Session = Depends(get_db)
):
total = db.query(Komentar).count()
positif = db.query(Komentar).filter(
Komentar.sentiment == "Positif"
).count()
negatif = db.query(Komentar).filter(
Komentar.sentiment == "Negatif"
).count()
if total > 0:
persentase_positif = (
positif / total
) * 100
persentase_negatif = (
negatif / total
) * 100
else:
persentase_positif = 0
persentase_negatif = 0
return {
"total": total,
"positif": positif,
"negatif": negatif,
"persentase_positif": round(
persentase_positif,
2
),
"persentase_negatif": round(
persentase_negatif,
2
)
}
# =========================================================
# DATA DASHBOARD
# =========================================================
@router.get("/data")
def get_dashboard_data(
db: Session = Depends(get_db)
):
data = db.query(Komentar).all()
hasil = []
for item in data:
hasil.append({
"id": item.id,
"komentar": item.komentar,
"text_preprocessed":
item.text_preprocessed,
"sentiment":
item.sentiment,
"probability":
item.probability
})
return hasil

150
backend/app/routes/data.py Normal file
View File

@ -0,0 +1,150 @@
from fastapi import (
APIRouter,
Depends,
HTTPException,
UploadFile,
File
)
from sqlalchemy.orm import Session
import pandas as pd
from io import StringIO
from ..database import get_db
from ..models import Komentar
from ..schemas import KomentarCreate, KomentarResponse
router = APIRouter(
prefix="/data",
tags=["Kelola Data"]
)
# =========================================================
# GET SEMUA DATA
# =========================================================
@router.get("/", response_model=list[KomentarResponse])
def get_all_data(
db: Session = Depends(get_db)
):
data = db.query(Komentar).all()
return data
# =========================================================
# TAMBAH DATA
# =========================================================
@router.post("/", response_model=KomentarResponse)
def create_data(
data: KomentarCreate,
db: Session = Depends(get_db)
):
komentar_baru = Komentar(
komentar=data.komentar
)
db.add(komentar_baru)
db.commit()
db.refresh(komentar_baru)
return komentar_baru
# =========================================================
# HAPUS DATA
# =========================================================
@router.delete("/{id}")
def delete_data(
id: int,
db: Session = Depends(get_db)
):
data = db.query(Komentar).filter(
Komentar.id == id
).first()
if not data:
raise HTTPException(
status_code=404,
detail="Data komentar tidak ditemukan"
)
db.delete(data)
db.commit()
return {
"message": "Data berhasil dihapus"
}
# =========================================================
# IMPORT CSV
# =========================================================
@router.post("/import-csv")
async def import_csv(
file: UploadFile = File(...),
db: Session = Depends(get_db)
):
# Cek format file
if not file.filename.endswith(".csv"):
raise HTTPException(
status_code=400,
detail="File harus berformat CSV"
)
# Baca file
contents = await file.read()
try:
df = pd.read_csv(
StringIO(
contents.decode("utf-8")
)
)
except Exception:
raise HTTPException(
status_code=400,
detail="File CSV tidak dapat dibaca"
)
# Cek kolom komentar
if "komentar" not in df.columns:
raise HTTPException(
status_code=400,
detail="CSV harus memiliki kolom 'komentar'"
)
jumlah_data = 0
# Masukkan data ke database
for _, row in df.iterrows():
komentar = str(
row["komentar"]
).strip()
if komentar:
data_baru = Komentar(
komentar=komentar
)
db.add(data_baru)
jumlah_data += 1
db.commit()
return {
"message": "Import CSV berhasil",
"jumlah_data": jumlah_data
}

View File

@ -0,0 +1,134 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..models import Komentar
from ..preprocessing import preprocess_text
from ..ml_model import predict_sentiment, get_tfidf
router = APIRouter(
prefix="/prediction",
tags=["Klasifikasi Sentimen"]
)
@router.post("/all")
def predict_all_data(
db: Session = Depends(get_db)
):
data = db.query(Komentar).all()
if not data:
return {
"message": "Tidak ada data komentar",
"jumlah_data": 0,
"positif": 0,
"negatif": 0
}
jumlah_positif = 0
jumlah_negatif = 0
for item in data:
# =========================
# PREPROCESSING
# =========================
hasil = preprocess_text(item.komentar)
item.case_folding = hasil["case_folding"]
item.normalisasi = hasil["normalisasi"]
item.cleaning = hasil["cleaning"]
item.tokenization = hasil["tokenization"]
item.stopword_removal = hasil["stopword_removal"]
item.stemming = hasil["stemming"]
item.text_preprocessed = hasil["text_preprocessed"]
# =========================
# TF-IDF
# =========================
hasil_tfidf = get_tfidf(
item.text_preprocessed
)
item.tfidf = str(hasil_tfidf)
# =========================
# NAÏVE BAYES
# =========================
hasil_prediksi = predict_sentiment(
item.text_preprocessed
)
item.sentiment = hasil_prediksi["sentiment"]
item.probability = hasil_prediksi["probability"]
# =========================
# HITUNG JUMLAH SENTIMEN
# =========================
if item.sentiment == "Positif":
jumlah_positif += 1
else:
jumlah_negatif += 1
db.commit()
return {
"message": "Prediksi seluruh data berhasil",
"jumlah_data": len(data),
"positif": jumlah_positif,
"negatif": jumlah_negatif
}
@router.post("/{id}")
def predict_data(
id: int,
db: Session = Depends(get_db)
):
data = (
db.query(Komentar)
.filter(Komentar.id == id)
.first()
)
if not data:
raise HTTPException(
status_code=404,
detail="Data komentar tidak ditemukan"
)
hasil = preprocess_text(data.komentar)
data.case_folding = hasil["case_folding"]
data.normalisasi = hasil["normalisasi"]
data.cleaning = hasil["cleaning"]
data.tokenization = hasil["tokenization"]
data.stopword_removal = hasil["stopword_removal"]
data.stemming = hasil["stemming"]
data.text_preprocessed = hasil["text_preprocessed"]
hasil_tfidf = get_tfidf(
data.text_preprocessed
)
data.tfidf = str(hasil_tfidf)
hasil_prediksi = predict_sentiment(
data.text_preprocessed
)
data.sentiment = hasil_prediksi["sentiment"]
data.probability = hasil_prediksi["probability"]
db.commit()
db.refresh(data)
return {
"id": data.id,
"komentar": data.komentar,
"text_preprocessed": data.text_preprocessed,
"tfidf": hasil_tfidf,
"sentiment": data.sentiment,
"probability": data.probability
}

View File

@ -0,0 +1,184 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from ..database import get_db
from ..models import Komentar
from ..preprocessing import (
preprocess_text
)
# =========================================================
# ROUTER
# =========================================================
router = APIRouter(
prefix="/preprocessing",
tags=["Pre-processing"]
)
# =========================================================
# RUN PREPROCESSING
# =========================================================
@router.post("/")
def run_preprocessing(
db: Session = Depends(get_db)
):
# =====================================================
# AMBIL HANYA DATA YANG BELUM PREPROCESSING
# =====================================================
data = (
db.query(Komentar)
.filter(
(Komentar.text_preprocessed.is_(None)) |
(Komentar.text_preprocessed == "")
)
.all()
)
# =====================================================
# JIKA TIDAK ADA DATA BARU
# =====================================================
if not data:
return {
"message":
"Tidak ada data baru yang perlu diproses",
"jumlah_data":
0
}
# =====================================================
# PROSES PREPROCESSING
# =====================================================
jumlah_data = 0
for item in data:
# -------------------------------------------------
# Jalankan seluruh tahapan preprocessing
# -------------------------------------------------
hasil = preprocess_text(
item.komentar
)
# -------------------------------------------------
# Simpan hasil Case Folding
# -------------------------------------------------
item.case_folding = (
hasil["case_folding"]
)
# -------------------------------------------------
# Simpan hasil Normalisasi
# -------------------------------------------------
item.normalisasi = (
hasil["normalisasi"]
)
# -------------------------------------------------
# Simpan hasil Cleaning
# -------------------------------------------------
item.cleaning = (
hasil["cleaning"]
)
# -------------------------------------------------
# Simpan hasil Tokenization
# -------------------------------------------------
item.tokenization = (
hasil["tokenization"]
)
# -------------------------------------------------
# Simpan hasil Stopword Removal
# -------------------------------------------------
item.stopword_removal = (
hasil["stopword_removal"]
)
# -------------------------------------------------
# Simpan hasil Stemming
# -------------------------------------------------
item.stemming = (
hasil["stemming"]
)
# -------------------------------------------------
# Simpan hasil akhir preprocessing
# -------------------------------------------------
item.text_preprocessed = (
hasil["text_preprocessed"]
)
# =================================================
# TIDAK MENGHAPUS TF-IDF LAMA
# =================================================
#
# Data yang diproses di sini adalah data baru,
# sehingga TF-IDF-nya memang belum ada.
#
# TF-IDF komentar lama TETAP dipertahankan.
#
# =================================================
# RESET KLASIFIKASI UNTUK DATA BARU
# =================================================
item.sentiment = None
item.probability = None
jumlah_data += 1
# =====================================================
# SIMPAN PERUBAHAN KE DATABASE
# =====================================================
db.commit()
# =====================================================
# RESPONSE
# =====================================================
return {
"message":
"Pre-processing berhasil",
"jumlah_data":
jumlah_data
}

416
backend/app/routes/tfidf.py Normal file
View File

@ -0,0 +1,416 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import or_
import json
import ast
from ..database import get_db
from ..models import Komentar
from ..ml_model import tfidf_vectorizer
router = APIRouter(
prefix="/tfidf",
tags=["TF-IDF"]
)
# =========================================================
# FUNGSI MEMBACA DATA TF-IDF
# =========================================================
def parse_tfidf_data(tfidf_value):
"""
Membaca data TF-IDF dari database.
Mendukung beberapa format:
1. Format JSON object:
{
"habis": 0.357312,
"notifikasi": 0.214512
}
2. Format JSON/Python list:
[
{
"kata": "habis",
"nilai": 0.357312
}
]
3. Format lama Python list dengan single quote:
[
{'kata': 'habis', 'nilai': 0.357312}
]
Hasil akhir selalu dikembalikan sebagai object:
{
"habis": 0.357312,
"notifikasi": 0.214512
}
"""
# -----------------------------------------------------
# Jika tidak ada data
# -----------------------------------------------------
if not tfidf_value:
return {}
# -----------------------------------------------------
# Jika data sudah berupa dictionary
# -----------------------------------------------------
if isinstance(tfidf_value, dict):
return tfidf_value
# -----------------------------------------------------
# Jika data berupa list
# -----------------------------------------------------
if isinstance(tfidf_value, list):
hasil = {}
for item in tfidf_value:
if not isinstance(item, dict):
continue
kata = item.get("kata")
nilai = item.get("nilai")
if kata is None or nilai is None:
continue
try:
hasil[str(kata)] = float(nilai)
except (TypeError, ValueError):
continue
return hasil
# -----------------------------------------------------
# Pastikan data berupa string
# -----------------------------------------------------
if not isinstance(tfidf_value, str):
return {}
tfidf_value = tfidf_value.strip()
if not tfidf_value:
return {}
# -----------------------------------------------------
# COBA BACA SEBAGAI JSON
# -----------------------------------------------------
try:
parsed = json.loads(tfidf_value)
# ---------------------------------------------
# JSON object
# ---------------------------------------------
if isinstance(parsed, dict):
hasil = {}
for kata, nilai in parsed.items():
try:
hasil[str(kata)] = float(nilai)
except (TypeError, ValueError):
continue
return hasil
# ---------------------------------------------
# JSON list
# ---------------------------------------------
if isinstance(parsed, list):
hasil = {}
for item in parsed:
if not isinstance(item, dict):
continue
kata = item.get("kata")
nilai = item.get("nilai")
if kata is None or nilai is None:
continue
try:
hasil[str(kata)] = float(nilai)
except (TypeError, ValueError):
continue
return hasil
except (json.JSONDecodeError, TypeError, ValueError):
pass
# -----------------------------------------------------
# COBA FORMAT LAMA PYTHON
# Contoh:
#
# [{'kata': 'habis', 'nilai': 0.357312}]
# -----------------------------------------------------
try:
parsed = ast.literal_eval(tfidf_value)
# ---------------------------------------------
# Dictionary
# ---------------------------------------------
if isinstance(parsed, dict):
hasil = {}
for kata, nilai in parsed.items():
try:
hasil[str(kata)] = float(nilai)
except (TypeError, ValueError):
continue
return hasil
# ---------------------------------------------
# List
# ---------------------------------------------
if isinstance(parsed, list):
hasil = {}
for item in parsed:
if not isinstance(item, dict):
continue
kata = item.get("kata")
nilai = item.get("nilai")
if kata is None or nilai is None:
continue
try:
hasil[str(kata)] = float(nilai)
except (TypeError, ValueError):
continue
return hasil
except (ValueError, SyntaxError, TypeError):
pass
# -----------------------------------------------------
# Jika semua cara gagal
# -----------------------------------------------------
return {}
# =========================================================
# GET DATA TF-IDF
# =========================================================
@router.get("/")
def get_tfidf_data(
db: Session = Depends(get_db)
):
data = db.query(Komentar).all()
hasil = []
for item in data:
# ---------------------------------------------
# Baca TF-IDF dari database
# ---------------------------------------------
tfidf_data = parse_tfidf_data(
item.tfidf
)
hasil.append({
"id": item.id,
"komentar": item.komentar,
"text_preprocessed": item.text_preprocessed,
"tfidf": tfidf_data
})
return {
"jumlah_data": len(hasil),
"data": hasil
}
# =========================================================
# CALCULATE TF-IDF
# =========================================================
@router.post("/calculate")
def calculate_tfidf(
db: Session = Depends(get_db)
):
# -----------------------------------------------------
# -----------------------------------------------------
# AMBIL HANYA DATA BARU YANG:
# 1. SUDAH MELALUI PREPROCESSING
# 2. BELUM MEMILIKI TF-IDF
#
# TF-IDF lama TETAP DISIMPAN dan tidak dihitung ulang.
# -----------------------------------------------------
data = db.query(Komentar).filter(
Komentar.text_preprocessed.isnot(None),
Komentar.text_preprocessed != "",
or_(
Komentar.tfidf.is_(None),
Komentar.tfidf == ""
)
).all()
if not data:
return {
"message": "Tidak ada data baru yang perlu dihitung TF-IDF",
"jumlah_data": 0,
"jumlah_fitur": 0
}
# PASTIKAN VECTORIZER TERSEDIA
# -----------------------------------------------------
if tfidf_vectorizer is None:
raise HTTPException(
status_code=500,
detail="TF-IDF vectorizer belum tersedia."
)
# -----------------------------------------------------
# AMBIL TEXT HASIL PREPROCESSING
# -----------------------------------------------------
texts = [
item.text_preprocessed
for item in data
]
# -----------------------------------------------------
# TRANSFORM MENGGUNAKAN VECTORIZER
# YANG SUDAH FIT DARI MODEL
# -----------------------------------------------------
try:
tfidf_matrix = (
tfidf_vectorizer.transform(texts)
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=(
f"Gagal menghitung TF-IDF: {str(e)}"
)
)
# -----------------------------------------------------
# AMBIL NAMA FITUR
# -----------------------------------------------------
feature_names = (
tfidf_vectorizer
.get_feature_names_out()
)
# -----------------------------------------------------
# SIMPAN TF-IDF SETIAP KOMENTAR
# -----------------------------------------------------
for index, item in enumerate(data):
row = tfidf_matrix[index]
tfidf_dict = {}
# ---------------------------------------------
# Ambil fitur yang memiliki nilai > 0
# ---------------------------------------------
for col_index, value in zip(
row.indices,
row.data
):
value = float(value)
if value <= 0:
continue
kata = feature_names[col_index]
tfidf_dict[str(kata)] = round(
value,
6
)
# ---------------------------------------------
# Simpan sebagai JSON
#
# Format:
#
# {
# "kata": nilai
# }
# ---------------------------------------------
item.tfidf = json.dumps(
tfidf_dict,
ensure_ascii=False
)
# -----------------------------------------------------
# SIMPAN KE DATABASE
# -----------------------------------------------------
try:
db.commit()
except Exception as e:
db.rollback()
raise HTTPException(
status_code=500,
detail=(
f"Gagal menyimpan TF-IDF: {str(e)}"
)
)
# -----------------------------------------------------
# RESPONSE
# -----------------------------------------------------
return {
"message": "TF-IDF data baru berhasil dihitung",
"jumlah_data": len(data),
"jumlah_fitur": len(feature_names)
}

39
backend/app/schemas.py Normal file
View File

@ -0,0 +1,39 @@
from pydantic import BaseModel
from datetime import datetime
class KomentarCreate(BaseModel):
komentar: str
class KomentarResponse(BaseModel):
id: int
komentar: str
case_folding: str | None = None
normalisasi: str | None = None
cleaning: str | None = None
tokenization: str | None = None
stopword_removal: str | None = None
stemming: str | None = None
text_preprocessed: str | None = None
tfidf: str | None = None
sentiment: str | None = None
probability: float | None = None
created_at: datetime | None = None
class Config:
from_attributes = True

Binary file not shown.

830
backend/package-lock.json generated Normal file
View File

@ -0,0 +1,830 @@
{
"name": "backend",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"axios": "^1.20.0",
"react-router-dom": "^7.18.3",
"recharts": "^3.10.1"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
"integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.6",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
"integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks",
"tests/types",
"tests/browser-compat"
]
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/immer": {
"version": "11.1.18",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
"integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.8"
}
},
"node_modules/react-is": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz",
"integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-router": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz",
"integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz",
"integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.3"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/recharts": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
"integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^11.1.8",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.2.0",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT",
"peer": true
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
}
}
}

7
backend/package.json Normal file
View File

@ -0,0 +1,7 @@
{
"dependencies": {
"axios": "^1.20.0",
"react-router-dom": "^7.18.3",
"recharts": "^3.10.1"
}
}

11
backend/requirements.txt Normal file
View File

@ -0,0 +1,11 @@
fastapi
uvicorn
pandas
numpy
scikit-learn
Sastrawi
nltk
SQLAlchemy
PyMySQL
python-multipart
joblib

23
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

70
frontend/README.md Normal file
View File

@ -0,0 +1,70 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
The page will reload when you make changes.\
You may also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

17861
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
frontend/package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^13.5.0",
"axios": "^1.20.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.3",
"react-scripts": "5.0.1",
"recharts": "^3.10.1",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
frontend/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
frontend/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

4130
frontend/src/App.css Normal file

File diff suppressed because it is too large Load Diff

66
frontend/src/App.js Normal file
View File

@ -0,0 +1,66 @@
import React from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Navbar from "./components/Navbar";
import Dashboard from "./pages/Dashboard";
import Data from "./pages/Data";
import Preprocessing from "./pages/Preprocessing";
import TFIDF from "./pages/TFIDF";
import Classification from "./pages/Classification";
import UserGuide from "./pages/UserGuide";
import "./App.css";
function App() {
return (
<BrowserRouter>
<Navbar />
<Routes>
{/* Dashboard */}
<Route
path="/"
element={<Dashboard />}
/>
{/* Kelola Data */}
<Route
path="/data"
element={<Data />}
/>
{/* Pre-processing */}
<Route
path="/preprocessing"
element={<Preprocessing />}
/>
{/* TF-IDF */}
<Route
path="/tfidf"
element={<TFIDF />}
/>
{/* Klasifikasi Sentimen */}
<Route
path="/sentiment"
element={<Classification />}
/>
{/* User Guide */}
<Route
path="/user-guide"
element={<UserGuide />}
/>
</Routes>
</BrowserRouter>
);
}
export default App;

8
frontend/src/App.test.js Normal file
View File

@ -0,0 +1,8 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@ -0,0 +1,169 @@
import React, { useEffect, useState } from "react";
import { NavLink } from "react-router-dom";
function Navbar() {
const [currentDate, setCurrentDate] = useState(new Date());
/* =====================================================
UPDATE TANGGAL
===================================================== */
useEffect(() => {
const timer = setInterval(() => {
setCurrentDate(new Date());
}, 60000);
return () => clearInterval(timer);
}, []);
/* =====================================================
FORMAT TANGGAL INDONESIA
===================================================== */
const getDateInformation = (date) => {
const hari = [
"Minggu",
"Senin",
"Selasa",
"Rabu",
"Kamis",
"Jumat",
"Sabtu"
];
const bulan = [
"Januari",
"Februari",
"Maret",
"April",
"Mei",
"Juni",
"Juli",
"Agustus",
"September",
"Oktober",
"November",
"Desember"
];
return {
hari: hari[date.getDay()],
tanggal: date.getDate(),
bulan: bulan[date.getMonth()],
tahun: date.getFullYear()
};
};
const dateInfo = getDateInformation(currentDate);
/* =====================================================
NAVIGATION
===================================================== */
const navClass = ({ isActive }) =>
isActive
? "nav-item active"
: "nav-item";
/* =====================================================
RENDER
===================================================== */
return (
<header className="navbar">
<div className="navbar-inner">
{/* BRAND */}
<div className="brand">
<div className="brand-mark">
S
</div>
<div className="brand-text">
<div className="brand-name">
S-Learn
</div>
<div className="brand-subtitle">
SENTIMENT STUDY
</div>
</div>
</div>
{/* NAVIGATION */}
<nav className="navigation">
<NavLink
to="/"
className={navClass}
end
>
Overview
</NavLink>
<NavLink
to="/data"
className={navClass}
>
Data
</NavLink>
<NavLink
to="/preprocessing"
className={navClass}
>
Pre-Processing
</NavLink>
<NavLink
to="/tfidf"
className={navClass}
>
TF-IDF
</NavLink>
<NavLink
to="/sentiment"
className={navClass}
>
Classification
</NavLink>
<NavLink
to="/user-guide"
className={navClass}
>
User Guide
</NavLink>
</nav>
{/* DATE */}
<div className="system-status">
<span className="date-day">
{dateInfo.hari}
</span>
<span className="date-full">
{dateInfo.tanggal} {dateInfo.bulan} {dateInfo.tahun}
</span>
</div>
</div>
</header>
);
}
export default Navbar;

View File

@ -0,0 +1,86 @@
import React from "react";
import {
PieChart,
Pie,
Cell,
Tooltip,
Legend,
ResponsiveContainer
} from "recharts";
function SentimentChart({
positif,
negatif
}) {
const data = [
{
name: "Positif",
value: positif
},
{
name: "Negatif",
value: negatif
}
];
return (
<div className="chart-card">
<div className="card-header">
<div>
<h3>
Distribusi Sentimen
</h3>
<p>
Perbandingan sentimen mahasiswa
</p>
</div>
</div>
<div className="chart-container">
<ResponsiveContainer
width="100%"
height={300}
>
<PieChart>
<Pie
data={data}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={65}
outerRadius={100}
paddingAngle={3}
label
>
<Cell fill="#4f46e5" />
<Cell fill="#ef4444" />
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</div>
);
}
export default SentimentChart;

View File

@ -0,0 +1,32 @@
import React from "react";
function StatCard({
title,
value,
description,
type
}) {
return (
<div className={`stat-card ${type}`}>
<div className="stat-card-content">
<p className="stat-title">
{title}
</p>
<h2 className="stat-value">
{value}
</h2>
<p className="stat-description">
{description}
</p>
</div>
</div>
);
}
export default StatCard;

7
frontend/src/index.css Normal file
View File

@ -0,0 +1,7 @@
html {
scroll-behavior: smooth;
}
body {
margin: 0;
}

17
frontend/src/index.js Normal file
View File

@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

1
frontend/src/logo.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,949 @@
import React, { useEffect, useRef, useState } from "react";
import api from "../services/api";
function Classification() {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [classifying, setClassifying] = useState(false);
const [error, setError] = useState("");
const [result, setResult] = useState(null);
// =========================================================
// PAGINATION
// =========================================================
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const dataTableRef = useRef(null);
const totalPages = Math.ceil(
data.length / itemsPerPage
);
const startIndex =
(currentPage - 1) * itemsPerPage;
const endIndex =
startIndex + itemsPerPage;
const currentData =
data.slice(startIndex, endIndex);
// =========================================================
// GET DATA
// =========================================================
const getData = async () => {
try {
setLoading(true);
setError("");
const response =
await api.get("/data/");
setData(response.data);
// Kembali ke halaman pertama
// setelah data diperbarui
setCurrentPage(1);
} catch (err) {
console.error(err);
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
"Data klasifikasi tidak dapat diambil dari server.";
setError(message);
} finally {
setLoading(false);
}
};
useEffect(() => {
getData();
}, []);
// =========================================================
// CLASSIFICATION
// =========================================================
const handleClassification = async () => {
try {
setClassifying(true);
setError("");
setResult(null);
const response =
await api.post("/prediction/all");
setResult(response.data);
await getData();
} catch (err) {
console.error(err);
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
"Klasifikasi sentimen gagal dilakukan.";
setError(message);
} finally {
setClassifying(false);
}
};
// =========================================================
// SUMMARY
// =========================================================
const total = data.length;
const positif = data.filter(
(item) =>
item.sentiment === "Positif"
).length;
const negatif = data.filter(
(item) =>
item.sentiment === "Negatif"
).length;
// =========================================================
// FORMAT PROBABILITY
// =========================================================
const formatProbability = (value) => {
if (
value === null ||
value === undefined
) {
return "-";
}
return `${(
Number(value) * 100
).toFixed(2)}%`;
};
// =========================================================
// SCROLL KE TABLE
// =========================================================
const scrollToDataTable = () => {
if (!dataTableRef.current) {
return;
}
const offset = 100;
const elementPosition =
dataTableRef.current.getBoundingClientRect().top;
const offsetPosition =
elementPosition +
window.pageYOffset -
offset;
window.scrollTo({
top: offsetPosition,
behavior: "smooth"
});
};
// =========================================================
// CHANGE PAGE
// =========================================================
const changePage = (page) => {
if (page < 1) {
return;
}
if (page > totalPages) {
return;
}
setCurrentPage(page);
setTimeout(() => {
scrollToDataTable();
}, 50);
};
// =========================================================
// PAGE NUMBERS
// =========================================================
const renderPageNumbers = () => {
const pages = [];
/*
* Jika halaman <= 7
* tampilkan seluruh nomor halaman.
*/
if (totalPages <= 7) {
for (
let i = 1;
i <= totalPages;
i++
) {
pages.push(
<button
key={i}
type="button"
className={
currentPage === i
? "pagination-number active"
: "pagination-number"
}
onClick={() =>
changePage(i)
}
>
{i}
</button>
);
}
return pages;
}
/*
* HALAMAN AWAL
*
* Contoh:
*
* 1 2 3 4 ... 55
*/
if (currentPage <= 3) {
for (
let i = 1;
i <= 4;
i++
) {
pages.push(
<button
key={i}
type="button"
className={
currentPage === i
? "pagination-number active"
: "pagination-number"
}
onClick={() =>
changePage(i)
}
>
{i}
</button>
);
}
pages.push(
<span
key="dots-right"
className="pagination-dots"
>
...
</span>
);
pages.push(
<button
key={totalPages}
type="button"
className="pagination-number"
onClick={() =>
changePage(
totalPages
)
}
>
{totalPages}
</button>
);
return pages;
}
/*
* HALAMAN AKHIR
*
* Contoh:
*
* 1 ... 52 53 54 55
*/
if (
currentPage >=
totalPages - 2
) {
pages.push(
<button
key={1}
type="button"
className="pagination-number"
onClick={() =>
changePage(1)
}
>
1
</button>
);
pages.push(
<span
key="dots-left"
className="pagination-dots"
>
...
</span>
);
for (
let i =
totalPages - 3;
i <= totalPages;
i++
) {
pages.push(
<button
key={i}
type="button"
className={
currentPage === i
? "pagination-number active"
: "pagination-number"
}
onClick={() =>
changePage(i)
}
>
{i}
</button>
);
}
return pages;
}
/*
* HALAMAN TENGAH
*
* Contoh:
*
* 1 ... 27 28 29 ... 55
*/
pages.push(
<button
key={1}
type="button"
className="pagination-number"
onClick={() =>
changePage(1)
}
>
1
</button>
);
pages.push(
<span
key="dots-left"
className="pagination-dots"
>
...
</span>
);
pages.push(
<button
key={currentPage - 1}
type="button"
className="pagination-number"
onClick={() =>
changePage(
currentPage - 1
)
}
>
{currentPage - 1}
</button>
);
pages.push(
<button
key={currentPage}
type="button"
className="pagination-number active"
onClick={() =>
changePage(currentPage)
}
>
{currentPage}
</button>
);
pages.push(
<button
key={currentPage + 1}
type="button"
className="pagination-number"
onClick={() =>
changePage(
currentPage + 1
)
}
>
{currentPage + 1}
</button>
);
pages.push(
<span
key="dots-right"
className="pagination-dots"
>
...
</span>
);
pages.push(
<button
key={totalPages}
type="button"
className="pagination-number"
onClick={() =>
changePage(
totalPages
)
}
>
{totalPages}
</button>
);
return pages;
};
// =========================================================
// RETURN
// =========================================================
return (
<div className="page-container">
{/* =====================================================
HEADER
====================================================== */}
<div className="page-header">
<div>
<div className="page-eyebrow">
SENTIMENT CLASSIFICATION
</div>
<h1>
Klasifikasi Sentimen
</h1>
<p>
Proses klasifikasi sentimen komentar
mahasiswa menggunakan metode
Naïve Bayes Classifier.
</p>
</div>
</div>
{/* =====================================================
SUMMARY
====================================================== */}
<div className="classification-summary">
<div className="summary-card">
<span className="summary-label">
TOTAL DATA
</span>
<strong className="summary-value">
{total}
</strong>
<span className="summary-description">
Seluruh komentar
</span>
</div>
<div className="summary-card positive">
<span className="summary-label">
POSITIF
</span>
<strong className="summary-value">
{positif}
</strong>
<span className="summary-description">
Komentar positif
</span>
</div>
<div className="summary-card negative">
<span className="summary-label">
NEGATIF
</span>
<strong className="summary-value">
{negatif}
</strong>
<span className="summary-description">
Komentar negatif
</span>
</div>
</div>
{/* =====================================================
CONTROL
====================================================== */}
<div className="classification-control">
<div>
<div className="card-eyebrow">
NAÏVE BAYES CLASSIFIER
</div>
<h2>
Klasifikasikan Data
</h2>
<p>
Sistem akan melakukan klasifikasi
terhadap seluruh komentar yang telah
melalui proses pre-processing.
</p>
</div>
<button
type="button"
className="primary-button"
onClick={
handleClassification
}
disabled={
classifying ||
data.length === 0
}
>
{classifying
? "Mengklasifikasikan..."
: "Klasifikasikan Data"}
</button>
</div>
{/* =====================================================
RESULT
====================================================== */}
{result && (
<div className="tfidf-success">
<strong>
{result.message}
</strong>
<span>
{result.jumlah_data} data ·{" "}
{result.positif} positif ·{" "}
{result.negatif} negatif
</span>
</div>
)}
{/* =====================================================
ERROR
====================================================== */}
{error && (
<div className="error-message">
{error}
</div>
)}
{/* =====================================================
TABLE
====================================================== */}
<div
className="data-table-card"
ref={dataTableRef}
>
<div className="table-card-header">
<div>
<h2>
Hasil Klasifikasi
</h2>
<p>
Hasil prediksi sentimen dari
seluruh komentar mahasiswa.
</p>
</div>
<div className="data-count">
<span>
DOKUMEN
</span>
<strong>
{data.length}
</strong>
</div>
</div>
{loading ? (
<div className="data-loading">
Memuat data...
</div>
) : data.length === 0 ? (
<div className="data-empty">
<strong>
Belum ada data komentar.
</strong>
<span>
Tambahkan data terlebih dahulu
melalui halaman Data.
</span>
</div>
) : (
<>
{/* =================================================
TABLE
================================================== */}
<div className="data-table-wrapper">
<table>
<thead>
<tr>
<th>
KOMENTAR
</th>
<th>
TEXT PREPROCESSED
</th>
<th>
SENTIMEN
</th>
<th>
PROBABILITY
</th>
</tr>
</thead>
<tbody>
{currentData.map(
(item) => (
<tr
key={item.id}
>
{/* KOMENTAR */}
<td>
<div className="data-comment">
{
item.komentar
}
</div>
</td>
{/* TEXT PREPROCESSED */}
<td>
<div className="tfidf-text">
{
item.text_preprocessed ||
"-"
}
</div>
</td>
{/* SENTIMEN */}
<td>
{item.sentiment ===
"Positif" ? (
<span className="sentiment-badge positive">
Positif
</span>
) : item.sentiment ===
"Negatif" ? (
<span className="sentiment-badge negative">
Negatif
</span>
) : (
<span className="sentiment-badge neutral">
Belum diklasifikasi
</span>
)}
</td>
{/* PROBABILITY */}
<td>
<strong>
{formatProbability(
item.probability
)}
</strong>
</td>
</tr>
)
)}
</tbody>
</table>
</div>
{/* =================================================
PAGINATION
================================================== */}
{totalPages > 1 && (
<div className="pagination-container">
<div className="pagination-info">
Menampilkan{" "}
<strong>
{startIndex + 1}
</strong>
{" - "}
<strong>
{Math.min(
endIndex,
data.length
)}
</strong>
{" dari "}
<strong>
{data.length}
</strong>
{" data"}
</div>
<div className="pagination-controls">
{/* SEBELUMNYA */}
<button
type="button"
className="pagination-button"
onClick={() =>
changePage(
currentPage - 1
)
}
disabled={
currentPage === 1
}
>
Sebelumnya
</button>
{/* NOMOR HALAMAN */}
<div className="pagination-numbers">
{renderPageNumbers()}
</div>
{/* SELANJUTNYA */}
<button
type="button"
className="pagination-button"
onClick={() =>
changePage(
currentPage + 1
)
}
disabled={
currentPage ===
totalPages
}
>
Selanjutnya
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}
export default Classification;

View File

@ -0,0 +1,605 @@
import React, { useEffect, useState } from "react";
import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Tooltip
} from "recharts";
import api from "../services/api";
function Dashboard() {
const [summary, setSummary] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
/* =====================================================
GET DATA DASHBOARD
===================================================== */
useEffect(() => {
const getDashboardData = async () => {
try {
setLoading(true);
setError("");
const summaryResponse =
await api.get("/dashboard/summary");
setSummary(summaryResponse.data);
} catch (err) {
console.error(err);
setError(
"Gagal mengambil data dari server."
);
} finally {
setLoading(false);
}
};
getDashboardData();
}, []);
/* =====================================================
LOADING
===================================================== */
if (loading) {
return (
<div className="loading-page">
<div className="loading-line"></div>
<p>
Loading sentiment study...
</p>
</div>
);
}
/* =====================================================
ERROR
===================================================== */
if (error) {
return (
<div className="error-page">
<div className="error-label">
CONNECTION ERROR
</div>
<h2>
{error}
</h2>
<p>
Pastikan backend FastAPI sedang
berjalan pada port 8000.
</p>
</div>
);
}
/* =====================================================
DATA
===================================================== */
const total =
summary?.total || 0;
const positif =
summary?.positif || 0;
const negatif =
summary?.negatif || 0;
const persentasePositif =
summary?.persentase_positif || 0;
const persentaseNegatif =
summary?.persentase_negatif || 0;
/* =====================================================
CHART DATA
===================================================== */
const chartData = [
{
name: "Positif",
value: positif
},
{
name: "Negatif",
value: negatif
}
];
/* =====================================================
RENDER
===================================================== */
return (
<main className="dashboard">
{/* =================================================
INTRODUCTION
================================================= */}
<section className="intro">
<div className="intro-left">
<div className="eyebrow">
SENTIMENT ANALYSIS · 2026
</div>
<h1>
Understanding student
<br />
<em>
perception
</em>
{" "}of S-Learn
</h1>
<p className="intro-description">
Analisis sentimen ulasan mahasiswa
terhadap aplikasi pembelajaran
S-Learn menggunakan metode
Naïve Bayes Classifier
</p>
</div>
<div className="intro-meta">
<span>
DATASET
</span>
<strong>
{total}
</strong>
<small>
student reviews
</small>
</div>
</section>
{/* =================================================
STATISTICS
================================================= */}
<section className="statistics">
{/* TOTAL */}
<div className="stat">
<span className="stat-label">
TOTAL REVIEWS
</span>
<strong>
{total}
</strong>
<p>
seluruh data ulasan
</p>
</div>
{/* POSITIVE */}
<div className="stat">
<span className="stat-label">
POSITIVE
</span>
<strong className="positive-number">
{positif}
</strong>
<p>
{persentasePositif}%
{" "}dari seluruh data
</p>
</div>
{/* NEGATIVE */}
<div className="stat">
<span className="stat-label">
NEGATIVE
</span>
<strong className="negative-number">
{negatif}
</strong>
<p>
{persentaseNegatif}%
{" "}dari seluruh data
</p>
</div>
{/* POSITIVE RATE */}
<div className="stat">
<span className="stat-label">
POSITIVE RATE
</span>
<strong>
{persentasePositif}%
</strong>
<p>
overall sentiment
</p>
</div>
</section>
{/* =================================================
SENTIMENT ANALYSIS
================================================= */}
<section className="analysis-section">
<div className="analysis-heading">
<div>
<h2>
Sentiment Distribution
</h2>
</div>
<p>
Classification Overview
</p>
</div>
<div className="analysis-grid">
{/* =========================================
CHART
========================================== */}
<div className="chart-area">
<ResponsiveContainer
width="100%"
height={390}
>
<PieChart>
<Pie
data={chartData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={105}
outerRadius={150}
paddingAngle={3}
stroke="none"
>
{/* POSITIVE */}
<Cell
fill="#315A7D"
/>
{/* NEGATIVE */}
<Cell
fill="#A65D5D"
/>
</Pie>
<Tooltip
formatter={(value) =>
`${value} reviews`
}
contentStyle={{
backgroundColor:
"#FFFFFF",
border:
"1px solid #D8E0E6",
borderRadius:
"4px",
boxShadow:
"none",
color:
"#172B4D",
fontSize:
"12px"
}}
/>
</PieChart>
</ResponsiveContainer>
{/* CHART CENTER */}
<div className="chart-center">
<strong>
{persentasePositif}%
</strong>
<span>
POSITIVE
</span>
</div>
</div>
{/* =========================================
SUMMARY
========================================== */}
<div className="sentiment-summary">
{/* POSITIVE */}
<div className="summary-item">
<div className="summary-top">
<span>
<i className="dot positive-dot"></i>
Positive
</span>
<strong>
{persentasePositif}%
</strong>
</div>
<div className="summary-line">
<div
className="
summary-fill
positive-fill
"
style={{
width:
`${persentasePositif}%`
}}
/>
</div>
<small>
{positif} reviews
</small>
</div>
{/* NEGATIVE */}
<div className="summary-item">
<div className="summary-top">
<span>
<i className="dot negative-dot"></i>
Negative
</span>
<strong>
{persentaseNegatif}%
</strong>
</div>
<div className="summary-line">
<div
className="
summary-fill
negative-fill
"
style={{
width:
`${persentaseNegatif}%`
}}
/>
</div>
<small>
{negatif} reviews
</small>
</div>
{/* INTERPRETATION */}
<div className="analysis-note">
<span>
INTERPRETATION
</span>
<p>
Mayoritas ulasan mahasiswa menunjukkan
sentimen positif, yang mencerminkan
persepsi pengguna yang secara umum baik
terhadap S-Learn.
</p>
</div>
</div>
</div>
</section>
{/* =================================================
FOOTER
================================================= */}
<footer className="dashboard-footer">
<span>
S-LEARN SENTIMENT STUDY
</span>
<span>
Naïve Bayes Classifier
</span>
</footer>
</main>
);
}
export default Dashboard;

1272
frontend/src/pages/Data.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,796 @@
import React, { useEffect, useRef, useState } from "react";
import api from "../services/api";
function Preprocessing() {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [processing, setProcessing] = useState(false);
const [error, setError] = useState("");
const [result, setResult] = useState(null);
// =========================================================
// PAGINATION
// =========================================================
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const dataTableRef = useRef(null);
const totalPages = Math.ceil(data.length / itemsPerPage);
const startIndex =
(currentPage - 1) * itemsPerPage;
const endIndex =
startIndex + itemsPerPage;
const currentData =
data.slice(startIndex, endIndex);
// =========================================================
// GET DATA
// =========================================================
const getData = async () => {
try {
setLoading(true);
setError("");
const response = await api.get("/data/");
setData(response.data);
// Setelah data diperbarui,
// kembali ke halaman pertama
setCurrentPage(1);
} catch (err) {
console.error(err);
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
"Data preprocessing tidak dapat diambil dari server.";
setError(message);
} finally {
setLoading(false);
}
};
useEffect(() => {
getData();
}, []);
// =========================================================
// PREPROCESSING
// =========================================================
const handlePreprocessing = async () => {
try {
setProcessing(true);
setError("");
setResult(null);
const response =
await api.post("/preprocessing/");
setResult(response.data);
await getData();
} catch (err) {
console.error(err);
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
"Proses preprocessing gagal dilakukan.";
setError(message);
} finally {
setProcessing(false);
}
};
// =========================================================
// FORMAT TOKEN
// =========================================================
/*
* Mengubah teks/token menjadi format:
* ["kata1", "kata2", "kata3"]
*/
const formatTokens = (text) => {
if (!text) {
return "-";
}
const tokens = text
.trim()
.split(/\s+/)
.filter(Boolean);
return `[${tokens
.map((token) => `"${token}"`)
.join(", ")}]`;
};
// =========================================================
// SUMMARY
// =========================================================
const processedCount = data.filter(
(item) => item.text_preprocessed
).length;
// =========================================================
// SCROLL KE TABLE
// =========================================================
const scrollToDataTable = () => {
if (!dataTableRef.current) {
return;
}
const offset = 100;
const elementPosition =
dataTableRef.current.getBoundingClientRect().top;
const offsetPosition =
elementPosition +
window.pageYOffset -
offset;
window.scrollTo({
top: offsetPosition,
behavior: "smooth"
});
};
// =========================================================
// CHANGE PAGE
// =========================================================
const changePage = (page) => {
if (page < 1) {
return;
}
if (page > totalPages) {
return;
}
setCurrentPage(page);
setTimeout(() => {
scrollToDataTable();
}, 50);
};
// =========================================================
// PAGE NUMBERS
// =========================================================
const renderPageNumbers = () => {
const pages = [];
/*
* Jika halaman sedikit,
* tampilkan seluruh nomor halaman.
*/
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) {
pages.push(
<button
key={i}
type="button"
className={
currentPage === i
? "pagination-number active"
: "pagination-number"
}
onClick={() => changePage(i)}
>
{i}
</button>
);
}
return pages;
}
/*
* Halaman banyak.
*/
// Halaman awal
if (currentPage <= 3) {
for (let i = 1; i <= 4; i++) {
pages.push(
<button
key={i}
type="button"
className={
currentPage === i
? "pagination-number active"
: "pagination-number"
}
onClick={() => changePage(i)}
>
{i}
</button>
);
}
pages.push(
<span
key="dots-right"
className="pagination-dots"
>
...
</span>
);
pages.push(
<button
key={totalPages}
type="button"
className="pagination-number"
onClick={() =>
changePage(totalPages)
}
>
{totalPages}
</button>
);
return pages;
}
// Halaman akhir
if (currentPage >= totalPages - 2) {
pages.push(
<button
key={1}
type="button"
className="pagination-number"
onClick={() => changePage(1)}
>
1
</button>
);
pages.push(
<span
key="dots-left"
className="pagination-dots"
>
...
</span>
);
for (
let i = totalPages - 3;
i <= totalPages;
i++
) {
pages.push(
<button
key={i}
type="button"
className={
currentPage === i
? "pagination-number active"
: "pagination-number"
}
onClick={() => changePage(i)}
>
{i}
</button>
);
}
return pages;
}
// Halaman tengah
pages.push(
<button
key={1}
type="button"
className="pagination-number"
onClick={() => changePage(1)}
>
1
</button>
);
pages.push(
<span
key="dots-left"
className="pagination-dots"
>
...
</span>
);
pages.push(
<button
key={currentPage - 1}
type="button"
className="pagination-number"
onClick={() =>
changePage(currentPage - 1)
}
>
{currentPage - 1}
</button>
);
pages.push(
<button
key={currentPage}
type="button"
className="pagination-number active"
onClick={() =>
changePage(currentPage)
}
>
{currentPage}
</button>
);
pages.push(
<button
key={currentPage + 1}
type="button"
className="pagination-number"
onClick={() =>
changePage(currentPage + 1)
}
>
{currentPage + 1}
</button>
);
pages.push(
<span
key="dots-right"
className="pagination-dots"
>
...
</span>
);
pages.push(
<button
key={totalPages}
type="button"
className="pagination-number"
onClick={() =>
changePage(totalPages)
}
>
{totalPages}
</button>
);
return pages;
};
return (
<div className="page-container">
{/* =====================================================
HEADER
====================================================== */}
<div className="page-header">
<div>
<div className="page-eyebrow">
TEXT PRE-PROCESSING
</div>
<h1>
Pre-Processing
</h1>
<p>
Tahap pengolahan awal teks komentar mahasiswa
sebelum dilakukan pembobotan kata menggunakan
TF-IDF dan klasifikasi sentimen
</p>
</div>
</div>
{/* =====================================================
SUMMARY
====================================================== */}
<div className="classification-summary">
<div className="summary-card">
<span className="summary-label">
TOTAL DATA
</span>
<strong className="summary-value">
{data.length}
</strong>
<span className="summary-description">
Seluruh komentar
</span>
</div>
<div className="summary-card positive">
<span className="summary-label">
SUDAH DIPROSES
</span>
<strong className="summary-value">
{processedCount}
</strong>
<span className="summary-description">
Data telah melalui preprocessing
</span>
</div>
<div className="summary-card negative">
<span className="summary-label">
BELUM DIPROSES
</span>
<strong className="summary-value">
{data.length - processedCount}
</strong>
<span className="summary-description">
Data belum diproses
</span>
</div>
</div>
{/* =====================================================
CONTROL
====================================================== */}
<div className="classification-control">
<div>
<div className="card-eyebrow">
TEXT PROCESSING
</div>
<h2>
Jalankan Pre-Processing
</h2>
<p>
Sistem akan melakukan seluruh tahapan
preprocessing terhadap komentar yang
tersimpan di database
</p>
</div>
<button
type="button"
className="primary-button"
onClick={handlePreprocessing}
disabled={
processing ||
data.length === 0
}
>
{processing
? "Memproses..."
: "Jalankan Pre-Processing"}
</button>
</div>
{/* =====================================================
SUCCESS
====================================================== */}
{result && (
<div className="tfidf-success">
<strong>
{result.message}
</strong>
<span>
{result.jumlah_data} data berhasil diproses
</span>
</div>
)}
{/* =====================================================
ERROR
====================================================== */}
{error && (
<div className="error-message">
{error}
</div>
)}
{/* =====================================================
TABLE
====================================================== */}
<div
className="data-table-card"
ref={dataTableRef}
>
<div className="table-card-header">
<div>
<h2>
Hasil Pre-processing
</h2>
<p>
Perbandingan hasil setiap tahapan
preprocessing pada komentar mahasiswa
</p>
</div>
<div className="data-count">
<span>
DOKUMEN
</span>
<strong>
{data.length}
</strong>
</div>
</div>
{loading ? (
<div className="data-loading">
Memuat data preprocessing...
</div>
) : data.length === 0 ? (
<div className="data-empty">
<strong>
Belum ada data komentar.
</strong>
<span>
Tambahkan data terlebih dahulu
melalui halaman Data.
</span>
</div>
) : (
<>
<div className="data-table-wrapper">
<table>
<thead>
<tr>
<th>KOMENTAR</th>
<th>CASE FOLDING</th>
<th>NORMALISASI</th>
<th>CLEANING</th>
<th>TOKENIZATION</th>
<th>STOPWORD REMOVAL</th>
<th>STEMMING</th>
<th>TEXT PREPROCESSED</th>
</tr>
</thead>
<tbody>
{currentData.map((item) => (
<tr key={item.id}>
{/* KOMENTAR */}
<td>
<div className="data-comment">
{item.komentar}
</div>
</td>
{/* CASE FOLDING */}
<td>
<div className="preprocessing-text">
{item.case_folding || "-"}
</div>
</td>
{/* NORMALISASI */}
<td>
<div className="preprocessing-text">
{item.normalisasi || "-"}
</div>
</td>
{/* CLEANING */}
<td>
<div className="preprocessing-text">
{item.cleaning || "-"}
</div>
</td>
{/* TOKENIZATION */}
<td>
<div className="preprocessing-text">
{formatTokens(
item.tokenization
)}
</div>
</td>
{/* STOPWORD REMOVAL */}
<td>
<div className="preprocessing-text">
{formatTokens(
item.stopword_removal
)}
</div>
</td>
{/* STEMMING */}
<td>
<div className="preprocessing-text">
{formatTokens(
item.stemming
)}
</div>
</td>
{/* TEXT PREPROCESSED */}
<td>
<div className="preprocessing-final">
{item.text_preprocessed || "-"}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* =================================================
PAGINATION
================================================== */}
{totalPages > 1 && (
<div className="pagination-container">
<div className="pagination-info">
Menampilkan{" "}
<strong>
{startIndex + 1}
</strong>
{" - "}
<strong>
{Math.min(
endIndex,
data.length
)}
</strong>
{" dari "}
<strong>
{data.length}
</strong>
{" data"}
</div>
<div className="pagination-controls">
<button
type="button"
className="pagination-button"
onClick={() =>
changePage(
currentPage - 1
)
}
disabled={
currentPage === 1
}
>
Sebelumnya
</button>
<div className="pagination-numbers">
{renderPageNumbers()}
</div>
<button
type="button"
className="pagination-button"
onClick={() =>
changePage(
currentPage + 1
)
}
disabled={
currentPage === totalPages
}
>
Selanjutnya
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}
export default Preprocessing;

883
frontend/src/pages/TFIDF.js Normal file
View File

@ -0,0 +1,883 @@
import React, { useEffect, useRef, useState } from "react";
import api from "../services/api";
function TFIDF() {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [calculating, setCalculating] = useState(false);
const [error, setError] = useState("");
const [result, setResult] = useState(null);
// =========================================================
// PAGINATION
// =========================================================
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const dataTableRef = useRef(null);
const totalPages = Math.ceil(
data.length / itemsPerPage
);
const startIndex =
(currentPage - 1) * itemsPerPage;
const endIndex =
startIndex + itemsPerPage;
const currentData =
data.slice(startIndex, endIndex);
// =========================================================
// GET DATA
// =========================================================
const getData = async () => {
try {
setLoading(true);
setError("");
const response = await api.get("/tfidf/");
/*
* Backend mengembalikan:
*
* {
* jumlah_data: 545,
* data: [...]
* }
*
* Karena state "data" harus berupa Array,
* maka yang dimasukkan adalah response.data.data
*/
setData(response.data.data);
// Setelah data diperbarui,
// kembali ke halaman pertama
setCurrentPage(1);
} catch (err) {
console.error(err);
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
"Data TF-IDF tidak dapat diambil dari server.";
setError(message);
} finally {
setLoading(false);
}
};
useEffect(() => {
getData();
}, []);
// =========================================================
// HITUNG TF-IDF
// =========================================================
const handleCalculate = async () => {
try {
setCalculating(true);
setError("");
setResult(null);
const response =
await api.post("/tfidf/calculate");
setResult(response.data);
// Ambil kembali data terbaru
// setelah perhitungan TF-IDF selesai
await getData();
} catch (err) {
console.error(err);
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
"Perhitungan TF-IDF gagal dilakukan.";
setError(message);
} finally {
setCalculating(false);
}
};
// =========================================================
// FORMAT NILAI
// =========================================================
const formatValue = (value) => {
if (
value === null ||
value === undefined
) {
return "-";
}
return Number(value).toFixed(6);
};
// =========================================================
// JUMLAH DATA YANG SUDAH DIHITUNG
// =========================================================
const calculatedCount = data.filter(
(item) =>
item.tfidf &&
Object.keys(item.tfidf).length > 0
).length;
// =========================================================
// SCROLL KE TABLE
// =========================================================
const scrollToDataTable = () => {
if (!dataTableRef.current) {
return;
}
const offset = 100;
const elementPosition =
dataTableRef.current.getBoundingClientRect().top;
const offsetPosition =
elementPosition +
window.pageYOffset -
offset;
window.scrollTo({
top: offsetPosition,
behavior: "smooth"
});
};
// =========================================================
// CHANGE PAGE
// =========================================================
const changePage = (page) => {
if (page < 1) {
return;
}
if (page > totalPages) {
return;
}
setCurrentPage(page);
setTimeout(() => {
scrollToDataTable();
}, 50);
};
// =========================================================
// PAGE NUMBERS
// =========================================================
const renderPageNumbers = () => {
const pages = [];
/*
* Jika halaman <= 7,
* tampilkan semua nomor halaman.
*/
if (totalPages <= 7) {
for (
let i = 1;
i <= totalPages;
i++
) {
pages.push(
<button
key={i}
type="button"
className={
currentPage === i
? "pagination-number active"
: "pagination-number"
}
onClick={() =>
changePage(i)
}
>
{i}
</button>
);
}
return pages;
}
/*
* HALAMAN AWAL
*
* Contoh:
* 1 2 3 4 ... 55
*/
if (currentPage <= 3) {
for (let i = 1; i <= 4; i++) {
pages.push(
<button
key={i}
type="button"
className={
currentPage === i
? "pagination-number active"
: "pagination-number"
}
onClick={() =>
changePage(i)
}
>
{i}
</button>
);
}
pages.push(
<span
key="dots-right"
className="pagination-dots"
>
...
</span>
);
pages.push(
<button
key={totalPages}
type="button"
className="pagination-number"
onClick={() =>
changePage(totalPages)
}
>
{totalPages}
</button>
);
return pages;
}
/*
* HALAMAN AKHIR
*
* Contoh:
* 1 ... 52 53 54 55
*/
if (
currentPage >=
totalPages - 2
) {
pages.push(
<button
key={1}
type="button"
className="pagination-number"
onClick={() =>
changePage(1)
}
>
1
</button>
);
pages.push(
<span
key="dots-left"
className="pagination-dots"
>
...
</span>
);
for (
let i = totalPages - 3;
i <= totalPages;
i++
) {
pages.push(
<button
key={i}
type="button"
className={
currentPage === i
? "pagination-number active"
: "pagination-number"
}
onClick={() =>
changePage(i)
}
>
{i}
</button>
);
}
return pages;
}
/*
* HALAMAN TENGAH
*
* Contoh:
* 1 ... 27 28 29 ... 55
*/
pages.push(
<button
key={1}
type="button"
className="pagination-number"
onClick={() =>
changePage(1)
}
>
1
</button>
);
pages.push(
<span
key="dots-left"
className="pagination-dots"
>
...
</span>
);
pages.push(
<button
key={currentPage - 1}
type="button"
className="pagination-number"
onClick={() =>
changePage(
currentPage - 1
)
}
>
{currentPage - 1}
</button>
);
pages.push(
<button
key={currentPage}
type="button"
className="pagination-number active"
onClick={() =>
changePage(currentPage)
}
>
{currentPage}
</button>
);
pages.push(
<button
key={currentPage + 1}
type="button"
className="pagination-number"
onClick={() =>
changePage(
currentPage + 1
)
}
>
{currentPage + 1}
</button>
);
pages.push(
<span
key="dots-right"
className="pagination-dots"
>
...
</span>
);
pages.push(
<button
key={totalPages}
type="button"
className="pagination-number"
onClick={() =>
changePage(totalPages)
}
>
{totalPages}
</button>
);
return pages;
};
// =========================================================
// RETURN
// =========================================================
return (
<div className="page-container">
{/* =====================================================
HEADER
====================================================== */}
<div className="page-header">
<div>
<div className="page-eyebrow">
TERM FREQUENCY — INVERSE DOCUMENT FREQUENCY
</div>
<h1>
Pembobotan Kata
</h1>
<p>
Proses pembobotan kata menggunakan metode
TF-IDF untuk menentukan tingkat kepentingan
setiap kata dalam dokumen.
</p>
</div>
</div>
{/* =====================================================
INFORMATION
====================================================== */}
<div className="classification-summary">
<div className="summary-card">
<span className="summary-label">
TOTAL DOKUMEN
</span>
<strong className="summary-value">
{data.length}
</strong>
<span className="summary-description">
Seluruh komentar
</span>
</div>
<div className="summary-card positive">
<span className="summary-label">
SUDAH DIHITUNG
</span>
<strong className="summary-value">
{calculatedCount}
</strong>
<span className="summary-description">
Dokumen memiliki nilai TF-IDF
</span>
</div>
<div className="summary-card negative">
<span className="summary-label">
BELUM DIHITUNG
</span>
<strong className="summary-value">
{data.length - calculatedCount}
</strong>
<span className="summary-description">
Dokumen belum memiliki bobot
</span>
</div>
</div>
{/* =====================================================
CONTROL
====================================================== */}
<div className="tfidf-control">
<div>
<div className="card-eyebrow">
TF-IDF CALCULATION
</div>
<h2>
Hitung Bobot Kata
</h2>
<p>
Sistem akan menghitung nilai TF-IDF
berdasarkan text hasil pre-processing
menggunakan vectorizer dari model yang
telah dilatih.
</p>
</div>
<button
type="button"
className="primary-button"
onClick={handleCalculate}
disabled={
calculating ||
data.length === 0
}
>
{calculating
? "Menghitung..."
: "Hitung TF-IDF"}
</button>
</div>
{/* =====================================================
SUCCESS
====================================================== */}
{result && (
<div className="tfidf-success">
<strong>
{result.message}
</strong>
<span>
{result.jumlah_data} dokumen ·{" "}
{result.jumlah_fitur} fitur kata
</span>
</div>
)}
{/* =====================================================
ERROR
====================================================== */}
{error && (
<div className="error-message">
{error}
</div>
)}
{/* =====================================================
TABLE
====================================================== */}
<div
className="data-table-card"
ref={dataTableRef}
>
<div className="table-card-header">
<div>
<h2>
Hasil Pembobotan TF-IDF
</h2>
<p>
Nilai bobot setiap kata berdasarkan
dokumen hasil pre-processing.
</p>
</div>
<div className="data-count">
<span>
DOKUMEN
</span>
<strong>
{data.length}
</strong>
</div>
</div>
{loading ? (
<div className="data-loading">
Memuat data TF-IDF...
</div>
) : data.length === 0 ? (
<div className="data-empty">
<strong>
Belum ada data komentar.
</strong>
<span>
Tambahkan data terlebih dahulu
melalui halaman Data.
</span>
</div>
) : (
<>
<div className="data-table-wrapper">
<table>
<thead>
<tr>
<th>
KOMENTAR
</th>
<th>
TEXT PREPROCESSED
</th>
<th>
NILAI TF-IDF
</th>
</tr>
</thead>
<tbody>
{currentData.map(
(item) => (
<tr
key={item.id}
>
{/* KOMENTAR */}
<td>
<div className="data-comment">
{item.komentar}
</div>
</td>
{/* PREPROCESSED */}
<td>
<div className="tfidf-text">
{item.text_preprocessed ||
"-"}
</div>
</td>
{/* TF-IDF */}
<td>
{Object.keys(
item.tfidf ||
{}
).length ===
0 ? (
<span className="tfidf-empty">
Belum dihitung
</span>
) : (
<div className="tfidf-values">
{Object.entries(
item.tfidf
).map(
(
[
kata,
nilai
]
) => (
<div
className="tfidf-item"
key={
kata
}
>
<span>
{
kata
}
</span>
<strong>
{formatValue(
nilai
)}
</strong>
</div>
)
)}
</div>
)}
</td>
</tr>
)
)}
</tbody>
</table>
</div>
{/* =================================================
PAGINATION
================================================== */}
{totalPages > 1 && (
<div className="pagination-container">
<div className="pagination-info">
Menampilkan{" "}
<strong>
{startIndex + 1}
</strong>
{" - "}
<strong>
{Math.min(
endIndex,
data.length
)}
</strong>
{" dari "}
<strong>
{data.length}
</strong>
{" data"}
</div>
<div className="pagination-controls">
<button
type="button"
className="pagination-button"
onClick={() =>
changePage(
currentPage - 1
)
}
disabled={
currentPage === 1
}
>
Sebelumnya
</button>
<div className="pagination-numbers">
{renderPageNumbers()}
</div>
<button
type="button"
className="pagination-button"
onClick={() =>
changePage(
currentPage + 1
)
}
disabled={
currentPage ===
totalPages
}
>
Selanjutnya
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}
export default TFIDF;

View File

@ -0,0 +1,187 @@
import React from "react";
function UserGuide() {
const guideSteps = [
{
number: "01",
title: "Overview",
subtitle: "Ringkasan Analisis Sentimen",
description: "Halaman Overview merupakan halaman utama yang menampilkan ringkasan hasil analisis sentimen mahasiswa terhadap aplikasi S-Learn.",
instructions: [
"Buka menu Overview untuk melihat ringkasan data komentar.",
"Perhatikan jumlah seluruh komentar yang tersimpan.",
"Lihat jumlah komentar positif dan negatif.",
"Amati grafik Sentiment Distribution untuk mengetahui perbandingan kedua kategori sentimen."
]
},
{
number: "02",
title: "Data",
subtitle: "Pengelolaan Data Komentar",
description: "Halaman Data digunakan untuk menambahkan dan mengelola komentar mahasiswa yang akan digunakan dalam proses analisis sentimen.",
instructions: [
"Buka menu Data untuk melihat komentar yang tersimpan.",
"Gunakan form yang tersedia untuk menambahkan komentar secara langsung.",
"Masukkan komentar sesuai kolom yang disediakan.",
"Gunakan fitur Import CSV untuk memasukkan data dari file.",
"Pastikan file CSV memiliki kolom komentar.",
"Periksa data yang berhasil ditambahkan pada tabel."
]
},
{
number: "03",
title: "Pre-Processing",
subtitle: "Pengolahan Teks Komentar",
description: "Halaman Pre-Processing digunakan untuk membersihkan dan mengolah teks komentar sebelum dilakukan pembobotan kata.",
instructions: [
"Buka menu Pre-Processing untuk melihat jumlah data yang sudah dan belum diproses.",
"Jalankan proses Pre-Processing.",
"Case Folding: mengubah seluruh huruf menjadi huruf kecil.",
"Normalisasi: memperbaiki kata tidak baku.",
"Cleaning: menghapus karakter yang tidak diperlukan.",
"Tokenizing: memisahkan kalimat menjadi beberapa kata.",
"Stopword Removal: menghapus kata yang kurang berpengaruh dalam analisis.",
"Stemming: mengubah kata berimbuhan menjadi bentuk dasarnya.",
"Periksa hasil pengolahan komentar."
]
},
{
number: "04",
title: "TF-IDF",
subtitle: "Pembobotan Kata",
description: "Halaman TF-IDF digunakan untuk menghitung nilai bobot setiap kata dari komentar yang telah melalui proses Pre-Processing.",
instructions: [
"Buka menu TF-IDF untuk melihat jumlah dokumen yang sudah dan belum dihitung.",
"Jalankan proses pembobotan TF-IDF.",
"Sistem menghitung bobot kata berdasarkan kemunculannya dalam dokumen.",
"Periksa hasil pembobotan kata yang ditampilkan.",
"Hasil TF-IDF digunakan sebagai masukan untuk proses klasifikasi."
]
},
{
number: "05",
title: "Classification",
subtitle: "Klasifikasi Sentimen",
description: "Halaman Classification digunakan untuk melihat hasil klasifikasi sentimen komentar mahasiswa menggunakan metode Multinomial Naïve Bayes.",
instructions: [
"Buka menu Classification untuk melihat hasil klasifikasi komentar.",
"Perhatikan kategori sentimen positif atau negatif.",
"Lihat nilai probabilitas yang ditampilkan dalam bentuk persentase.",
"Untuk menguji komentar baru, masukkan teks komentar pada kolom yang tersedia.",
"Klik tombol analisis untuk menjalankan proses klasifikasi.",
"Sistem menampilkan hasil prediksi sentimen beserta nilai probabilitasnya."
]
}
];
return (
<main className="page-container user-guide">
{/* HEADER */}
<section className="page-header">
<div>
<div className="page-eyebrow">
S-LEARN DOCUMENTATION
</div>
<h1>User Guide</h1>
<p className="user-guide-description">
Halaman ini berisi petunjuk penggunaan aplikasi
S-Learn, mulai dari pengelolaan data komentar
hingga proses klasifikasi sentimen.
</p>
</div>
</section>
{/* INTRODUCTION */}
<section className="guide-introduction">
<div className="guide-section-heading">
<h2>Tentang Aplikasi</h2>
</div>
<p>
S-Learn merupakan aplikasi yang digunakan untuk
melakukan analisis sentimen terhadap komentar
mahasiswa mengenai aplikasi S-Learn. Analisis
dilakukan dengan mengelompokkan komentar ke dalam
dua kategori, yaitu sentimen positif dan negatif,
menggunakan metode Multinomial Naïve Bayes.
</p>
</section>
{/* GUIDE CONTENT */}
<section className="guide-content">
<div className="guide-heading">
<span className="page-eyebrow">
APPLICATION INSTRUCTIONS
</span>
<h2>Petunjuk Penggunaan</h2>
<p>
Berikut merupakan langkah-langkah penggunaan
setiap menu yang tersedia pada aplikasi S-Learn.
</p>
</div>
<div className="guide-list">
{guideSteps.map((step) => (
<article
className="guide-card"
key={step.number}
>
<div className="guide-number">
{step.number}
</div>
<div className="guide-card-content">
<div className="guide-subtitle">
{step.subtitle}
</div>
<h3>{step.title}</h3>
<p className="guide-description">
{step.description}
</p>
<div className="guide-instruction-title">
Langkah Penggunaan
</div>
<ol>
{step.instructions.map(
(instruction, index) => (
<li key={index}>
{instruction}
</li>
)
)}
</ol>
</div>
</article>
))}
</div>
</section>
</main>
);
}
export default UserGuide;

View File

@ -0,0 +1,13 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@ -0,0 +1,9 @@
import axios from "axios";
const api = axios.create({
baseURL: "http://127.0.0.1:8000"
});
export default api;

View File

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';