Upload project aplikasi analisis sentimen S-Learn
This commit is contained in:
commit
0d5b112685
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
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.
|
|
@ -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()
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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.
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"axios": "^1.20.0",
|
||||
"react-router-dom": "^7.18.3",
|
||||
"recharts": "^3.10.1"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fastapi
|
||||
uvicorn
|
||||
pandas
|
||||
numpy
|
||||
scikit-learn
|
||||
Sastrawi
|
||||
nltk
|
||||
SQLAlchemy
|
||||
PyMySQL
|
||||
python-multipart
|
||||
joblib
|
||||
|
|
@ -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*
|
||||
|
|
@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
|
|
@ -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>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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 |
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import axios from "axios";
|
||||
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: "http://127.0.0.1:8000"
|
||||
});
|
||||
|
||||
|
||||
export default api;
|
||||
|
|
@ -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';
|
||||
Loading…
Reference in New Issue