1141 lines
51 KiB
Python
1141 lines
51 KiB
Python
import streamlit as st
|
|
import pandas as pd
|
|
import numpy as np
|
|
import os
|
|
import re
|
|
import string
|
|
from collections import Counter
|
|
from datetime import datetime, timedelta
|
|
from html import escape
|
|
from database import engine, get_tweet_count, get_latest_crawl_time
|
|
from timezone_utils import (
|
|
parse_dt_with_tz,
|
|
parse_dt_with_source_tz,
|
|
get_timezone_label,
|
|
get_timezone_name,
|
|
)
|
|
import plotly.graph_objects as go
|
|
import joblib
|
|
|
|
from page_modules.table_utils import render_standard_table
|
|
|
|
|
|
model = joblib.load("model_naive_bayes.pkl")
|
|
tfidf = joblib.load("tfidf_vectorizer.pkl")
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Timezone & formatting helpers
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
def parse_dt(series):
|
|
return parse_dt_with_tz(series, st.session_state.get("user_timezone", "WIB (UTC+7)"))
|
|
|
|
|
|
def parse_crawled_dt(series):
|
|
return parse_dt_with_source_tz(
|
|
series,
|
|
st.session_state.get("user_timezone", "WIB (UTC+7)"),
|
|
os.getenv("APP_TIMEZONE", "Asia/Makassar"),
|
|
)
|
|
|
|
|
|
def format_dt(value):
|
|
if value is None or pd.isna(value):
|
|
return "Belum ada"
|
|
try:
|
|
tz_label = get_timezone_label(st.session_state.get("user_timezone", "WIB (UTC+7)"))
|
|
return f"{value.strftime('%d/%m/%Y %H:%M')} {tz_label}"
|
|
except Exception:
|
|
return "Belum ada"
|
|
|
|
|
|
def format_now():
|
|
now = parse_dt(pd.Series([datetime.utcnow().isoformat()])).iloc[0]
|
|
return format_dt(now)
|
|
|
|
|
|
def user_today():
|
|
timezone_choice = st.session_state.get("user_timezone", "WIB (UTC+7)")
|
|
return pd.Timestamp.now(tz=get_timezone_name(timezone_choice)).date()
|
|
|
|
|
|
def _sync_dynamic_period():
|
|
mode = st.session_state.get("analysis_mode")
|
|
today = user_today()
|
|
configs = {
|
|
"realtime": (today - timedelta(days=6), today, "Tweet Terkini — 7 Hari Terakhir"),
|
|
"30days": (today - timedelta(days=29), today, "30 Hari Terakhir"),
|
|
"captured": (today, today, "Tweet Hari Ini"),
|
|
}
|
|
if mode not in configs:
|
|
return
|
|
start_day, end_day, mode_display = configs[mode]
|
|
start_day = pd.Timestamp(start_day).date()
|
|
end_day = pd.Timestamp(end_day).date()
|
|
dt_start = datetime.combine(start_day, datetime.min.time())
|
|
dt_end = datetime.combine(end_day + timedelta(days=1), datetime.min.time())
|
|
st.session_state.filter_start_date = dt_start
|
|
st.session_state.filter_end_date = dt_end
|
|
st.session_state.filter_label = f"{dt_start.strftime('%d/%m/%Y')} s/d {end_day.strftime('%d/%m/%Y')}"
|
|
st.session_state.mode_display = mode_display
|
|
st.session_state.filter_date_column = "created_at"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Preprocessing helpers (same as original)
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
NORMALISASI = {
|
|
"gk": "tidak", "ga": "tidak", "gak": "tidak", "nggak": "tidak",
|
|
"ngga": "tidak", "tdk": "tidak", "tak": "tidak", "yg": "yang",
|
|
"dgn": "dengan", "utk": "untuk", "org": "orang", "krn": "karena",
|
|
"dr": "dari", "tp": "tapi", "tpi": "tapi", "sm": "sama",
|
|
"jd": "jadi", "sdh": "sudah", "blm": "belum", "emg": "memang",
|
|
"emang": "memang", "gimana": "bagaimana", "gitu": "begitu",
|
|
"gini": "begini", "bgt": "banget", "ongkir": "ongkos kirim",
|
|
"freeongkir": "gratis ongkir", "free": "gratis", "ecommerce": "e commerce",
|
|
}
|
|
|
|
|
|
def _load_stopwords():
|
|
stopword_file = "indonesian-stopwords-complete.txt"
|
|
base = set()
|
|
try:
|
|
with open(stopword_file, "r", encoding="utf-8") as f:
|
|
base = set(f.read().splitlines())
|
|
for kata in ["tidak", "bukan", "jangan", "kurang", "lebih"]:
|
|
base.discard(kata)
|
|
except FileNotFoundError:
|
|
base = {
|
|
"yang", "dan", "di", "ke", "dari", "ini", "itu",
|
|
"dengan", "untuk", "pada", "adalah", "oleh", "ada",
|
|
"ya", "akan", "atau", "juga",
|
|
}
|
|
base.update({
|
|
"rt", "amp", "https", "http", "co", "t",
|
|
"wkwk", "wkwkwk", "haha", "hehe",
|
|
"yg", "dgn", "utk", "dr", "krn", "tp", "jd", "sdh",
|
|
"aja", "doang", "banget", "bgt", "nih", "sih", "dong", "deh",
|
|
})
|
|
return base
|
|
|
|
|
|
def _get_stemmer():
|
|
try:
|
|
from Sastrawi.Stemmer.StemmerFactory import StemmerFactory
|
|
return StemmerFactory().create_stemmer()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def preprocess(text, stopwords, stemmer):
|
|
text = str(text).lower()
|
|
text = re.sub(r"http\S+|www\S+|https\S+", "", text)
|
|
text = re.sub(r"@\w+", "", text)
|
|
text = re.sub(r"#", "", text)
|
|
text = re.sub(r"\d+", "", text)
|
|
text = text.translate(str.maketrans("", "", string.punctuation))
|
|
text = re.sub(r"[^a-zA-Z\s]", "", text)
|
|
text = re.sub(r"\s+", " ", text).strip()
|
|
text = " ".join(NORMALISASI.get(w, w) for w in text.split())
|
|
text = " ".join(w for w in text.split() if w not in stopwords and len(w) > 2)
|
|
if stemmer:
|
|
text = stemmer.stem(text)
|
|
return text
|
|
|
|
|
|
def predict_batch(texts):
|
|
vectors = tfidf.transform(texts)
|
|
preds = model.predict(vectors)
|
|
confidences = (
|
|
model.predict_proba(vectors).max(axis=1)
|
|
if hasattr(model, "predict_proba")
|
|
else np.ones(len(preds))
|
|
)
|
|
final = []
|
|
for pred, conf in zip(preds, confidences):
|
|
p = str(pred).lower()
|
|
if p == "positif":
|
|
sentiment = "Positif"
|
|
elif p == "negatif":
|
|
sentiment = "Negatif"
|
|
elif p == "netral":
|
|
sentiment = "Netral"
|
|
else:
|
|
sentiment = str(pred).capitalize()
|
|
final.append((sentiment, float(conf)))
|
|
return final
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Shared UI helpers
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
def _section_header(title, subtitle=""):
|
|
sub_html = (
|
|
f'<div style="font-size:0.78rem;color:#64748b;margin-top:4px;line-height:1.5;">{subtitle}</div>'
|
|
if subtitle else ""
|
|
)
|
|
st.markdown(
|
|
f'<div style="background:#ffffff;border:1.5px solid #e2e8f0;border-radius:14px;'
|
|
f'padding:1rem 1.25rem;margin-bottom:1rem;box-shadow:0 2px 6px rgba(15,23,42,0.06);">'
|
|
f'<div style="font-size:0.95rem;font-weight:700;color:#0f172a;letter-spacing:0.02em;">{title}</div>'
|
|
f'{sub_html}</div>',
|
|
unsafe_allow_html=True,
|
|
)
|
|
|
|
|
|
def _section_gap(size="md"):
|
|
heights = {"sm": "1rem", "md": "1.45rem", "lg": "1.9rem"}
|
|
st.markdown(f'<div style="height:{heights.get(size,"1.45rem")};"></div>', unsafe_allow_html=True)
|
|
|
|
|
|
def _render_sentiment_styles():
|
|
st.markdown("""
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
|
|
|
|
section[data-testid="stMain"] * {
|
|
font-family: 'Plus Jakarta Sans', sans-serif !important;
|
|
}
|
|
|
|
/* ── Remove top whitespace ── */
|
|
.block-container { padding-top: 1rem !important; }
|
|
[data-testid="stMainBlockContainer"] { padding-top: 1rem !important; }
|
|
header[data-testid="stHeader"] { height: 0 !important; min-height: 0 !important; }
|
|
|
|
/* ── Panel containers ── */
|
|
.st-key-sentiment_keyword_panel,
|
|
.st-key-sentiment_table_controls,
|
|
.st-key-sentiment_download_panel,
|
|
.st-key-sentiment_chart_panel,
|
|
.st-key-sentiment_trend_panel,
|
|
.st-key-sentiment_wordfreq_panel {
|
|
background: #ffffff !important;
|
|
border: 1.5px solid #e2e8f0 !important;
|
|
border-radius: 12px !important;
|
|
box-shadow: 0 2px 6px rgba(15,23,42,0.06) !important;
|
|
padding: 1.1rem 1.2rem 1rem !important;
|
|
}
|
|
|
|
/* ── Input styling ── */
|
|
.st-key-sentiment_keyword_panel [data-testid="stTextInput"] label p,
|
|
.st-key-sentiment_table_controls [data-testid="stTextInput"] label p,
|
|
.st-key-sentiment_table_controls [data-testid="stSelectbox"] label p {
|
|
color: #334155 !important;
|
|
font-size: 0.72rem !important;
|
|
font-weight: 800 !important;
|
|
letter-spacing: 0.04em !important;
|
|
line-height: 1.2 !important;
|
|
margin-bottom: 0.22rem !important;
|
|
text-transform: uppercase !important;
|
|
}
|
|
.st-key-sentiment_keyword_panel [data-testid="stTextInput"] input,
|
|
.st-key-sentiment_table_controls [data-testid="stTextInput"] input {
|
|
background: #ffffff !important;
|
|
color: #0f172a !important;
|
|
-webkit-text-fill-color: #0f172a !important;
|
|
caret-color: #0f172a !important;
|
|
min-height: 46px !important;
|
|
border-radius: 10px !important;
|
|
}
|
|
.st-key-sentiment_keyword_panel [data-baseweb="input"],
|
|
.st-key-sentiment_table_controls [data-baseweb="input"],
|
|
.st-key-sentiment_table_controls [data-baseweb="select"] > div {
|
|
background: #ffffff !important;
|
|
border-color: #e2e8f0 !important;
|
|
color: #0f172a !important;
|
|
min-height: 46px !important;
|
|
border-radius: 10px !important;
|
|
}
|
|
.st-key-sentiment_keyword_panel [data-baseweb="input"] *,
|
|
.st-key-sentiment_table_controls [data-baseweb="input"] *,
|
|
.st-key-sentiment_table_controls [data-baseweb="select"] * {
|
|
color: #0f172a !important;
|
|
}
|
|
.st-key-sentiment_keyword_panel input::placeholder,
|
|
.st-key-sentiment_table_controls input::placeholder {
|
|
color: #94a3b8 !important;
|
|
-webkit-text-fill-color: #94a3b8 !important;
|
|
}
|
|
.st-key-sentiment_keyword_panel .stButton > button,
|
|
.st-key-sentiment_download_panel [data-testid="stDownloadButton"] button {
|
|
min-height: 46px !important;
|
|
margin-bottom: 0 !important;
|
|
border-radius: 10px !important;
|
|
}
|
|
|
|
/* ── Buttons ── */
|
|
.stButton > button {
|
|
border-radius: 12px !important;
|
|
font-weight: 700 !important;
|
|
font-family: 'Plus Jakarta Sans', sans-serif !important;
|
|
transition: all 0.18s ease !important;
|
|
}
|
|
.stButton > button:hover {
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 6px 18px rgba(59,108,247,0.18) !important;
|
|
}
|
|
|
|
/* ── Stat pill hover ── */
|
|
.sent-pill {
|
|
transition: transform 0.2s cubic-bezier(.34,1.56,.64,1), box-shadow 0.2s ease;
|
|
}
|
|
.sent-pill:hover {
|
|
transform: translateY(-3px);
|
|
box-shadow: 0 10px 24px rgba(15,23,42,0.10) !important;
|
|
}
|
|
|
|
/* ── Page header animation ── */
|
|
@keyframes fadeDown {
|
|
from { opacity: 0; transform: translateY(-10px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
.sent-header { animation: fadeDown 0.45s ease both; }
|
|
|
|
/* ── Staggered stat animations ── */
|
|
@keyframes fadeUp {
|
|
from { opacity: 0; transform: translateY(10px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
.stat-1 { animation: fadeUp 0.35s 0.05s ease both; }
|
|
.stat-2 { animation: fadeUp 0.35s 0.12s ease both; }
|
|
.stat-3 { animation: fadeUp 0.35s 0.19s ease both; }
|
|
.stat-4 { animation: fadeUp 0.35s 0.26s ease both; }
|
|
|
|
/* ── Context pill ── */
|
|
.sentiment-active-context {
|
|
display: flex; flex-wrap: wrap; gap: 0.45rem; margin-top: 0.15rem; align-items: center;
|
|
}
|
|
.sentiment-context-pill {
|
|
align-items: center; background: #f8fafc; border: 1px solid #e2e8f0;
|
|
border-radius: 999px; color: #475569; display: inline-flex;
|
|
font-size: 0.73rem; font-weight: 750; gap: 0.35rem; line-height: 1.2;
|
|
min-height: 28px; padding: 0.28rem 0.65rem; white-space: nowrap;
|
|
}
|
|
.sentiment-context-pill strong { color: #0f172a; font-weight: 850; }
|
|
|
|
/* ── Recommendation cards ── */
|
|
.reco-card {
|
|
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
|
}
|
|
.reco-card:hover {
|
|
transform: translateX(3px);
|
|
box-shadow: 0 6px 20px rgba(15,23,42,0.09) !important;
|
|
}
|
|
|
|
/* ── Word frequency bars ── */
|
|
.st-key-sentiment_wordfreq_panel { padding: 1.1rem 1.2rem 1rem !important; }
|
|
</style>
|
|
""", unsafe_allow_html=True)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Page Header
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
def _render_page_header():
|
|
st.markdown("""
|
|
<div class="sent-header" style="
|
|
background: linear-gradient(135deg,#ffffff 0%,#eef2ff 50%,#ede9fe 100%);
|
|
border: 1px solid #c7d2fe;
|
|
border-radius: 20px;
|
|
padding: 1.5rem 1.75rem;
|
|
margin-bottom: 1.5rem;
|
|
box-shadow: 0 4px 20px rgba(59,108,247,0.08);
|
|
display: flex; align-items: center; gap: 1.1rem;
|
|
">
|
|
<div style="
|
|
width:52px;height:52px;
|
|
background:linear-gradient(135deg,#3b6cf7,#6366f1);
|
|
border-radius:14px;
|
|
display:flex;align-items:center;justify-content:center;
|
|
font-size:1.5rem;
|
|
box-shadow:0 6px 16px rgba(99,102,241,0.35);
|
|
flex-shrink:0;
|
|
">📈</div>
|
|
<div>
|
|
<h2 style="font-size:1.25rem;font-weight:800;color:#0f172a;
|
|
margin:0 0 4px;letter-spacing:-0.01em;line-height:1.2;">
|
|
Analisis Sentimen</h2>
|
|
<p style="font-size:0.8rem;color:#64748b;margin:0;line-height:1.5;">
|
|
Klasifikasi otomatis tweet menggunakan Naive Bayes — Positif · Netral · Negatif</p>
|
|
</div>
|
|
<div style="
|
|
margin-left:auto;
|
|
background:linear-gradient(135deg,#eef2ff,#e0e7ff);
|
|
border:1px solid #a5b4fc;
|
|
border-radius:10px;
|
|
padding:0.45rem 0.9rem;
|
|
font-size:0.72rem;font-weight:700;color:#3b6cf7;
|
|
white-space:nowrap;letter-spacing:0.04em;text-transform:uppercase;
|
|
">🤖 Naive Bayes</div>
|
|
</div>
|
|
""", unsafe_allow_html=True)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Summary Stat Pills (4 cards)
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
def _render_summary_pills(total, pos_n, neu_n, neg_n, pos_p, neu_p, neg_p, filter_label):
|
|
pills = [
|
|
("stat-1", "📊", "linear-gradient(135deg,#eef2ff,#e0e7ff)", "#3b6cf7", "#1e3a8a", "#c7d2fe",
|
|
"Total Dianalisis", f"{total:,}", f"Periode {filter_label}"),
|
|
("stat-2", "😊", "linear-gradient(135deg,#f0fdf4,#dcfce7)", "#16a34a", "#14532d", "#86efac",
|
|
"Positif", f"{pos_n:,}", f"{pos_p:.1f}% dari total"),
|
|
("stat-3", "😐", "linear-gradient(135deg,#f8fafc,#f1f5f9)", "#64748b", "#334155", "#cbd5e1",
|
|
"Netral", f"{neu_n:,}", f"{neu_p:.1f}% dari total"),
|
|
("stat-4", "😞", "linear-gradient(135deg,#fef2f2,#fee2e2)", "#ef4444", "#7f1d1d", "#fca5a5",
|
|
"Negatif", f"{neg_n:,}", f"{neg_p:.1f}% dari total"),
|
|
]
|
|
|
|
cols = st.columns(4, gap="medium")
|
|
for col, (anim, icon, bg, color, dark, border, label, val, sub) in zip(cols, pills):
|
|
fs = "1.6rem" if len(str(val)) <= 6 else "1.2rem"
|
|
with col:
|
|
st.markdown(
|
|
f'<div class="sent-pill {anim}" style="background:{bg};border:1.5px solid {border};'
|
|
f'border-radius:14px;padding:1.25rem 1rem;text-align:center;'
|
|
f'box-shadow:0 2px 8px {color}15;margin-bottom:0.5rem;">'
|
|
f'<div style="width:40px;height:40px;background:{color};border-radius:10px;'
|
|
f'display:flex;align-items:center;justify-content:center;font-size:1.1rem;'
|
|
f'margin:0 auto 0.65rem;box-shadow:0 4px 10px {color}44;">{icon}</div>'
|
|
f'<div style="font-size:0.65rem;font-weight:800;color:{color};text-transform:uppercase;'
|
|
f'letter-spacing:0.07em;margin-bottom:0.3rem;line-height:1.3;">{label}</div>'
|
|
f'<div style="font-size:{fs};font-weight:800;color:{dark};line-height:1.15;'
|
|
f'margin-bottom:0.25rem;">{val}</div>'
|
|
f'<div style="font-size:0.67rem;color:{color};font-weight:600;opacity:0.9;'
|
|
f'line-height:1.35;">{sub}</div>'
|
|
f'</div>',
|
|
unsafe_allow_html=True,
|
|
)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Sentiment proportion bar (horizontal stacked)
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
def _render_proportion_bar(pos_p, neu_p, neg_p):
|
|
pos_p = round(pos_p, 1)
|
|
neu_p = round(neu_p, 1)
|
|
neg_p = round(neg_p, 1)
|
|
|
|
st.markdown(
|
|
f'<div style="background:#f8fafc;border:1.5px solid #e2e8f0;border-radius:12px;'
|
|
f'padding:1rem 1.25rem;margin-bottom:0;">'
|
|
f'<div style="font-size:0.72rem;font-weight:800;color:#64748b;text-transform:uppercase;'
|
|
f'letter-spacing:0.06em;margin-bottom:0.6rem;">Proporsi Sentimen Keseluruhan</div>'
|
|
f'<div style="display:flex;border-radius:8px;overflow:hidden;height:28px;">'
|
|
f'<div style="width:{pos_p}%;background:#16a34a;display:flex;align-items:center;'
|
|
f'justify-content:center;font-size:0.68rem;font-weight:700;color:white;'
|
|
f'white-space:nowrap;padding:0 4px;" title="Positif {pos_p}%">'
|
|
f'{"😊 " + str(pos_p) + "%" if pos_p >= 8 else ""}</div>'
|
|
f'<div style="width:{neu_p}%;background:#94a3b8;display:flex;align-items:center;'
|
|
f'justify-content:center;font-size:0.68rem;font-weight:700;color:white;'
|
|
f'white-space:nowrap;padding:0 4px;" title="Netral {neu_p}%">'
|
|
f'{"😐 " + str(neu_p) + "%" if neu_p >= 8 else ""}</div>'
|
|
f'<div style="width:{neg_p}%;background:#ef4444;display:flex;align-items:center;'
|
|
f'justify-content:center;font-size:0.68rem;font-weight:700;color:white;'
|
|
f'white-space:nowrap;padding:0 4px;" title="Negatif {neg_p}%">'
|
|
f'{"😞 " + str(neg_p) + "%" if neg_p >= 8 else ""}</div>'
|
|
f'</div>'
|
|
f'<div style="display:flex;gap:1.25rem;margin-top:0.6rem;flex-wrap:wrap;">'
|
|
f'<span style="font-size:0.7rem;color:#16a34a;font-weight:700;">● Positif {pos_p}%</span>'
|
|
f'<span style="font-size:0.7rem;color:#94a3b8;font-weight:700;">● Netral {neu_p}%</span>'
|
|
f'<span style="font-size:0.7rem;color:#ef4444;font-weight:700;">● Negatif {neg_p}%</span>'
|
|
f'</div></div>',
|
|
unsafe_allow_html=True,
|
|
)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Donut chart + Bar chart (side by side)
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
def _render_donut_chart(pos_n, neu_n, neg_n, total, filter_label):
|
|
labels, values, colors = [], [], []
|
|
color_map = {"Positif": "#16a34a", "Netral": "#94a3b8", "Negatif": "#ef4444"}
|
|
for label, val in [("Positif", pos_n), ("Netral", neu_n), ("Negatif", neg_n)]:
|
|
if val > 0:
|
|
labels.append(label)
|
|
values.append(val)
|
|
colors.append(color_map[label])
|
|
|
|
dominant = max([("Positif", pos_n), ("Netral", neu_n), ("Negatif", neg_n)], key=lambda x: x[1])
|
|
dom_pct = round(dominant[1] / total * 100) if total > 0 else 0
|
|
dom_emoji = {"Positif": "😊", "Netral": "😐", "Negatif": "😞"}.get(dominant[0], "📊")
|
|
|
|
fig = go.Figure(data=[go.Pie(
|
|
labels=labels, values=values, hole=0.62,
|
|
marker=dict(colors=colors, line=dict(color="white", width=4)),
|
|
textinfo="label+percent", textfont=dict(size=12),
|
|
hovertemplate="<b>%{label}</b><br>%{value:,} tweet — %{percent}<extra></extra>",
|
|
direction="clockwise", sort=False,
|
|
)])
|
|
fig.add_annotation(
|
|
text=f"<b>{dom_pct}%</b><br><span style='font-size:10px;color:#94a3b8;'>{dom_emoji} {dominant[0]}</span>",
|
|
x=0.5, y=0.5, showarrow=False, align="center", font=dict(size=18, color="#0f172a"),
|
|
)
|
|
fig.update_layout(
|
|
height=290, margin=dict(l=10, r=10, t=10, b=10),
|
|
showlegend=True,
|
|
legend=dict(orientation="h", y=-0.1, x=0.5, xanchor="center", font=dict(size=11)),
|
|
paper_bgcolor="rgba(0,0,0,0)",
|
|
)
|
|
st.plotly_chart(fig, width="stretch", config={"displayModeBar": False})
|
|
return dominant
|
|
|
|
|
|
def _render_bar_chart(pos_n, neu_n, neg_n, total):
|
|
categories = ["Positif 😊", "Netral 😐", "Negatif 😞"]
|
|
values = [pos_n, neu_n, neg_n]
|
|
colors = ["#16a34a", "#94a3b8", "#ef4444"]
|
|
percentages = [(v / total * 100) if total > 0 else 0 for v in values]
|
|
|
|
fig = go.Figure()
|
|
for cat, val, color, pct in zip(categories, values, colors, percentages):
|
|
fig.add_trace(go.Bar(
|
|
x=[cat], y=[val],
|
|
marker=dict(color=color, opacity=0.88, cornerradius=8),
|
|
text=[f"{val:,}"], textposition="outside",
|
|
textfont=dict(size=13, color="#0f172a"), width=0.5,
|
|
hovertemplate=f"<b>{cat}</b><br>{val:,} tweet ({pct:.1f}%)<extra></extra>",
|
|
))
|
|
fig.update_layout(
|
|
height=290, margin=dict(l=0, r=0, t=30, b=0),
|
|
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
|
|
showlegend=False,
|
|
xaxis=dict(showgrid=False, tickfont=dict(size=11, color="#64748b")),
|
|
yaxis=dict(showgrid=True, gridcolor="rgba(226,232,240,0.8)", griddash="dot",
|
|
tickfont=dict(size=10, color="#94a3b8")),
|
|
)
|
|
st.plotly_chart(fig, width="stretch", config={"displayModeBar": False})
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Trend line chart
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
def _render_trend_chart(fdf, filter_label, dt_start, dt_end):
|
|
_section_header(
|
|
"📅 Tren Sentimen dari Hari ke Hari",
|
|
f"Periode {filter_label} · Diupdate: {format_now()}"
|
|
)
|
|
|
|
df_tl = fdf.copy()
|
|
df_tl["date"] = pd.to_datetime(df_tl["created_at"], errors="coerce").dt.date
|
|
actual_tl = df_tl.groupby(["date", "sentiment"]).size().reset_index(name="count")
|
|
|
|
date_range = pd.date_range(pd.Timestamp(dt_start).date(), pd.Timestamp(dt_end).date(), freq="D")
|
|
base_dates = pd.DataFrame({"date": date_range.date})
|
|
|
|
fig = go.Figure()
|
|
config_lines = [
|
|
("Positif", "#16a34a", "rgba(22,163,74,0.08)"),
|
|
("Netral", "#94a3b8", "rgba(148,163,184,0.06)"),
|
|
("Negatif", "#ef4444", "rgba(239,68,68,0.08)"),
|
|
]
|
|
for sent, color, fill in config_lines:
|
|
data = base_dates.merge(
|
|
actual_tl[actual_tl["sentiment"] == sent][["date", "count"]],
|
|
on="date", how="left",
|
|
)
|
|
data["count"] = data["count"].fillna(0).astype(int)
|
|
fig.add_trace(go.Scatter(
|
|
x=data["date"], y=data["count"], name=sent,
|
|
mode="lines+markers",
|
|
line=dict(color=color, width=2.5, shape="spline", smoothing=0.8),
|
|
marker=dict(size=6, color="white", line=dict(color=color, width=2.5)),
|
|
fill="tozeroy", fillcolor=fill,
|
|
hovertemplate=f"<b>{sent}</b><br>%{{x}}: <b>%{{y}} tweet</b><extra></extra>",
|
|
))
|
|
fig.update_layout(
|
|
height=290, margin=dict(l=0, r=0, t=10, b=0),
|
|
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
|
|
legend=dict(orientation="h", y=1.1, x=0, font=dict(size=11)),
|
|
xaxis=dict(showgrid=True, gridcolor="rgba(226,232,240,0.6)",
|
|
tickfont=dict(size=10, color="#94a3b8")),
|
|
yaxis=dict(showgrid=True, gridcolor="rgba(226,232,240,0.6)", griddash="dot",
|
|
tickfont=dict(size=10, color="#94a3b8")),
|
|
hovermode="x unified",
|
|
)
|
|
with st.container(border=True, key="sentiment_trend_panel"):
|
|
st.plotly_chart(fig, width="stretch", config={"displayModeBar": False})
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Confidence distribution histogram
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
def _render_confidence_chart(fdf):
|
|
_section_header(
|
|
"🎯 Distribusi Keyakinan Model",
|
|
"Seberapa yakin model dalam mengklasifikasikan setiap tweet"
|
|
)
|
|
|
|
fig = go.Figure()
|
|
sent_cfg = [
|
|
("Positif", "#16a34a", "rgba(22,163,74,0.7)"),
|
|
("Netral", "#94a3b8", "rgba(148,163,184,0.7)"),
|
|
("Negatif", "#ef4444", "rgba(239,68,68,0.7)"),
|
|
]
|
|
for sent, color, fill_color in sent_cfg:
|
|
sub = fdf[fdf["sentiment"] == sent]["confidence"]
|
|
if sub.empty:
|
|
continue
|
|
fig.add_trace(go.Histogram(
|
|
x=sub, name=sent, nbinsx=20,
|
|
marker=dict(color=fill_color, line=dict(color=color, width=1)),
|
|
opacity=0.85,
|
|
hovertemplate=f"<b>{sent}</b><br>Keyakinan: %{{x:.0%}}<br>Jumlah: %{{y}}<extra></extra>",
|
|
))
|
|
fig.update_layout(
|
|
height=260, margin=dict(l=0, r=0, t=10, b=0), barmode="overlay",
|
|
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
|
|
legend=dict(orientation="h", y=1.1, x=0, font=dict(size=11)),
|
|
xaxis=dict(
|
|
tickformat=".0%", title="Tingkat Keyakinan",
|
|
tickfont=dict(size=10, color="#94a3b8"),
|
|
showgrid=True, gridcolor="rgba(226,232,240,0.6)",
|
|
),
|
|
yaxis=dict(
|
|
title="Jumlah Tweet",
|
|
tickfont=dict(size=10, color="#94a3b8"),
|
|
showgrid=True, gridcolor="rgba(226,232,240,0.6)", griddash="dot",
|
|
),
|
|
)
|
|
with st.container(border=True, key="sentiment_conf_panel"):
|
|
st.plotly_chart(fig, width="stretch", config={"displayModeBar": False})
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Word frequency (Top 15 per sentimen, side by side)
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
def _render_word_freq_per_sentiment(fdf):
|
|
_section_header(
|
|
"📊 Kata Dominan per Sentimen",
|
|
"Top 15 kata paling sering muncul di masing-masing kelompok sentimen"
|
|
)
|
|
|
|
stop_extra = {"ongkos", "kirim", "gratis", "komdigi", "ongkir"}
|
|
sent_cfg = [
|
|
("Positif", "#16a34a", "#1e3a8a"),
|
|
("Netral", "#64748b", "#334155"),
|
|
("Negatif", "#ef4444", "#7f1d1d"),
|
|
]
|
|
|
|
cols = st.columns(3, gap="medium")
|
|
for col, (sent, color, dark) in zip(cols, sent_cfg):
|
|
with col:
|
|
sub = fdf[fdf["sentiment"] == sent]
|
|
all_words = " ".join(sub["clean_text"].fillna("")).split()
|
|
filtered = [w for w in all_words if len(w) > 2 and w not in stop_extra]
|
|
wf = Counter(filtered).most_common(15)
|
|
|
|
st.markdown(
|
|
f'<div style="background:#ffffff;border:1.5px solid {color}33;border-radius:14px;'
|
|
f'padding:0.9rem 1rem;box-shadow:0 2px 8px {color}10;margin-bottom:0.5rem;">'
|
|
f'<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.6rem;">'
|
|
f'<div style="width:30px;height:30px;background:{color};border-radius:8px;'
|
|
f'display:flex;align-items:center;justify-content:center;font-size:0.9rem;">'
|
|
f'{"😊" if sent=="Positif" else "😐" if sent=="Netral" else "😞"}</div>'
|
|
f'<div style="font-size:0.85rem;font-weight:800;color:{dark};">{sent}</div>'
|
|
f'<div style="margin-left:auto;font-size:0.68rem;color:{color};font-weight:700;">'
|
|
f'{len(sub):,} tweet</div>'
|
|
f'</div>',
|
|
unsafe_allow_html=True,
|
|
)
|
|
|
|
if not wf:
|
|
st.info("Belum ada data")
|
|
st.markdown('</div>', unsafe_allow_html=True)
|
|
continue
|
|
|
|
words_list = [w[0] for w in wf]
|
|
counts_list = [w[1] for w in wf]
|
|
max_c = max(counts_list) if counts_list else 1
|
|
|
|
fig = go.Figure(data=[go.Bar(
|
|
y=words_list[::-1], x=counts_list[::-1], orientation="h",
|
|
marker=dict(
|
|
color=[f"rgba({int(color[1:3],16)},{int(color[3:5],16)},{int(color[5:7],16)},{0.35+0.65*(c/max_c):.2f})"
|
|
for c in counts_list[::-1]],
|
|
line=dict(width=0), cornerradius=4,
|
|
),
|
|
text=[str(c) for c in counts_list[::-1]],
|
|
textposition="outside",
|
|
textfont=dict(size=9, color="#475569"),
|
|
hovertemplate="<b>%{y}</b><br>%{x} kali<extra></extra>",
|
|
)])
|
|
fig.update_layout(
|
|
height=360, margin=dict(l=0, r=40, t=4, b=4),
|
|
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
|
|
showlegend=False,
|
|
xaxis=dict(showgrid=True, gridcolor="rgba(203,213,225,0.6)",
|
|
tickfont=dict(size=8, color="#94a3b8"), fixedrange=True),
|
|
yaxis=dict(showgrid=False, tickfont=dict(size=9, color="#334155"), fixedrange=True),
|
|
)
|
|
st.plotly_chart(fig, width="stretch", config={"displayModeBar": False})
|
|
st.markdown('</div>', unsafe_allow_html=True)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Word cloud
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
def _render_wordcloud(fdf):
|
|
_section_header(
|
|
"☁️ Word Cloud per Sentimen",
|
|
"Kata-kata populer dari tweet yang sudah melalui preprocessing"
|
|
)
|
|
try:
|
|
from wordcloud import WordCloud
|
|
import matplotlib.pyplot as plt
|
|
|
|
stop_wc = {"ongkos", "kirim", "gratis", "komdigi", "ongkir"}
|
|
wc1, wc2, wc3 = st.columns(3, gap="medium")
|
|
cfg = [
|
|
(wc1, "Positif", "Greens", "😊 Positif", "#f0fdf4", "#14532d"),
|
|
(wc2, "Netral", "Blues", "😐 Netral", "#f8fafc", "#334155"),
|
|
(wc3, "Negatif", "Reds", "😞 Negatif", "#fef2f2", "#7f1d1d"),
|
|
]
|
|
for col, sent, cmap, title, bg, tc in cfg:
|
|
with col:
|
|
st.markdown(
|
|
f'<div style="background:{bg};border-radius:12px;padding:0.625rem 0.875rem;'
|
|
f'margin-bottom:0.5rem;text-align:center;">'
|
|
f'<span style="font-size:0.825rem;font-weight:700;color:{tc};">{title}</span></div>',
|
|
unsafe_allow_html=True,
|
|
)
|
|
sub = fdf[fdf["sentiment"] == sent]
|
|
words = [w for w in " ".join(sub["clean_text"].fillna("")).split()
|
|
if len(w) > 2 and w not in stop_wc]
|
|
if words:
|
|
wc = WordCloud(
|
|
width=420, height=260, background_color="white",
|
|
colormap=cmap, max_words=60,
|
|
relative_scaling=0.5, collocations=False,
|
|
).generate(" ".join(words))
|
|
fig, ax = plt.subplots(figsize=(5, 3.1))
|
|
ax.imshow(wc, interpolation="bilinear")
|
|
ax.axis("off")
|
|
plt.tight_layout(pad=0)
|
|
st.pyplot(fig, clear_figure=True)
|
|
else:
|
|
st.info("Data kata tidak cukup")
|
|
except ImportError:
|
|
st.warning("Install `wordcloud` dan `matplotlib` terlebih dahulu.")
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Tweet table with search / filter / sort
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
def _render_tweet_table(fdf, filter_label):
|
|
_section_header(
|
|
"📋 Tabel Analisis Sentimen",
|
|
f"Total {len(fdf):,} tweet · {filter_label}"
|
|
)
|
|
|
|
with st.container(border=True, key="sentiment_table_controls"):
|
|
tf1, tf2, tf3 = st.columns([3, 1.6, 1.7], gap="medium", vertical_alignment="bottom")
|
|
with tf1:
|
|
search = st.text_input(
|
|
"Search tweet", placeholder="Ketik kata kunci di isi tweet...",
|
|
key="sentiment_table_search",
|
|
)
|
|
with tf2:
|
|
sf = st.selectbox(
|
|
"Filter sentimen",
|
|
["Semua", "Positif 😊", "Netral 😐", "Negatif 😞"],
|
|
key="sentiment_table_filter",
|
|
)
|
|
with tf3:
|
|
sort_by = st.selectbox(
|
|
"Urutkan",
|
|
["Terbaru dulu", "Terlama dulu", "Keyakinan tertinggi"],
|
|
key="sentiment_table_sort",
|
|
)
|
|
|
|
_section_gap("sm")
|
|
|
|
tdf = fdf.copy()
|
|
if "crawled_at" not in tdf.columns:
|
|
tdf["crawled_at"] = pd.NaT
|
|
|
|
if search:
|
|
tdf = tdf[tdf["text"].str.contains(search, case=False, na=False)]
|
|
|
|
sf_map = {"Positif 😊": "Positif", "Netral 😐": "Netral", "Negatif 😞": "Negatif"}
|
|
if sf != "Semua":
|
|
tdf = tdf[tdf["sentiment"] == sf_map.get(sf, sf)]
|
|
|
|
if sort_by == "Terbaru dulu":
|
|
tdf = tdf.sort_values("created_at", ascending=False)
|
|
elif sort_by == "Terlama dulu":
|
|
tdf = tdf.sort_values("created_at", ascending=True)
|
|
else:
|
|
tdf = tdf.sort_values("confidence", ascending=False)
|
|
|
|
out = tdf[["created_at", "crawled_at", "text", "clean_text", "sentiment", "confidence"]].copy()
|
|
out["created_at"] = out["created_at"].apply(format_dt)
|
|
out["crawled_at"] = out["crawled_at"].apply(format_dt)
|
|
out["confidence"] = out["confidence"].apply(lambda x: f"{x:.0%}")
|
|
out["sentiment"] = out["sentiment"].map({
|
|
"Positif": "😊 Positif", "Netral": "😐 Netral", "Negatif": "😞 Negatif",
|
|
}).fillna(out["sentiment"])
|
|
out.columns = ["Tanggal Tweet", "Masuk Database", "Tweet Asli", "Tweet Bersih", "Sentimen", "Keyakinan"]
|
|
|
|
render_standard_table(
|
|
out, height=400, min_width=1220,
|
|
badge_columns=["Sentimen"],
|
|
nowrap=["Tanggal Tweet", "Masuk Database", "Sentimen", "Keyakinan"],
|
|
wide_columns=["Tweet Asli", "Tweet Bersih"],
|
|
column_widths={
|
|
"Tanggal Tweet": "170px", "Masuk Database": "170px",
|
|
"Tweet Asli": "360px", "Tweet Bersih": "360px",
|
|
"Sentimen": "130px", "Keyakinan": "110px",
|
|
},
|
|
)
|
|
st.caption(f"Menampilkan {len(tdf):,} tweet")
|
|
return tdf
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Insight & Recommendation panel
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
def _render_insight_panel(dominant, pos_n, neu_n, neg_n, total, filter_label):
|
|
dom_name = dominant[0]
|
|
dom_pct = dominant[1] / total * 100 if total else 0
|
|
neg_pct = neg_n / total * 100 if total else 0
|
|
pos_pct = pos_n / total * 100 if total else 0
|
|
neu_pct = neu_n / total * 100 if total else 0
|
|
|
|
_section_header(
|
|
"💡 Insight & Rekomendasi Tindakan",
|
|
f"Berdasarkan analisis {total:,} tweet · {filter_label}"
|
|
)
|
|
|
|
# ── Top insight bar ───────────────────────────────────────
|
|
dom_color = {"Positif": "#16a34a", "Netral": "#64748b", "Negatif": "#ef4444"}.get(dom_name, "#3b6cf7")
|
|
dom_bg = {"Positif": "#f0fdf4", "Netral": "#f8fafc", "Negatif": "#fef2f2"}.get(dom_name, "#eef2ff")
|
|
dom_emoji = {"Positif": "😊", "Netral": "😐", "Negatif": "😞"}.get(dom_name, "📊")
|
|
|
|
st.markdown(
|
|
f'<div style="background:{dom_bg};border:1.5px solid {dom_color}33;border-radius:14px;'
|
|
f'padding:1rem 1.25rem;margin-bottom:1rem;border-left:4px solid {dom_color};">'
|
|
f'<div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;">'
|
|
f'<div style="font-size:1.75rem;">{dom_emoji}</div>'
|
|
f'<div>'
|
|
f'<div style="font-size:0.75rem;font-weight:800;color:{dom_color};text-transform:uppercase;'
|
|
f'letter-spacing:0.06em;margin-bottom:0.15rem;">Sentimen Dominan</div>'
|
|
f'<div style="font-size:1.1rem;font-weight:800;color:{dom_color};">'
|
|
f'{dom_name} · {dom_pct:.1f}%</div>'
|
|
f'</div>'
|
|
f'<div style="margin-left:auto;font-size:0.8rem;color:#64748b;">'
|
|
f'Positif: <strong>{pos_pct:.1f}%</strong> · '
|
|
f'Netral: <strong>{neu_pct:.1f}%</strong> · '
|
|
f'Negatif: <strong>{neg_pct:.1f}%</strong>'
|
|
f'</div></div></div>',
|
|
unsafe_allow_html=True,
|
|
)
|
|
|
|
# ── Recommendations ───────────────────────────────────────
|
|
rows = []
|
|
if neg_pct >= 40:
|
|
rows.append(("🔴 URGENT", "#fef2f2", "#7f1d1d", "#ef4444",
|
|
"Tanggapi Keluhan Publik",
|
|
"Sentimen negatif tinggi menunjukkan ketidakpuasan signifikan",
|
|
"Buat klarifikasi resmi dan buka ruang dialog publik",
|
|
"Humas / Tim Kebijakan"))
|
|
if neg_pct >= 20:
|
|
rows.append(("🟠 TINGGI", "#fff7ed", "#7c2d12", "#ea580c",
|
|
"Tinjau Ulang Kebijakan",
|
|
f"Sentimen negatif mencapai {neg_pct:.1f}%",
|
|
"Evaluasi poin kebijakan yang paling banyak dikeluhkan",
|
|
"Tim Kebijakan"))
|
|
if neu_pct >= 30:
|
|
rows.append(("🟡 SEDANG", "#fefce8", "#713f12", "#ca8a04",
|
|
"Tingkatkan Sosialisasi",
|
|
f"Sentimen netral {neu_pct:.1f}% — banyak publik belum berpihak",
|
|
"Perbanyak konten edukatif dan FAQ resmi",
|
|
"Tim Komunikasi"))
|
|
if pos_pct >= 40:
|
|
rows.append(("🟢 INFO", "#f0fdf4", "#14532d", "#16a34a",
|
|
"Pertahankan Momentum Positif",
|
|
f"Sentimen positif {pos_pct:.1f}%",
|
|
"Perkuat narasi positif via kanal resmi secara konsisten",
|
|
"Tim Media Sosial"))
|
|
rows.append(("🔵 RUTIN", "#eff6ff", "#1e3a8a", "#3b6cf7",
|
|
"Pemantauan Berkelanjutan",
|
|
"Opini publik dapat berubah sewaktu-waktu",
|
|
"Pantau sentimen harian dan buat laporan berkala",
|
|
"Tim Analis Data"))
|
|
|
|
df_rek = pd.DataFrame(rows, columns=["Prioritas", "bg", "tc", "bc",
|
|
"Tindakan", "Dasar Analisis",
|
|
"Rekomendasi", "Penanggung Jawab"])
|
|
|
|
for _, row in df_rek.iterrows():
|
|
st.markdown(
|
|
f'<div class="reco-card" style="background:{row.bg};border:1.5px solid {row.bc}44;'
|
|
f'border-radius:14px;padding:1rem 1.25rem;margin-bottom:0.75rem;'
|
|
f'border-left:4px solid {row.bc};">'
|
|
f'<div style="display:flex;align-items:flex-start;gap:1rem;flex-wrap:wrap;">'
|
|
f'<div style="min-width:90px;">'
|
|
f'<span style="font-size:0.72rem;font-weight:800;color:{row.bc};'
|
|
f'text-transform:uppercase;letter-spacing:0.05em;">{row.Prioritas}</span></div>'
|
|
f'<div style="flex:1;min-width:200px;">'
|
|
f'<div style="font-size:0.875rem;font-weight:700;color:{row.tc};margin-bottom:0.25rem;">'
|
|
f'{row.Tindakan}</div>'
|
|
f'<div style="font-size:0.775rem;color:{row.tc};opacity:0.75;margin-bottom:0.375rem;">'
|
|
f'📌 {row["Dasar Analisis"]}</div>'
|
|
f'<div style="font-size:0.8rem;color:{row.tc};line-height:1.6;">'
|
|
f'✅ {row.Rekomendasi}</div>'
|
|
f'</div>'
|
|
f'<div style="min-width:120px;text-align:right;">'
|
|
f'<span style="font-size:0.72rem;background:{row.bc}22;color:{row.bc};'
|
|
f'font-weight:700;padding:0.25rem 0.625rem;border-radius:20px;white-space:nowrap;">'
|
|
f'👤 {row["Penanggung Jawab"]}</span></div>'
|
|
f'</div></div>',
|
|
unsafe_allow_html=True,
|
|
)
|
|
|
|
export_df = df_rek[["Prioritas", "Tindakan", "Dasar Analisis", "Rekomendasi", "Penanggung Jawab"]]
|
|
return export_df
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Main show()
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
def show():
|
|
_render_sentiment_styles()
|
|
_render_page_header()
|
|
|
|
if "analysis_mode" not in st.session_state:
|
|
st.warning("⚠️ Silakan pilih mode tampilan di halaman Ambil Data Twitter terlebih dahulu.")
|
|
return
|
|
|
|
_sync_dynamic_period()
|
|
|
|
start_date = st.session_state.get("filter_start_date")
|
|
end_date = st.session_state.get("filter_end_date")
|
|
filter_label = st.session_state.get("filter_label", "-")
|
|
mode_display = st.session_state.get("mode_display", "-")
|
|
|
|
if start_date is None or end_date is None:
|
|
st.warning("⚠️ Silakan buka halaman Ambil Data Twitter terlebih dahulu.")
|
|
return
|
|
|
|
mode_meta = {
|
|
"realtime": ("#16a34a", "📡"),
|
|
"30days": ("#3b6cf7", "📅"),
|
|
"captured": ("#0284c7", "📆"),
|
|
"custom": ("#d97706", "🔍"),
|
|
}
|
|
mode_color, mode_icon = mode_meta.get(st.session_state.analysis_mode, ("#3b6cf7", "📊"))
|
|
|
|
# ── Active filter banner ──────────────────────────────────
|
|
st.markdown(
|
|
f'<div style="background:#fff;border-left:4px solid {mode_color};'
|
|
f'border-top:1.5px solid #e2e8f0;border-right:1.5px solid #e2e8f0;'
|
|
f'border-bottom:1.5px solid #e2e8f0;border-radius:0 12px 12px 0;'
|
|
f'padding:0.875rem 1.25rem;margin-bottom:1.5rem;'
|
|
f'box-shadow:0 2px 6px rgba(15,23,42,0.07);'
|
|
f'display:flex;align-items:center;gap:0.75rem;">'
|
|
f'<span style="font-size:1.375rem;">{mode_icon}</span>'
|
|
f'<div>'
|
|
f'<div style="font-size:0.875rem;font-weight:700;color:#0f172a;">{mode_display}</div>'
|
|
f'<div style="font-size:0.78rem;color:#475569;margin-top:2px;">'
|
|
f'Periode: <strong style="color:{mode_color};">{filter_label}</strong></div>'
|
|
f'</div></div>',
|
|
unsafe_allow_html=True,
|
|
)
|
|
|
|
# ── Load data ─────────────────────────────────────────────
|
|
try:
|
|
df_all = pd.read_sql("SELECT * FROM tweets ORDER BY created_at DESC", engine)
|
|
if df_all.empty:
|
|
st.warning("⚠️ Belum ada data tweet.")
|
|
return
|
|
df_all["created_at"] = parse_dt(df_all["created_at"])
|
|
if "crawled_at" in df_all.columns:
|
|
df_all["crawled_at"] = parse_crawled_dt(df_all["crawled_at"])
|
|
except Exception as e:
|
|
st.error(f"❌ Gagal membaca database: {e}")
|
|
return
|
|
|
|
s_dt = pd.Timestamp(start_date)
|
|
e_dt = pd.Timestamp(end_date)
|
|
df = df_all[(df_all["created_at"] >= s_dt) & (df_all["created_at"] < e_dt)].copy()
|
|
|
|
if df.empty:
|
|
st.warning(f"⚠️ Tidak ada tweet dengan tanggal asli dalam periode {filter_label}.")
|
|
return
|
|
|
|
# ── Caching ───────────────────────────────────────────────
|
|
total_tweets_in_db = get_tweet_count()
|
|
latest_crawl_marker = get_latest_crawl_time() or "no-crawl"
|
|
data_marker = (total_tweets_in_db, latest_crawl_marker)
|
|
cache_key = (
|
|
f"sent_{st.session_state.analysis_mode}_"
|
|
f"{start_date}_{end_date}_{total_tweets_in_db}_{latest_crawl_marker}"
|
|
)
|
|
|
|
for old_key in list(st.session_state.keys()):
|
|
if old_key.startswith("sent_") and old_key != cache_key:
|
|
del st.session_state[old_key]
|
|
|
|
force_refresh = data_marker != st.session_state.get("_last_sentiment_data_marker")
|
|
|
|
if cache_key not in st.session_state or force_refresh:
|
|
if force_refresh:
|
|
st.info("🔄 Menyegarkan prediksi sentimen dengan data terbaru...")
|
|
with st.spinner("🔍 Preprocessing & prediksi sentimen seluruh tweet..."):
|
|
stopwords = _load_stopwords()
|
|
stemmer = _get_stemmer()
|
|
df["clean_text"] = df["text"].apply(lambda t: preprocess(t, stopwords, stemmer))
|
|
dfc = df[df["clean_text"].str.strip().str.len() > 0].copy()
|
|
results = predict_batch(dfc["clean_text"].tolist())
|
|
if results:
|
|
sentiments, confidences = zip(*results)
|
|
else:
|
|
sentiments, confidences = [], []
|
|
dfc["sentiment"] = list(sentiments)
|
|
dfc["confidence"] = list(confidences)
|
|
st.session_state[cache_key] = dfc
|
|
st.session_state["_last_sentiment_data_marker"] = data_marker
|
|
|
|
df_s = st.session_state[cache_key]
|
|
|
|
# ── Keyword filter panel ──────────────────────────────────
|
|
_section_header("🎯 Filter Kata Kunci", "Kosongkan untuk melihat semua tweet")
|
|
|
|
with st.container(border=True, key="sentiment_keyword_panel"):
|
|
kw = st.text_input(
|
|
"Kata kunci", placeholder="Contoh: ongkir, kurir, komdigi...",
|
|
key="sentiment_keyword_search",
|
|
)
|
|
st.markdown(
|
|
f'<div class="sentiment-active-context">'
|
|
f'<span class="sentiment-context-pill" style="border-color:{mode_color}33;'
|
|
f'background:{mode_color}0f;color:{mode_color};">'
|
|
f'Mode <strong style="color:{mode_color};">{escape(str(mode_display))}</strong></span>'
|
|
f'<span class="sentiment-context-pill">'
|
|
f'Periode <strong>{escape(str(filter_label))}</strong></span>'
|
|
f'</div>',
|
|
unsafe_allow_html=True,
|
|
)
|
|
|
|
_section_gap("sm")
|
|
|
|
fdf = (
|
|
df_s[df_s["text"].str.contains(kw, case=False, na=False)].copy()
|
|
if kw else df_s.copy()
|
|
)
|
|
|
|
if fdf.empty:
|
|
st.warning("⚠️ Tidak ada tweet yang cocok dengan kata kunci tersebut.")
|
|
return
|
|
|
|
sc = fdf["sentiment"].value_counts()
|
|
total = len(fdf)
|
|
pos_n = int(sc.get("Positif", 0))
|
|
neu_n = int(sc.get("Netral", 0))
|
|
neg_n = int(sc.get("Negatif", 0))
|
|
pos_p = pos_n / total * 100
|
|
neu_p = neu_n / total * 100
|
|
neg_p = neg_n / total * 100
|
|
|
|
# ── Summary pills ─────────────────────────────────────────
|
|
_section_header(
|
|
"📌 Ringkasan Sentimen",
|
|
f"Berdasarkan tanggal asli tweet · {filter_label}"
|
|
)
|
|
_section_gap("sm")
|
|
_render_summary_pills(total, pos_n, neu_n, neg_n, pos_p, neu_p, neg_p, filter_label)
|
|
_section_gap("sm")
|
|
_render_proportion_bar(pos_p, neu_p, neg_p)
|
|
_section_gap("lg")
|
|
|
|
# ── Donut + Bar side by side ──────────────────────────────
|
|
col_left, col_right = st.columns(2, gap="medium")
|
|
with col_left:
|
|
_section_header(
|
|
"🔵 Sebaran Sentimen",
|
|
f"Periode {filter_label} · {format_now()}"
|
|
)
|
|
with st.container(border=True, key="sentiment_chart_panel"):
|
|
dominant = _render_donut_chart(pos_n, neu_n, neg_n, total, filter_label)
|
|
|
|
with col_right:
|
|
_section_header(
|
|
"📊 Perbandingan Jumlah per Sentimen",
|
|
f"Periode {filter_label} · {format_now()}"
|
|
)
|
|
with st.container(border=True, key="sentiment_bar_panel"):
|
|
_render_bar_chart(pos_n, neu_n, neg_n, total)
|
|
|
|
_section_gap("lg")
|
|
|
|
# ── Trend chart ───────────────────────────────────────────
|
|
_render_trend_chart(fdf, filter_label, start_date, end_date)
|
|
_section_gap("lg")
|
|
|
|
# ── Confidence distribution ───────────────────────────────
|
|
_render_confidence_chart(fdf)
|
|
_section_gap("lg")
|
|
|
|
# ── Word freq per sentiment ───────────────────────────────
|
|
_render_word_freq_per_sentiment(fdf)
|
|
_section_gap("lg")
|
|
|
|
# ── Word cloud ────────────────────────────────────────────
|
|
_render_wordcloud(fdf)
|
|
_section_gap("lg")
|
|
|
|
# ── Tweet table ───────────────────────────────────────────
|
|
tdf = _render_tweet_table(fdf, filter_label)
|
|
_section_gap("lg")
|
|
|
|
# ── Insight & Recommendations ─────────────────────────────
|
|
df_rek = _render_insight_panel(dominant, pos_n, neu_n, neg_n, total, filter_label)
|
|
_section_gap("lg")
|
|
|
|
# ── Download section ──────────────────────────────────────
|
|
_section_header("📥 Unduh Hasil Analisis")
|
|
|
|
with st.container(border=True, key="sentiment_download_panel"):
|
|
d1, d2, d3 = st.columns(3, gap="medium", vertical_alignment="bottom")
|
|
|
|
with d1:
|
|
st.download_button(
|
|
"📥 Semua Hasil Prediksi",
|
|
fdf.to_csv(index=False).encode("utf-8"),
|
|
f"hasil_prediksi_{datetime.now().strftime('%Y%m%d_%H%M')}.csv",
|
|
"text/csv", width="stretch",
|
|
)
|
|
with d2:
|
|
summary = pd.DataFrame({
|
|
"Sentimen": ["Positif", "Netral", "Negatif"],
|
|
"Jumlah": [pos_n, neu_n, neg_n],
|
|
"Persen": [f"{pos_p:.2f}%", f"{neu_p:.2f}%", f"{neg_p:.2f}%"],
|
|
})
|
|
st.download_button(
|
|
"📈 Ringkasan Sentimen",
|
|
summary.to_csv(index=False).encode("utf-8"),
|
|
f"ringkasan_{datetime.now().strftime('%Y%m%d_%H%M')}.csv",
|
|
"text/csv", width="stretch",
|
|
)
|
|
with d3:
|
|
st.download_button(
|
|
"🎯 Rekomendasi Tindakan",
|
|
df_rek.to_csv(index=False).encode("utf-8"),
|
|
f"rekomendasi_{datetime.now().strftime('%Y%m%d_%H%M')}.csv",
|
|
"text/csv", width="stretch",
|
|
) |