Spaces:
Sleeping
Sleeping
Upload 12 files
Browse files- .gitattributes +2 -0
- Dockerfile +35 -0
- app.py +110 -0
- model.py +173 -0
- requirements.txt +9 -0
- static/css/style.css +481 -0
- static/images/ikn.png +3 -0
- static/images/whoosh.png +3 -0
- templates/about.html +33 -0
- templates/analysis.html +228 -0
- templates/dashboard.html +142 -0
- templates/home.html +50 -0
- templates/layout.html +90 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
static/images/ikn.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
static/images/whoosh.png filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Gunakan Python 3.9 sebagai dasar
|
| 2 |
+
FROM python:3.9
|
| 3 |
+
|
| 4 |
+
# Set working directory
|
| 5 |
+
WORKDIR /code
|
| 6 |
+
|
| 7 |
+
# Copy requirements.txt ke dalam container
|
| 8 |
+
COPY ./requirements.txt /code/requirements.txt
|
| 9 |
+
|
| 10 |
+
# Install dependencies
|
| 11 |
+
# --no-cache-dir agar image tidak terlalu besar
|
| 12 |
+
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
|
| 13 |
+
|
| 14 |
+
# Buat user baru 'user' dengan ID 1000 (Syarat keamanan Hugging Face)
|
| 15 |
+
RUN useradd -m -u 1000 user
|
| 16 |
+
|
| 17 |
+
# Ganti user ke 'user'
|
| 18 |
+
USER user
|
| 19 |
+
|
| 20 |
+
# Set environment variables
|
| 21 |
+
ENV HOME=/home/user \
|
| 22 |
+
PATH=/home/user/.local/bin:$PATH
|
| 23 |
+
|
| 24 |
+
# Pindah ke direktori kerja user
|
| 25 |
+
WORKDIR $HOME/app
|
| 26 |
+
|
| 27 |
+
# Copy semua file proyek ke dalam container dengan izin user yang benar
|
| 28 |
+
COPY --chown=user . $HOME/app
|
| 29 |
+
|
| 30 |
+
# Buat folder uploads agar tidak error saat upload file
|
| 31 |
+
RUN mkdir -p $HOME/app/uploads
|
| 32 |
+
|
| 33 |
+
# Jalankan aplikasi menggunakan Gunicorn pada port 7860
|
| 34 |
+
# Port 7860 adalah port wajib untuk Hugging Face Spaces
|
| 35 |
+
CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
|
app.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Flask, render_template, request, url_for, flash, redirect
|
| 2 |
+
from model import predict_sentiment, analyze_sentiment_from_file, get_sentiment_counts, generate_wordcloud_base64
|
| 3 |
+
import os
|
| 4 |
+
from werkzeug.utils import secure_filename
|
| 5 |
+
|
| 6 |
+
# Konfigurasi folder upload
|
| 7 |
+
UPLOAD_FOLDER = 'uploads'
|
| 8 |
+
ALLOWED_EXTENSIONS = {'csv', 'xlsx'}
|
| 9 |
+
|
| 10 |
+
app = Flask(__name__)
|
| 11 |
+
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
| 12 |
+
app.config['SECRET_KEY'] = 'gantidenganyangaman'
|
| 13 |
+
|
| 14 |
+
# Pastikan folder upload ada
|
| 15 |
+
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
| 16 |
+
|
| 17 |
+
def allowed_file(filename):
|
| 18 |
+
"""Memeriksa apakah ekstensi file diizinkan."""
|
| 19 |
+
return '.' in filename and \
|
| 20 |
+
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
| 21 |
+
|
| 22 |
+
@app.route('/')
|
| 23 |
+
def home():
|
| 24 |
+
"""Merender halaman home."""
|
| 25 |
+
return render_template('home.html')
|
| 26 |
+
|
| 27 |
+
@app.route('/dashboard')
|
| 28 |
+
def dashboard():
|
| 29 |
+
"""
|
| 30 |
+
Merender halaman dashboard dengan data visualisasi IKN dan Whoosh.
|
| 31 |
+
"""
|
| 32 |
+
# Data Hardcoded sesuai permintaan
|
| 33 |
+
dashboard_data = {
|
| 34 |
+
'ikn': {
|
| 35 |
+
'title': 'Sentimen Masyarakat terhadap IKN',
|
| 36 |
+
'counts': {'Positif': 633, 'Netral': 387, 'Negatif': 452},
|
| 37 |
+
'image': 'ikn.png' # Pastikan file ini ada di static/images/
|
| 38 |
+
},
|
| 39 |
+
'whoosh': {
|
| 40 |
+
'title': 'Sentimen Masyarakat terhadap Whoosh',
|
| 41 |
+
'counts': {'Positif': 1122, 'Netral': 4270, 'Negatif': 2108},
|
| 42 |
+
'image': 'whoosh.png' # Pastikan file ini ada di static/images/
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
return render_template('dashboard.html', dashboard_data=dashboard_data)
|
| 47 |
+
|
| 48 |
+
@app.route('/analysis', methods=['GET', 'POST'])
|
| 49 |
+
def analysis():
|
| 50 |
+
"""
|
| 51 |
+
Merender halaman analisis. Menangani analisis teks tunggal DAN upload file.
|
| 52 |
+
"""
|
| 53 |
+
prediction_result = None
|
| 54 |
+
input_text = ""
|
| 55 |
+
results_table = None
|
| 56 |
+
wordcloud = None
|
| 57 |
+
sentiment_counts = None
|
| 58 |
+
|
| 59 |
+
if request.method == 'POST':
|
| 60 |
+
if 'text_input' in request.form:
|
| 61 |
+
input_text = request.form.get('text_input', '')
|
| 62 |
+
if input_text:
|
| 63 |
+
prediction_result = predict_sentiment(input_text)
|
| 64 |
+
|
| 65 |
+
elif 'file' in request.files:
|
| 66 |
+
file = request.files['file']
|
| 67 |
+
text_column = request.form.get('text_column')
|
| 68 |
+
|
| 69 |
+
if file.filename == '':
|
| 70 |
+
flash('Tidak ada file yang dipilih', 'danger')
|
| 71 |
+
return redirect(url_for('analysis'))
|
| 72 |
+
|
| 73 |
+
if not text_column:
|
| 74 |
+
flash('Nama kolom teks tidak boleh kosong', 'danger')
|
| 75 |
+
return redirect(url_for('analysis'))
|
| 76 |
+
|
| 77 |
+
if file and allowed_file(file.filename):
|
| 78 |
+
filename = secure_filename(file.filename)
|
| 79 |
+
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
| 80 |
+
file.save(file_path)
|
| 81 |
+
|
| 82 |
+
df, error = analyze_sentiment_from_file(file_path, text_column)
|
| 83 |
+
|
| 84 |
+
if error:
|
| 85 |
+
flash(error, 'danger')
|
| 86 |
+
os.remove(file_path)
|
| 87 |
+
return redirect(url_for('analysis'))
|
| 88 |
+
|
| 89 |
+
sentiment_counts = get_sentiment_counts(df)
|
| 90 |
+
wordcloud = generate_wordcloud_base64(df, text_column)
|
| 91 |
+
results_table = df.to_html(classes='table table-striped table-hover', index=False, border=0)
|
| 92 |
+
os.remove(file_path)
|
| 93 |
+
else:
|
| 94 |
+
flash('Format file tidak diizinkan. Gunakan .csv atau .xlsx', 'danger')
|
| 95 |
+
return redirect(url_for('analysis'))
|
| 96 |
+
|
| 97 |
+
return render_template('analysis.html',
|
| 98 |
+
prediction=prediction_result,
|
| 99 |
+
text=input_text,
|
| 100 |
+
sentiment_counts=sentiment_counts,
|
| 101 |
+
results_table=results_table,
|
| 102 |
+
wordcloud=wordcloud)
|
| 103 |
+
|
| 104 |
+
@app.route('/about')
|
| 105 |
+
def about():
|
| 106 |
+
"""Merender halaman about."""
|
| 107 |
+
return render_template('about.html')
|
| 108 |
+
|
| 109 |
+
if __name__ == '__main__':
|
| 110 |
+
app.run(debug=True)
|
model.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
from transformers import AutoTokenizer, AutoModelForSequenceClassification
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import matplotlib
|
| 5 |
+
matplotlib.use('Agg') # Gunakan backend 'Agg' agar tidak error di server
|
| 6 |
+
import matplotlib.pyplot as plt
|
| 7 |
+
import io
|
| 8 |
+
import base64
|
| 9 |
+
import re
|
| 10 |
+
from wordcloud import WordCloud, STOPWORDS
|
| 11 |
+
|
| 12 |
+
# --- Bagian 1: Pemuatan Model IndoBERT ---
|
| 13 |
+
|
| 14 |
+
MODEL_NAME = "crypter70/IndoBERT-Sentiment-Analysis"
|
| 15 |
+
tokenizer = None
|
| 16 |
+
model = None
|
| 17 |
+
|
| 18 |
+
def load_model():
|
| 19 |
+
"""
|
| 20 |
+
Lazy-load the tokenizer and model. This avoids long blocking downloads during module import
|
| 21 |
+
and prevents the Flask app from hanging when importing `model.py`.
|
| 22 |
+
"""
|
| 23 |
+
global tokenizer, model
|
| 24 |
+
if tokenizer is not None and model is not None:
|
| 25 |
+
return
|
| 26 |
+
try:
|
| 27 |
+
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
|
| 28 |
+
model = AutoModelForSequenceClassification.from_pretrained(MODEL_NAME)
|
| 29 |
+
model.eval()
|
| 30 |
+
print(f"Model {MODEL_NAME} loaded successfully.")
|
| 31 |
+
except Exception as e:
|
| 32 |
+
print(f"Error loading model {MODEL_NAME}: {e}")
|
| 33 |
+
print("Model and tokenizer set to None. Predictions will not work.")
|
| 34 |
+
tokenizer = None
|
| 35 |
+
model = None
|
| 36 |
+
|
| 37 |
+
# Definisikan label berdasarkan konfigurasi model
|
| 38 |
+
labels = ['Positif', 'Netral', 'Negatif']
|
| 39 |
+
|
| 40 |
+
def predict_sentiment(text):
|
| 41 |
+
"""
|
| 42 |
+
Memprediksi sentimen dari satu string teks.
|
| 43 |
+
"""
|
| 44 |
+
# Pastikan model dimuat secara lazy jika belum
|
| 45 |
+
if model is None or tokenizer is None:
|
| 46 |
+
load_model()
|
| 47 |
+
if not model or not tokenizer:
|
| 48 |
+
print("Model or tokenizer is not loaded. Cannot predict.")
|
| 49 |
+
return "Error: Model not loaded."
|
| 50 |
+
|
| 51 |
+
try:
|
| 52 |
+
# Ubah input menjadi string jika bukan (penting untuk data dari file)
|
| 53 |
+
text = str(text)
|
| 54 |
+
|
| 55 |
+
# Tokenisasi teks
|
| 56 |
+
inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=512, padding=True)
|
| 57 |
+
|
| 58 |
+
# Dapatkan prediksi model
|
| 59 |
+
with torch.no_grad(): # Nonaktifkan perhitungan gradien untuk inferensi
|
| 60 |
+
outputs = model(**inputs)
|
| 61 |
+
|
| 62 |
+
# Dapatkan probabilitas (logits) dan cari ID kelas dengan prob tertinggi
|
| 63 |
+
logits = outputs.logits
|
| 64 |
+
predicted_class_id = torch.argmax(logits, dim=1).item()
|
| 65 |
+
|
| 66 |
+
# Petakan ID ke label yang sesuai
|
| 67 |
+
return labels[predicted_class_id]
|
| 68 |
+
|
| 69 |
+
except Exception as e:
|
| 70 |
+
print(f"Error during prediction: {e}")
|
| 71 |
+
return "Error: Prediction failed."
|
| 72 |
+
|
| 73 |
+
# --- Bagian 2: Analisis File ---
|
| 74 |
+
|
| 75 |
+
def analyze_sentiment_from_file(file_path, text_column_name):
|
| 76 |
+
"""
|
| 77 |
+
Membaca file (CSV atau XLSX), menerapkan prediksi sentimen ke kolom yang ditentukan.
|
| 78 |
+
"""
|
| 79 |
+
try:
|
| 80 |
+
# Baca file berdasarkan ekstensinya
|
| 81 |
+
if file_path.endswith('.csv'):
|
| 82 |
+
df = pd.read_csv(file_path)
|
| 83 |
+
elif file_path.endswith('.xlsx'):
|
| 84 |
+
df = pd.read_excel(file_path)
|
| 85 |
+
else:
|
| 86 |
+
return None, "Error: Unsupported file format."
|
| 87 |
+
|
| 88 |
+
# Cek apakah kolom yang diminta ada di DataFrame
|
| 89 |
+
if text_column_name not in df.columns:
|
| 90 |
+
return None, f"Error: Column '{text_column_name}' not found in the file."
|
| 91 |
+
|
| 92 |
+
# Pastikan kolom teks adalah string (untuk menghindari error .apply())
|
| 93 |
+
df[text_column_name] = df[text_column_name].astype(str)
|
| 94 |
+
|
| 95 |
+
# Terapkan fungsi prediksi sentimen ke kolom yang ditentukan
|
| 96 |
+
# Membuat kolom baru 'Sentiment_Prediction'
|
| 97 |
+
df['Sentiment_Prediction'] = df[text_column_name].apply(predict_sentiment)
|
| 98 |
+
|
| 99 |
+
return df, None # Kembalikan DataFrame dan tidak ada error
|
| 100 |
+
|
| 101 |
+
except FileNotFoundError:
|
| 102 |
+
return None, "Error: File not found."
|
| 103 |
+
except Exception as e:
|
| 104 |
+
print(f"Error processing file: {e}")
|
| 105 |
+
return None, "Error: Could not process the file."
|
| 106 |
+
|
| 107 |
+
# --- Bagian 3: Visualisasi (DIPERBARUI) ---
|
| 108 |
+
|
| 109 |
+
def get_sentiment_counts(df, sentiment_column='Sentiment_Prediction'):
|
| 110 |
+
"""
|
| 111 |
+
Menghitung jumlah sentimen dan mengembalikannya sebagai kamus (dictionary).
|
| 112 |
+
Siap untuk digunakan oleh Chart.js.
|
| 113 |
+
"""
|
| 114 |
+
if sentiment_column not in df.columns:
|
| 115 |
+
print(f"Error: Sentiment column '{sentiment_column}' not found.")
|
| 116 |
+
return {}
|
| 117 |
+
|
| 118 |
+
sentiment_counts = df[sentiment_column].value_counts()
|
| 119 |
+
|
| 120 |
+
# Pastikan semua label ada, bahkan jika jumlahnya 0
|
| 121 |
+
all_labels = ['Positif', 'Netral', 'Negatif']
|
| 122 |
+
for label in all_labels:
|
| 123 |
+
if label not in sentiment_counts:
|
| 124 |
+
sentiment_counts[label] = 0
|
| 125 |
+
|
| 126 |
+
# Mengembalikan sebagai kamus standar
|
| 127 |
+
return sentiment_counts.to_dict()
|
| 128 |
+
|
| 129 |
+
def generate_wordcloud_base64(df, text_column_name):
|
| 130 |
+
"""
|
| 131 |
+
Menghasilkan Word Cloud dari kolom teks dan mengembalikannya sebagai string base64.
|
| 132 |
+
"""
|
| 133 |
+
wordcloud_base64 = None
|
| 134 |
+
try:
|
| 135 |
+
if text_column_name in df.columns:
|
| 136 |
+
# Gabungkan semua teks menjadi satu string besar
|
| 137 |
+
text = " ".join(review for review in df[text_column_name].astype(str))
|
| 138 |
+
|
| 139 |
+
# Tambahkan stopwords dasar bahasa Indonesia
|
| 140 |
+
stopwords_indonesia = set(STOPWORDS)
|
| 141 |
+
stopwords_indonesia.update([
|
| 142 |
+
'yg', 'dg', 'rt', 'dgn', 'ny', 'd', 'k', 'ke', 'di', 'dari', 'dan', 'ini', 'itu',
|
| 143 |
+
'atau', 'pada', 'untuk', 'juga', 'dengan', 'yang', 'ke', 'ya', 'ga', 'gak', 'tidak',
|
| 144 |
+
'ada', 'adalah', 'saya', 'dia', 'kamu', 'kita', 'mereka', 'saja', 'seperti',
|
| 145 |
+
'telah', 'akan', 'tapi', 'namun', 'karena', 'oleh', 'saat', 'sebagai', 'bahwa'
|
| 146 |
+
])
|
| 147 |
+
|
| 148 |
+
# Bersihkan teks (opsional, tapi disarankan)
|
| 149 |
+
text = re.sub(r'http\S+', '', text) # Hapus URL
|
| 150 |
+
text = re.sub(r'[^a-zA-Z\s]', '', text.lower()) # Hapus non-alfabet
|
| 151 |
+
|
| 152 |
+
# Buat Word Cloud
|
| 153 |
+
wordcloud = WordCloud(
|
| 154 |
+
width=800,
|
| 155 |
+
height=400,
|
| 156 |
+
background_color='white',
|
| 157 |
+
stopwords=stopwords_indonesia,
|
| 158 |
+
min_font_size=10,
|
| 159 |
+
colormap='viridis' # Ganti palet warna
|
| 160 |
+
).generate(text)
|
| 161 |
+
|
| 162 |
+
# Simpan ke buffer
|
| 163 |
+
buf_wc = io.BytesIO()
|
| 164 |
+
wordcloud.to_image().save(buf_wc, format='PNG')
|
| 165 |
+
buf_wc.seek(0)
|
| 166 |
+
wordcloud_base64 = base64.b64encode(buf_wc.getvalue()).decode('utf-8')
|
| 167 |
+
plt.close()
|
| 168 |
+
|
| 169 |
+
except Exception as e:
|
| 170 |
+
print(f"Error creating word cloud: {e}")
|
| 171 |
+
wordcloud_base64 = None
|
| 172 |
+
|
| 173 |
+
return wordcloud_base64
|
requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flask
|
| 2 |
+
torch
|
| 3 |
+
transformers
|
| 4 |
+
pandas
|
| 5 |
+
matplotlib
|
| 6 |
+
openpyxl
|
| 7 |
+
werkzeug
|
| 8 |
+
wordcloud
|
| 9 |
+
gunicorn
|
static/css/style.css
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* --- 1. Import Font & Variabel Global Neo-Brutalism --- */
|
| 2 |
+
:root {
|
| 3 |
+
/* Palette Warna Neo-Brutalism */
|
| 4 |
+
--bg-color: #f0f0f0; /* Abu-abu sangat muda untuk background */
|
| 5 |
+
--card-bg: #ffffff; /* Putih untuk kartu */
|
| 6 |
+
--border-color: #000000; /* Hitam pekat untuk border */
|
| 7 |
+
|
| 8 |
+
--primary-color: #FFEB3B; /* Kuning Terang (Main Action) */
|
| 9 |
+
--secondary-color: #FF90E8; /* Pink Neon (Accent) */
|
| 10 |
+
--accent-color: #23A6D5; /* Cyan (Accent 2) */
|
| 11 |
+
--success-color: #00E676; /* Hijau Neon */
|
| 12 |
+
--danger-color: #FF5252; /* Merah Terang */
|
| 13 |
+
|
| 14 |
+
--text-main: #000000;
|
| 15 |
+
--text-muted: #444444;
|
| 16 |
+
|
| 17 |
+
/* Dimensi Brutal */
|
| 18 |
+
--border-width: 3px;
|
| 19 |
+
--shadow-offset: 5px;
|
| 20 |
+
--radius: 0px; /* Sudut tajam (bisa diubah ke 4px-8px jika ingin sedikit lengkung) */
|
| 21 |
+
|
| 22 |
+
--navbar-height: 80px;
|
| 23 |
+
--transition-speed: 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/* --- 2. Reset & Setup Global --- */
|
| 27 |
+
* {
|
| 28 |
+
margin: 0;
|
| 29 |
+
padding: 0;
|
| 30 |
+
box-sizing: border-box;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
body {
|
| 34 |
+
font-family: 'Inter', sans-serif;
|
| 35 |
+
background-color: var(--bg-color);
|
| 36 |
+
background-image: radial-gradient(#000 1px, transparent 1px); /* Pola titik-titik halus */
|
| 37 |
+
background-size: 20px 20px;
|
| 38 |
+
color: var(--text-main);
|
| 39 |
+
line-height: 1.6;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
h1, h2, h3 {
|
| 43 |
+
font-family: 'Space Grotesk', sans-serif;
|
| 44 |
+
color: var(--text-main);
|
| 45 |
+
font-weight: 700;
|
| 46 |
+
text-transform: uppercase;
|
| 47 |
+
letter-spacing: -0.5px;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
a {
|
| 51 |
+
text-decoration: none;
|
| 52 |
+
color: var(--text-main);
|
| 53 |
+
font-weight: 600;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
/* --- 3. Navbar Neo-Brutal --- */
|
| 57 |
+
.navbar {
|
| 58 |
+
width: 100%;
|
| 59 |
+
height: var(--navbar-height);
|
| 60 |
+
background-color: var(--card-bg);
|
| 61 |
+
border-bottom: var(--border-width) solid var(--border-color);
|
| 62 |
+
padding: 0 30px;
|
| 63 |
+
position: fixed;
|
| 64 |
+
top: 0;
|
| 65 |
+
left: 0;
|
| 66 |
+
z-index: 1000;
|
| 67 |
+
display: flex;
|
| 68 |
+
align-items: center;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.navbar-container {
|
| 72 |
+
width: 100%;
|
| 73 |
+
height: 100%;
|
| 74 |
+
display: flex;
|
| 75 |
+
justify-content: space-between;
|
| 76 |
+
align-items: center;
|
| 77 |
+
max-width: 1400px;
|
| 78 |
+
margin: 0 auto;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.navbar-brand {
|
| 82 |
+
font-family: 'Space Grotesk', sans-serif;
|
| 83 |
+
font-size: 1.8rem;
|
| 84 |
+
font-weight: 800;
|
| 85 |
+
color: var(--text-main);
|
| 86 |
+
background-color: var(--primary-color);
|
| 87 |
+
padding: 5px 15px;
|
| 88 |
+
border: var(--border-width) solid var(--border-color);
|
| 89 |
+
box-shadow: 4px 4px 0px var(--border-color);
|
| 90 |
+
transition: transform 0.1s;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.navbar-brand:hover {
|
| 94 |
+
transform: translate(2px, 2px);
|
| 95 |
+
box-shadow: 2px 2px 0px var(--border-color);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.navbar-nav {
|
| 99 |
+
list-style-type: none;
|
| 100 |
+
display: flex;
|
| 101 |
+
gap: 20px;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.navbar-nav li a {
|
| 105 |
+
font-family: 'Space Grotesk', sans-serif;
|
| 106 |
+
padding: 8px 16px;
|
| 107 |
+
border: var(--border-width) solid transparent; /* Invisible border untuk layout */
|
| 108 |
+
transition: all var(--transition-speed);
|
| 109 |
+
font-size: 1.1rem;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.navbar-nav li a:hover {
|
| 113 |
+
background-color: var(--secondary-color);
|
| 114 |
+
border: var(--border-width) solid var(--border-color);
|
| 115 |
+
box-shadow: 4px 4px 0px var(--border-color);
|
| 116 |
+
transform: translate(-2px, -2px);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/* --- 4. Layout Konten --- */
|
| 120 |
+
.content-wrapper {
|
| 121 |
+
margin-top: var(--navbar-height);
|
| 122 |
+
padding: 40px 20px;
|
| 123 |
+
max-width: 1200px;
|
| 124 |
+
margin-left: auto;
|
| 125 |
+
margin-right: auto;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.page-header {
|
| 129 |
+
margin-bottom: 30px;
|
| 130 |
+
background-color: var(--accent-color);
|
| 131 |
+
border: var(--border-width) solid var(--border-color);
|
| 132 |
+
box-shadow: var(--shadow-offset) var(--shadow-offset) 0px var(--border-color);
|
| 133 |
+
padding: 20px;
|
| 134 |
+
display: inline-block;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.page-header h1 {
|
| 138 |
+
margin: 0;
|
| 139 |
+
font-size: 2.5rem;
|
| 140 |
+
color: #fff;
|
| 141 |
+
text-shadow: 2px 2px 0px var(--border-color);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
/* --- 5. Komponen Card (Kotak) --- */
|
| 145 |
+
.card {
|
| 146 |
+
background: var(--card-bg);
|
| 147 |
+
border: var(--border-width) solid var(--border-color);
|
| 148 |
+
padding: 30px;
|
| 149 |
+
margin-bottom: 40px;
|
| 150 |
+
box-shadow: var(--shadow-offset) var(--shadow-offset) 0px var(--border-color);
|
| 151 |
+
transition: transform var(--transition-speed);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.card:hover {
|
| 155 |
+
/* Efek hover halus */
|
| 156 |
+
transform: translate(-2px, -2px);
|
| 157 |
+
box-shadow: 7px 7px 0px var(--border-color);
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.card h2 {
|
| 161 |
+
font-size: 1.8rem;
|
| 162 |
+
margin-bottom: 1.5rem;
|
| 163 |
+
border-bottom: var(--border-width) solid var(--border-color);
|
| 164 |
+
padding-bottom: 10px;
|
| 165 |
+
display: inline-block;
|
| 166 |
+
background-color: var(--primary-color);
|
| 167 |
+
padding: 5px 15px;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
/* --- 6. Form & Input --- */
|
| 171 |
+
.form-group {
|
| 172 |
+
margin-bottom: 1.5rem;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.form-group label {
|
| 176 |
+
display: block;
|
| 177 |
+
margin-bottom: 10px;
|
| 178 |
+
font-weight: 700;
|
| 179 |
+
font-family: 'Space Grotesk', sans-serif;
|
| 180 |
+
font-size: 1.1rem;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.form-control, .form-control-file {
|
| 184 |
+
width: 100%;
|
| 185 |
+
padding: 15px;
|
| 186 |
+
font-size: 1rem;
|
| 187 |
+
font-family: 'Inter', sans-serif;
|
| 188 |
+
border: var(--border-width) solid var(--border-color);
|
| 189 |
+
background-color: #fff;
|
| 190 |
+
border-radius: 0; /* Kotak tajam */
|
| 191 |
+
box-shadow: 4px 4px 0px rgba(0,0,0,0.1);
|
| 192 |
+
transition: all 0.2s;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.form-control:focus {
|
| 196 |
+
outline: none;
|
| 197 |
+
box-shadow: 6px 6px 0px var(--border-color);
|
| 198 |
+
background-color: #fffef0;
|
| 199 |
+
transform: translate(-2px, -2px);
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
textarea.form-control {
|
| 203 |
+
min-height: 150px;
|
| 204 |
+
resize: vertical;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
/* Kustomisasi Input File Button */
|
| 208 |
+
.form-control-file::file-selector-button {
|
| 209 |
+
padding: 8px 15px;
|
| 210 |
+
margin-right: 15px;
|
| 211 |
+
border: var(--border-width) solid var(--border-color);
|
| 212 |
+
background-color: var(--secondary-color);
|
| 213 |
+
color: var(--text-main);
|
| 214 |
+
font-weight: 700;
|
| 215 |
+
cursor: pointer;
|
| 216 |
+
box-shadow: 2px 2px 0px var(--border-color);
|
| 217 |
+
transition: all 0.1s;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
.form-control-file::file-selector-button:hover {
|
| 221 |
+
transform: translate(1px, 1px);
|
| 222 |
+
box-shadow: 1px 1px 0px var(--border-color);
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
/* --- 7. Tombol (Buttons) --- */
|
| 226 |
+
.btn {
|
| 227 |
+
padding: 12px 30px;
|
| 228 |
+
font-size: 1.1rem;
|
| 229 |
+
font-weight: 700;
|
| 230 |
+
font-family: 'Space Grotesk', sans-serif;
|
| 231 |
+
text-transform: uppercase;
|
| 232 |
+
border: var(--border-width) solid var(--border-color);
|
| 233 |
+
cursor: pointer;
|
| 234 |
+
display: inline-block;
|
| 235 |
+
text-align: center;
|
| 236 |
+
box-shadow: 5px 5px 0px var(--border-color);
|
| 237 |
+
transition: all 0.1s;
|
| 238 |
+
position: relative;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.btn:active {
|
| 242 |
+
transform: translate(4px, 4px);
|
| 243 |
+
box-shadow: 1px 1px 0px var(--border-color);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.btn-primary {
|
| 247 |
+
background-color: var(--primary-color); /* Kuning */
|
| 248 |
+
color: var(--text-main);
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.btn-primary:hover {
|
| 252 |
+
background-color: #ffe600;
|
| 253 |
+
transform: translate(-2px, -2px);
|
| 254 |
+
box-shadow: 7px 7px 0px var(--border-color);
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
.btn-success {
|
| 258 |
+
background-color: var(--success-color); /* Hijau */
|
| 259 |
+
color: var(--text-main);
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
.btn-success:hover {
|
| 263 |
+
background-color: #00c853;
|
| 264 |
+
transform: translate(-2px, -2px);
|
| 265 |
+
box-shadow: 7px 7px 0px var(--border-color);
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
/* --- 8. Alerts / Flash Messages --- */
|
| 269 |
+
.alert {
|
| 270 |
+
padding: 20px;
|
| 271 |
+
margin-bottom: 30px;
|
| 272 |
+
border: var(--border-width) solid var(--border-color);
|
| 273 |
+
display: flex;
|
| 274 |
+
justify-content: space-between;
|
| 275 |
+
align-items: center;
|
| 276 |
+
font-weight: 600;
|
| 277 |
+
box-shadow: 5px 5px 0px var(--border-color);
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
.alert-danger {
|
| 281 |
+
background-color: var(--danger-color);
|
| 282 |
+
color: #fff;
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
.alert-success {
|
| 286 |
+
background-color: var(--success-color);
|
| 287 |
+
color: #000;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
.alert-close {
|
| 291 |
+
background: none;
|
| 292 |
+
border: 2px solid currentColor;
|
| 293 |
+
width: 30px;
|
| 294 |
+
height: 30px;
|
| 295 |
+
font-size: 1.2rem;
|
| 296 |
+
cursor: pointer;
|
| 297 |
+
display: flex;
|
| 298 |
+
align-items: center;
|
| 299 |
+
justify-content: center;
|
| 300 |
+
border-radius: 50%;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
.alert-close:hover {
|
| 304 |
+
background-color: rgba(0,0,0,0.1);
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
/* --- 9. Visualisasi & Hasil --- */
|
| 308 |
+
.result-block {
|
| 309 |
+
padding: 20px;
|
| 310 |
+
background-color: #fff;
|
| 311 |
+
border: var(--border-width) solid var(--border-color);
|
| 312 |
+
margin-top: 2rem;
|
| 313 |
+
box-shadow: 4px 4px 0px var(--border-color);
|
| 314 |
+
background-image: linear-gradient(45deg, #f0f0f0 25%, transparent 25%, transparent 75%, #f0f0f0 75%, #f0f0f0), linear-gradient(45deg, #f0f0f0 25%, transparent 25%, transparent 75%, #f0f0f0 75%, #f0f0f0);
|
| 315 |
+
background-size: 20px 20px;
|
| 316 |
+
background-position: 0 0, 10px 10px;
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
.result-block h3 {
|
| 320 |
+
background-color: #fff;
|
| 321 |
+
border: var(--border-width) solid var(--border-color);
|
| 322 |
+
padding: 10px;
|
| 323 |
+
display: inline-block;
|
| 324 |
+
margin: 0;
|
| 325 |
+
font-size: 2rem;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
.charts-grid {
|
| 329 |
+
display: flex;
|
| 330 |
+
flex-wrap: wrap;
|
| 331 |
+
gap: 30px;
|
| 332 |
+
margin-bottom: 30px;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
.chart-box {
|
| 336 |
+
flex: 1;
|
| 337 |
+
min-width: 300px;
|
| 338 |
+
padding: 20px;
|
| 339 |
+
border: var(--border-width) solid var(--border-color);
|
| 340 |
+
box-shadow: 5px 5px 0px var(--border-color);
|
| 341 |
+
text-align: center;
|
| 342 |
+
background: #fff;
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
.chart-box h3 {
|
| 346 |
+
background-color: var(--accent-color);
|
| 347 |
+
color: #fff;
|
| 348 |
+
display: inline-block;
|
| 349 |
+
padding: 5px 10px;
|
| 350 |
+
border: 2px solid var(--border-color);
|
| 351 |
+
margin-bottom: 20px;
|
| 352 |
+
font-size: 1.2rem;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
.table-responsive {
|
| 356 |
+
max-height: 500px;
|
| 357 |
+
overflow-y: auto;
|
| 358 |
+
border: var(--border-width) solid var(--border-color);
|
| 359 |
+
box-shadow: 5px 5px 0px var(--border-color);
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
/* Table Styling */
|
| 363 |
+
.table {
|
| 364 |
+
width: 100%;
|
| 365 |
+
border-collapse: collapse;
|
| 366 |
+
}
|
| 367 |
+
.table th, .table td {
|
| 368 |
+
border: 2px solid var(--border-color);
|
| 369 |
+
padding: 12px;
|
| 370 |
+
text-align: left;
|
| 371 |
+
}
|
| 372 |
+
.table th {
|
| 373 |
+
background-color: var(--primary-color);
|
| 374 |
+
font-family: 'Space Grotesk', sans-serif;
|
| 375 |
+
text-transform: uppercase;
|
| 376 |
+
}
|
| 377 |
+
.table-striped tbody tr:nth-of-type(odd) {
|
| 378 |
+
background-color: #f9f9f9;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
/* --- 10. Spinner --- */
|
| 382 |
+
.spinner-overlay {
|
| 383 |
+
display: none;
|
| 384 |
+
position: fixed;
|
| 385 |
+
top: 0;
|
| 386 |
+
left: 0;
|
| 387 |
+
width: 100%;
|
| 388 |
+
height: 100%;
|
| 389 |
+
background-color: rgba(255, 255, 255, 0.9);
|
| 390 |
+
z-index: 9999;
|
| 391 |
+
align-items: center;
|
| 392 |
+
justify-content: center;
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
.spinner-container {
|
| 396 |
+
text-align: center;
|
| 397 |
+
background: var(--primary-color);
|
| 398 |
+
padding: 40px;
|
| 399 |
+
border: var(--border-width) solid var(--border-color);
|
| 400 |
+
box-shadow: 10px 10px 0px var(--border-color);
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
.spinner-container p {
|
| 404 |
+
margin-top: 20px;
|
| 405 |
+
font-weight: 800;
|
| 406 |
+
font-family: 'Space Grotesk', sans-serif;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
.spinner {
|
| 410 |
+
width: 60px;
|
| 411 |
+
height: 60px;
|
| 412 |
+
border: 8px solid #fff;
|
| 413 |
+
border-left-color: var(--border-color);
|
| 414 |
+
border-bottom-color: var(--border-color);
|
| 415 |
+
border-radius: 50%;
|
| 416 |
+
animation: spin 0.8s linear infinite;
|
| 417 |
+
margin: 0 auto;
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
@keyframes spin {
|
| 421 |
+
to { transform: rotate(360deg); }
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
.hidden { display: none !important; }
|
| 425 |
+
|
| 426 |
+
/* --- 11. Responsive Mobile --- */
|
| 427 |
+
.hamburger-btn {
|
| 428 |
+
display: none;
|
| 429 |
+
background: var(--secondary-color);
|
| 430 |
+
border: var(--border-width) solid var(--border-color);
|
| 431 |
+
color: var(--text-main);
|
| 432 |
+
font-size: 1.5rem;
|
| 433 |
+
padding: 5px 10px;
|
| 434 |
+
box-shadow: 3px 3px 0px var(--border-color);
|
| 435 |
+
cursor: pointer;
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
.hamburger-btn:active {
|
| 439 |
+
transform: translate(2px, 2px);
|
| 440 |
+
box-shadow: 1px 1px 0px var(--border-color);
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
@media (max-width: 768px) {
|
| 444 |
+
.hamburger-btn { display: block; }
|
| 445 |
+
|
| 446 |
+
.navbar-container { padding: 0 20px; }
|
| 447 |
+
|
| 448 |
+
.navbar-nav-container {
|
| 449 |
+
position: absolute;
|
| 450 |
+
top: var(--navbar-height);
|
| 451 |
+
left: 0;
|
| 452 |
+
width: 100%;
|
| 453 |
+
background-color: var(--card-bg);
|
| 454 |
+
border-bottom: var(--border-width) solid var(--border-color);
|
| 455 |
+
max-height: 0;
|
| 456 |
+
overflow: hidden;
|
| 457 |
+
transition: max-height 0.3s ease-out;
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
.navbar-nav-container.active {
|
| 461 |
+
max-height: 300px;
|
| 462 |
+
border-top: var(--border-width) solid var(--border-color);
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
.navbar-nav {
|
| 466 |
+
flex-direction: column;
|
| 467 |
+
padding: 20px;
|
| 468 |
+
gap: 10px;
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
.navbar-nav li a {
|
| 472 |
+
display: block;
|
| 473 |
+
border: 2px solid var(--border-color);
|
| 474 |
+
text-align: center;
|
| 475 |
+
background-color: #f9f9f9;
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
.page-header h1 { font-size: 1.8rem; }
|
| 479 |
+
|
| 480 |
+
.charts-grid { flex-direction: column; }
|
| 481 |
+
}
|
static/images/ikn.png
ADDED
|
Git LFS Details
|
static/images/whoosh.png
ADDED
|
Git LFS Details
|
templates/about.html
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends 'layout.html' %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Tentang Sentimenter{% endblock %}
|
| 4 |
+
|
| 5 |
+
<!-- TAMBAHKAN BLOCK INI untuk mengisi H1 yang sudah diperbaiki -->
|
| 6 |
+
{% block page_title %}Tentang Sentimenter{% endblock %}
|
| 7 |
+
|
| 8 |
+
{% block content %}
|
| 9 |
+
<div class="card">
|
| 10 |
+
<p style="font-size: 1.1em; margin-bottom: 25px;">
|
| 11 |
+
Aplikasi ini adalah prototipe untuk mendemonstrasikan kekuatan model pemrosesan bahasa alami (NLP)
|
| 12 |
+
dalam menganalisis sentimen teks berbahasa Indonesia.
|
| 13 |
+
</p>
|
| 14 |
+
|
| 15 |
+
<h2>Teknologi yang Digunakan</h2>
|
| 16 |
+
<ul style="list-style-type: none; padding-left: 0;">
|
| 17 |
+
<li style="margin-bottom: 10px; font-size: 1.1em;"><i class="fab fa-python" style="color: #3498db; margin-right: 10px; width: 20px;"></i> <strong>Backend:</strong> Flask (Python)</li>
|
| 18 |
+
<li style="margin-bottom: 10px; font-size: 1.1em;"><i class="fab fa-html5" style="color: #e74c3c; margin-right: 10px; width: 20px;"></i> <strong>Frontend:</strong> HTML & CSS Kustom</li>
|
| 19 |
+
<li style="margin-bottom: 10px; font-size: 1.1em;"><i class="fas fa-brain" style="color: #9b59b6; margin-right: 10px; width: 20px;"></i> <strong>Model ML:</strong> IndoBERT (Transformers)</li>
|
| 20 |
+
<li style="margin-bottom: 10px; font-size: 1.1em;"><i class="fas fa-database" style="color: #f39c12; margin-right: 10px; width: 20px;"></i> <strong>Analisis Data:</strong> Pandas</li>
|
| 21 |
+
<li style="margin-bottom: 10px; font-size: 1.1em;"><i class="fas fa-chart-pie" style="color: #2ecc71; margin-right: 10px; width: 20px;"></i> <strong>Visualisasi:</strong> Matplotlib, WordCloud</li>
|
| 22 |
+
</ul>
|
| 23 |
+
</div>
|
| 24 |
+
|
| 25 |
+
<div class="card">
|
| 26 |
+
<h2>Profil Developer (BRIN devs)</h2>
|
| 27 |
+
<p>
|
| 28 |
+
Website ini dikembangkan oleh BRIN devs, sekumpulan mahasiswa Sains Data yang kepo tentang data science, machine learning, dan AI.
|
| 29 |
+
Kami bersemangat untuk mengeksplorasi bagaimana teknologi ini dapat digunakan untuk menciptakan solusi inovatif.
|
| 30 |
+
Ikuti terus perjalanan kami di media sosial dan GitHub untuk update terbaru tentang proyek-proyek kami!
|
| 31 |
+
</p>
|
| 32 |
+
</div>
|
| 33 |
+
{% endblock %}
|
templates/analysis.html
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends 'layout.html' %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Analisis Sentimen{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block page_title %}Analisis Sentimen{% endblock %}
|
| 6 |
+
|
| 7 |
+
{% block content %}
|
| 8 |
+
<p style="font-size: 1.1em; margin-top: -1rem; margin-bottom: 2rem; font-weight: 500;">
|
| 9 |
+
Gunakan bagian ini untuk menganalisis sentimen dari teks tunggal atau mengunggah file untuk analisis massal.
|
| 10 |
+
</p>
|
| 11 |
+
|
| 12 |
+
<!-- Menampilkan Flash Messages -->
|
| 13 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 14 |
+
{% if messages %}
|
| 15 |
+
{% for category, message in messages %}
|
| 16 |
+
<div class="alert alert-{{ category }}" role="alert">
|
| 17 |
+
<div>
|
| 18 |
+
<i class="alert-icon fas {% if category == 'danger' %}fa-exclamation-triangle{% else %}fa-check-circle{% endif %}"></i>
|
| 19 |
+
{{ message }}
|
| 20 |
+
</div>
|
| 21 |
+
<button type="button" class="alert-close">×</button>
|
| 22 |
+
</div>
|
| 23 |
+
{% endfor %}
|
| 24 |
+
{% endif %}
|
| 25 |
+
{% endwith %}
|
| 26 |
+
|
| 27 |
+
{# Embed JSON data #}
|
| 28 |
+
<script id="sentiment-data" type="application/json">{% if sentiment_counts %}{{ sentiment_counts | tojson | safe }}{% else %}null{% endif %}</script>
|
| 29 |
+
|
| 30 |
+
<!-- Bagian 1: Analisis Teks Tunggal -->
|
| 31 |
+
<div class="card">
|
| 32 |
+
<h2>Analisis Teks Tunggal</h2>
|
| 33 |
+
<p>Masukkan sebuah kalimat atau paragraf dalam bahasa Indonesia untuk dianalisis.</p>
|
| 34 |
+
|
| 35 |
+
<form method="POST" action="{{ url_for('analysis') }}">
|
| 36 |
+
<div class="form-group">
|
| 37 |
+
<label for="text_input_area">Teks Anda:</label>
|
| 38 |
+
<textarea name="text_input" id="text_input_area" rows="6" class="form-control"
|
| 39 |
+
placeholder="Ketik atau tempel teks Anda di sini...">{{ text }}</textarea>
|
| 40 |
+
</div>
|
| 41 |
+
<button type="submit" class="btn btn-primary">
|
| 42 |
+
<i class="fas fa-bolt"></i> Analisis Teks
|
| 43 |
+
</button>
|
| 44 |
+
</form>
|
| 45 |
+
|
| 46 |
+
<!-- Hasil Prediksi -->
|
| 47 |
+
{% if prediction %}
|
| 48 |
+
<div class="result-block">
|
| 49 |
+
<h2 style="font-size: 1.2rem; margin-bottom: 1rem; border: none; background: none; padding: 0;">HASIL ANALISIS:</h2>
|
| 50 |
+
<h3>{{ prediction }}</h3>
|
| 51 |
+
</div>
|
| 52 |
+
{% endif %}
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<!-- Bagian 2: Analisis File Massal -->
|
| 56 |
+
<div class="card">
|
| 57 |
+
<h2>Analisis File (CSV/XLSX)</h2>
|
| 58 |
+
<p>Upload file untuk analisis sentimen massal.</p>
|
| 59 |
+
|
| 60 |
+
<form method="POST" enctype="multipart/form-data" action="{{ url_for('analysis') }}" id="file-analysis-form">
|
| 61 |
+
<div class="form-group">
|
| 62 |
+
<label for="file">Pilih File:</label>
|
| 63 |
+
<input type="file" name="file" id="file" class="form-control-file" required>
|
| 64 |
+
</div>
|
| 65 |
+
<div class="form-group">
|
| 66 |
+
<label for="text_column">Nama Kolom Teks:</label>
|
| 67 |
+
<input type="text" name="text_column" id="text_column" class="form-control" placeholder="Contoh: 'tweet_text'" required>
|
| 68 |
+
</div>
|
| 69 |
+
<button type="submit" class="btn btn-success">
|
| 70 |
+
<i class="fas fa-file-upload"></i> Upload & Proses
|
| 71 |
+
</button>
|
| 72 |
+
</form>
|
| 73 |
+
|
| 74 |
+
{% if sentiment_counts %}
|
| 75 |
+
<div class="results-container" style="margin-top: 3rem;">
|
| 76 |
+
<h2 style="font-size: 2rem; margin-bottom: 20px; text-decoration: underline;">VISUALISASI HASIL</h2>
|
| 77 |
+
<div class="charts-grid">
|
| 78 |
+
|
| 79 |
+
<!-- Bar Chart -->
|
| 80 |
+
<div class="chart-box">
|
| 81 |
+
<h3>Distribusi Sentimen</h3>
|
| 82 |
+
<canvas id="barChartCanvas"></canvas>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
<!-- Pie Chart -->
|
| 86 |
+
<div class="chart-box">
|
| 87 |
+
<h3>Proporsi Sentimen</h3>
|
| 88 |
+
<div style="position: relative; max-height: 400px; margin: auto;">
|
| 89 |
+
<canvas id="pieChartCanvas"></canvas>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
{% if wordcloud %}
|
| 95 |
+
<div class="chart-box" style="width: 100%; margin-bottom: 30px;">
|
| 96 |
+
<h3>Word Cloud</h3>
|
| 97 |
+
<img src="data:image/png;base64,{{ wordcloud }}" alt="Word Cloud" style="max-width: 100%; border: 2px solid #000;">
|
| 98 |
+
</div>
|
| 99 |
+
{% endif %}
|
| 100 |
+
|
| 101 |
+
<h2 style="font-size: 1.5rem; margin-top: 2rem;">Data Tabel</h2>
|
| 102 |
+
<div class="table-responsive">
|
| 103 |
+
{{ results_table|safe }}
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
{% endif %}
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
{% endblock %}
|
| 110 |
+
|
| 111 |
+
<!-- Script Khusus untuk Chart Neo-Brutalism -->
|
| 112 |
+
{% block scripts %}
|
| 113 |
+
<script>
|
| 114 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 115 |
+
const fileForm = document.getElementById('file-analysis-form');
|
| 116 |
+
const spinnerOverlay = document.getElementById('spinner-overlay');
|
| 117 |
+
|
| 118 |
+
if (fileForm) {
|
| 119 |
+
fileForm.addEventListener('submit', function() {
|
| 120 |
+
const fileInput = document.getElementById('file');
|
| 121 |
+
const columnInput = document.getElementById('text_column');
|
| 122 |
+
if (fileInput.value && columnInput.value && spinnerOverlay) {
|
| 123 |
+
spinnerOverlay.style.display = 'flex'; // Gunakan flex agar center
|
| 124 |
+
spinnerOverlay.classList.remove('hidden');
|
| 125 |
+
}
|
| 126 |
+
});
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
const countsEl = document.getElementById('sentiment-data');
|
| 130 |
+
let counts = null;
|
| 131 |
+
|
| 132 |
+
if (countsEl) {
|
| 133 |
+
try {
|
| 134 |
+
counts = JSON.parse(countsEl.textContent || 'null');
|
| 135 |
+
} catch (e) {
|
| 136 |
+
console.error("Gagal mem-parsing data sentimen:", e);
|
| 137 |
+
counts = null;
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
if (counts && Object.keys(counts).length > 0) {
|
| 142 |
+
// Setup Data
|
| 143 |
+
const labels = ['Positif', 'Netral', 'Negatif'];
|
| 144 |
+
const data = [
|
| 145 |
+
counts['Positif'] || 0,
|
| 146 |
+
counts['Netral'] || 0,
|
| 147 |
+
counts['Negatif'] || 0
|
| 148 |
+
];
|
| 149 |
+
|
| 150 |
+
// Warna Neo-Brutalism
|
| 151 |
+
const bgColors = [
|
| 152 |
+
'#00E676', // Hijau
|
| 153 |
+
'#d1d1d1', // Abu
|
| 154 |
+
'#FF5252' // Merah
|
| 155 |
+
];
|
| 156 |
+
|
| 157 |
+
const borderColors = '#000000'; // Border Hitam Pekat
|
| 158 |
+
const borderWidth = 3; // Border Tebal
|
| 159 |
+
|
| 160 |
+
// Config Font
|
| 161 |
+
Chart.defaults.font.family = "'Space Grotesk', sans-serif";
|
| 162 |
+
Chart.defaults.color = '#000';
|
| 163 |
+
|
| 164 |
+
// 4. Buat Bar Chart
|
| 165 |
+
const barCtx = document.getElementById('barChartCanvas');
|
| 166 |
+
if (barCtx) {
|
| 167 |
+
new Chart(barCtx, {
|
| 168 |
+
type: 'bar',
|
| 169 |
+
data: {
|
| 170 |
+
labels: labels,
|
| 171 |
+
datasets: [{
|
| 172 |
+
label: 'Jumlah',
|
| 173 |
+
data: data,
|
| 174 |
+
backgroundColor: bgColors,
|
| 175 |
+
borderColor: borderColors,
|
| 176 |
+
borderWidth: borderWidth,
|
| 177 |
+
hoverOffset: 4
|
| 178 |
+
}]
|
| 179 |
+
},
|
| 180 |
+
options: {
|
| 181 |
+
responsive: true,
|
| 182 |
+
plugins: {
|
| 183 |
+
legend: { display: false }
|
| 184 |
+
},
|
| 185 |
+
scales: {
|
| 186 |
+
y: {
|
| 187 |
+
beginAtZero: true,
|
| 188 |
+
grid: { color: '#000', lineWidth: 1 },
|
| 189 |
+
ticks: { color: '#000', font: { weight: 'bold' } }
|
| 190 |
+
},
|
| 191 |
+
x: {
|
| 192 |
+
grid: { display: false },
|
| 193 |
+
ticks: { color: '#000', font: { weight: 'bold' } }
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
});
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
// 5. Buat Pie Chart
|
| 201 |
+
const pieCtx = document.getElementById('pieChartCanvas');
|
| 202 |
+
if (pieCtx) {
|
| 203 |
+
new Chart(pieCtx, {
|
| 204 |
+
type: 'pie',
|
| 205 |
+
data: {
|
| 206 |
+
labels: labels,
|
| 207 |
+
datasets: [{
|
| 208 |
+
data: data,
|
| 209 |
+
backgroundColor: bgColors,
|
| 210 |
+
borderColor: borderColors,
|
| 211 |
+
borderWidth: borderWidth
|
| 212 |
+
}]
|
| 213 |
+
},
|
| 214 |
+
options: {
|
| 215 |
+
responsive: true,
|
| 216 |
+
plugins: {
|
| 217 |
+
legend: {
|
| 218 |
+
position: 'bottom',
|
| 219 |
+
labels: { color: '#000', font: { weight: 'bold' } }
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
});
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
});
|
| 227 |
+
</script>
|
| 228 |
+
{% endblock %}
|
templates/dashboard.html
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends 'layout.html' %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Dashboard Sentimen{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block page_title %}DASHBOARD SENTIMEN{% endblock %}
|
| 6 |
+
|
| 7 |
+
{% block content %}
|
| 8 |
+
<p style="font-size: 1.1em; margin-top: -1rem; margin-bottom: 2rem; font-weight: 500;">
|
| 9 |
+
Analisis sentimen terkini mengenai topik-topik hangat di Indonesia.
|
| 10 |
+
</p>
|
| 11 |
+
|
| 12 |
+
<div id="dashboard-container">
|
| 13 |
+
<!-- Loop data dari Flask -->
|
| 14 |
+
{% for key, data in dashboard_data.items() %}
|
| 15 |
+
<div class="card" style="border-width: 4px; box-shadow: 8px 8px 0px #000;">
|
| 16 |
+
<div style="background-color: var(--primary-color); border-bottom: 4px solid #000; padding: 15px; margin: -30px -30px 20px -30px;">
|
| 17 |
+
<h2 style="margin: 0; border: none; font-size: 1.5rem; padding: 0; background: none;">{{ data.title }}</h2>
|
| 18 |
+
</div>
|
| 19 |
+
|
| 20 |
+
<div class="charts-grid" style="margin-top: 30px;">
|
| 21 |
+
<!-- Bar Chart Container -->
|
| 22 |
+
<div class="chart-box">
|
| 23 |
+
<h3>Distribusi</h3>
|
| 24 |
+
<canvas id="bar-{{ key }}"></canvas>
|
| 25 |
+
</div>
|
| 26 |
+
|
| 27 |
+
<!-- Pie Chart Container -->
|
| 28 |
+
<div class="chart-box">
|
| 29 |
+
<h3>Proporsi</h3>
|
| 30 |
+
<div style="position: relative; max-height: 300px; margin: auto;">
|
| 31 |
+
<canvas id="pie-{{ key }}"></canvas>
|
| 32 |
+
</div>
|
| 33 |
+
</div>
|
| 34 |
+
</div>
|
| 35 |
+
|
| 36 |
+
<!-- Word Cloud Image Section -->
|
| 37 |
+
<div class="chart-box" style="width: 100%; margin-top: 30px; border: 3px solid #000;">
|
| 38 |
+
<h3>Word Cloud</h3>
|
| 39 |
+
<!-- Menggunakan url_for static -->
|
| 40 |
+
<img src="{{ url_for('static', filename='images/' + data.image) }}"
|
| 41 |
+
alt="Word Cloud {{ data.title }}"
|
| 42 |
+
style="max-width: 100%; height: auto; display: block; margin: 0 auto;"
|
| 43 |
+
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
|
| 44 |
+
|
| 45 |
+
<!-- Fallback jika gambar tidak ditemukan -->
|
| 46 |
+
<div style="display: none; padding: 40px; color: #666;">
|
| 47 |
+
<i class="fas fa-image" style="font-size: 3rem; margin-bottom: 10px;"></i><br>
|
| 48 |
+
Simpan file <strong>{{ data.image }}</strong> di folder <code>static/images/</code>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
{% endfor %}
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
{% endblock %}
|
| 56 |
+
|
| 57 |
+
{% block scripts %}
|
| 58 |
+
<script>
|
| 59 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 60 |
+
// 1. Ambil data lengkap dari Flask
|
| 61 |
+
const dashboardData = {{ dashboard_data | tojson }};
|
| 62 |
+
|
| 63 |
+
// Konfigurasi Font & Warna Neo-Brutalism
|
| 64 |
+
Chart.defaults.font.family = "'Space Grotesk', sans-serif";
|
| 65 |
+
Chart.defaults.color = '#000';
|
| 66 |
+
|
| 67 |
+
const bgColors = ['#00E676', '#d1d1d1', '#FF5252']; // Hijau, Abu, Merah
|
| 68 |
+
const borderColors = '#000000';
|
| 69 |
+
const borderWidth = 3;
|
| 70 |
+
|
| 71 |
+
// 2. Loop setiap topik (IKN, Whoosh)
|
| 72 |
+
for (const [key, item] of Object.entries(dashboardData)) {
|
| 73 |
+
const counts = item.counts;
|
| 74 |
+
const labels = ['Positif', 'Netral', 'Negatif'];
|
| 75 |
+
const dataValues = [
|
| 76 |
+
counts['Positif'] || 0,
|
| 77 |
+
counts['Netral'] || 0,
|
| 78 |
+
counts['Negatif'] || 0
|
| 79 |
+
];
|
| 80 |
+
|
| 81 |
+
// Render Bar Chart
|
| 82 |
+
const barCtx = document.getElementById(`bar-${key}`);
|
| 83 |
+
if (barCtx) {
|
| 84 |
+
new Chart(barCtx, {
|
| 85 |
+
type: 'bar',
|
| 86 |
+
data: {
|
| 87 |
+
labels: labels,
|
| 88 |
+
datasets: [{
|
| 89 |
+
label: 'Jumlah',
|
| 90 |
+
data: dataValues,
|
| 91 |
+
backgroundColor: bgColors,
|
| 92 |
+
borderColor: borderColors,
|
| 93 |
+
borderWidth: borderWidth
|
| 94 |
+
}]
|
| 95 |
+
},
|
| 96 |
+
options: {
|
| 97 |
+
responsive: true,
|
| 98 |
+
plugins: { legend: { display: false } },
|
| 99 |
+
scales: {
|
| 100 |
+
y: {
|
| 101 |
+
beginAtZero: true,
|
| 102 |
+
grid: { color: '#000', lineWidth: 1 },
|
| 103 |
+
ticks: { color: '#000', font: { weight: 'bold' } }
|
| 104 |
+
},
|
| 105 |
+
x: {
|
| 106 |
+
grid: { display: false },
|
| 107 |
+
ticks: { color: '#000', font: { weight: 'bold' } }
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
});
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// Render Pie Chart
|
| 115 |
+
const pieCtx = document.getElementById(`pie-${key}`);
|
| 116 |
+
if (pieCtx) {
|
| 117 |
+
new Chart(pieCtx, {
|
| 118 |
+
type: 'pie',
|
| 119 |
+
data: {
|
| 120 |
+
labels: labels,
|
| 121 |
+
datasets: [{
|
| 122 |
+
data: dataValues,
|
| 123 |
+
backgroundColor: bgColors,
|
| 124 |
+
borderColor: borderColors,
|
| 125 |
+
borderWidth: borderWidth
|
| 126 |
+
}]
|
| 127 |
+
},
|
| 128 |
+
options: {
|
| 129 |
+
responsive: true,
|
| 130 |
+
plugins: {
|
| 131 |
+
legend: {
|
| 132 |
+
position: 'bottom',
|
| 133 |
+
labels: { color: '#000', font: { weight: 'bold' } }
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
});
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
});
|
| 141 |
+
</script>
|
| 142 |
+
{% endblock %}
|
templates/home.html
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends 'layout.html' %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Home{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block page_title %}{% endblock %} <!-- Kosongkan default title, kita gunakan Hero section -->
|
| 6 |
+
|
| 7 |
+
{% block content %}
|
| 8 |
+
<!-- Hero Section -->
|
| 9 |
+
<div style="text-align: center; padding: 60px 20px; margin-bottom: 40px; background: linear-gradient(180deg, rgba(52,152,219,0.05) 0%, rgba(255,255,255,0) 100%); border-radius: 20px;">
|
| 10 |
+
<h1 style="font-size: 3.5rem; margin-bottom: 20px; background: -webkit-linear-gradient(#2980b9, #3498db); -webkit-background-clip: text; -webkit-text-fill-color: transparent;">
|
| 11 |
+
Sentimenter AI
|
| 12 |
+
</h1>
|
| 13 |
+
<p style="font-size: 1.3rem; max-width: 700px; margin: 0 auto 40px auto; color: #7f8c8d;">
|
| 14 |
+
Pahami emosi di balik kata-kata dengan kekuatan Artificial Intelligence.
|
| 15 |
+
Analisis ulasan pelanggan, komentar media sosial, dan dokumen teks secara instan.
|
| 16 |
+
</p>
|
| 17 |
+
<a href="{{ url_for('analysis') }}" class="btn btn-primary" style="padding: 16px 40px; font-size: 1.1rem; border-radius: 50px;">
|
| 18 |
+
Mulai Analisis <i class="fas fa-arrow-right" style="margin-left: 10px;"></i>
|
| 19 |
+
</a>
|
| 20 |
+
</div>
|
| 21 |
+
|
| 22 |
+
<!-- Features Grid -->
|
| 23 |
+
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 30px;">
|
| 24 |
+
|
| 25 |
+
<div class="card" style="text-align: center;">
|
| 26 |
+
<div style="width: 60px; height: 60px; background: rgba(52, 152, 219, 0.1); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 20px auto;">
|
| 27 |
+
<i class="fas fa-bolt" style="font-size: 1.5rem; color: #3498db;"></i>
|
| 28 |
+
</div>
|
| 29 |
+
<h3>Cepat & Akurat</h3>
|
| 30 |
+
<p>Didukung oleh model IndoBERT yang telah dilatih khusus untuk memahami konteks bahasa Indonesia, termasuk bahasa gaul.</p>
|
| 31 |
+
</div>
|
| 32 |
+
|
| 33 |
+
<div class="card" style="text-align: center;">
|
| 34 |
+
<div style="width: 60px; height: 60px; background: rgba(46, 204, 113, 0.1); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 20px auto;">
|
| 35 |
+
<i class="fas fa-layer-group" style="font-size: 1.5rem; color: #2ecc71;"></i>
|
| 36 |
+
</div>
|
| 37 |
+
<h3>Analisis Massal</h3>
|
| 38 |
+
<p>Hemat waktu dengan mengunggah file CSV atau Excel. Proses ribuan baris data hanya dalam sekali klik.</p>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<div class="card" style="text-align: center;">
|
| 42 |
+
<div style="width: 60px; height: 60px; background: rgba(231, 76, 60, 0.1); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 20px auto;">
|
| 43 |
+
<i class="fas fa-chart-bar" style="font-size: 1.5rem; color: #e74c3c;"></i>
|
| 44 |
+
</div>
|
| 45 |
+
<h3>Visualisasi Data</h3>
|
| 46 |
+
<p>Dapatkan wawasan mendalam melalui grafik interaktif dan Word Cloud yang estetis dan mudah dipahami.</p>
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
+
</div>
|
| 50 |
+
{% endblock %}
|
templates/layout.html
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="id">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{% block title %}Sentimenter{% endblock %} - Analisis Sentimen</title>
|
| 7 |
+
|
| 8 |
+
<!-- 1. Google Font: Space Grotesk (Judul) & Inter (Body) -->
|
| 9 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 10 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 11 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
|
| 12 |
+
|
| 13 |
+
<!-- 2. Font Awesome (untuk Ikon) -->
|
| 14 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css"
|
| 15 |
+
xintegrity="sha512-Fo3rlrZj/k7ujTnHg4CGR2D7kSs0v4LLanw2qksYuRlEzO+tcaEPQogQ0KaoGN26/zrn20ImR1DfuLWnOo7aBA=="
|
| 16 |
+
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
| 17 |
+
|
| 18 |
+
<!-- 3. Link ke File CSS Eksternal -->
|
| 19 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
| 20 |
+
|
| 21 |
+
</head>
|
| 22 |
+
<body>
|
| 23 |
+
|
| 24 |
+
<!-- 5. Loading Spinner (Neo-Brutalism Style) -->
|
| 25 |
+
<div class_alias="spinner-overlay" id="spinner-overlay" class="hidden">
|
| 26 |
+
<div class="spinner-container">
|
| 27 |
+
<div class="spinner"></div>
|
| 28 |
+
<p>MEMPROSES DATA...</p>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
|
| 32 |
+
<!-- 4. Navbar Atas -->
|
| 33 |
+
<nav class="navbar">
|
| 34 |
+
<div class="navbar-container">
|
| 35 |
+
<a href="{{ url_for('home') }}" class="navbar-brand">
|
| 36 |
+
<i class="fas fa-robot"></i> SENTIMENTER
|
| 37 |
+
</a>
|
| 38 |
+
|
| 39 |
+
<button class="hamburger-btn" id="hamburger-btn">
|
| 40 |
+
<i class="fas fa-bars"></i>
|
| 41 |
+
</button>
|
| 42 |
+
|
| 43 |
+
<div class="navbar-nav-container" id="navbar-nav-container">
|
| 44 |
+
<ul class="navbar-nav">
|
| 45 |
+
<li><a href="{{ url_for('home') }}">Home</a></li>
|
| 46 |
+
<li><a href="{{ url_for('dashboard') }}">Dashboard</a></li>
|
| 47 |
+
<li><a href="{{ url_for('analysis') }}">Analysis</a></li>
|
| 48 |
+
<li><a href="{{ url_for('about') }}">About</a></li>
|
| 49 |
+
</ul>
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
</nav>
|
| 53 |
+
|
| 54 |
+
<!-- 6. Konten Utama -->
|
| 55 |
+
<div class="content-wrapper">
|
| 56 |
+
<!-- Judul Halaman -->
|
| 57 |
+
<div class="page-header">
|
| 58 |
+
<h1>{% block page_title %}{% endblock %}</h1>
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
<!-- 7. Blok Konten Utama -->
|
| 62 |
+
{% block content %}{% endblock %}
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<!-- 8. JavaScript untuk Toggle Navbar & Alert Close -->
|
| 66 |
+
<script>
|
| 67 |
+
// Toggle Navbar Mobile
|
| 68 |
+
const hamburgerBtn = document.getElementById('hamburger-btn');
|
| 69 |
+
const navContainer = document.getElementById('navbar-nav-container');
|
| 70 |
+
|
| 71 |
+
hamburgerBtn.addEventListener('click', () => {
|
| 72 |
+
navContainer.classList.toggle('active');
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
// Tutup Alert
|
| 76 |
+
document.body.addEventListener('click', function(event) {
|
| 77 |
+
if (event.target.classList.contains('alert-close')) {
|
| 78 |
+
event.target.closest('.alert').style.display = 'none';
|
| 79 |
+
}
|
| 80 |
+
});
|
| 81 |
+
</script>
|
| 82 |
+
|
| 83 |
+
<!-- 9. Tambahkan CDN Chart.js -->
|
| 84 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
| 85 |
+
|
| 86 |
+
<!-- 10. Blok Skrip Khusus Halaman -->
|
| 87 |
+
{% block scripts %}{% endblock %}
|
| 88 |
+
|
| 89 |
+
</body>
|
| 90 |
+
</html>
|