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")
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()
# Gunakan timestamp yang lebih inklusif untuk end_date
dt_start = datetime.combine(start_day, datetime.min.time())
dt_end = datetime.combine(
end_day + timedelta(days=1), # Termasuk seluruh hari end_day
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"
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(word, word)
for word in text.split()
)
text = " ".join(
word
for word in text.split()
if word not in stopwords and len(word) > 2
)
if stemmer:
text = stemmer.stem(text)
return text
def predict_batch(texts):
vectors = tfidf.transform(texts)
preds = model.predict(vectors)
confidences = []
if hasattr(model, "predict_proba"):
probs = model.predict_proba(vectors)
confidences = probs.max(axis=1)
else:
confidences = 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
def _section_header(title, subtitle=""):
"""Render consistent section header card"""
sub_html = (
f'
{subtitle}
'
if subtitle else ""
)
st.markdown(f"""
""", unsafe_allow_html=True)
def _render_sentiment_styles():
st.markdown("""
""", unsafe_allow_html=True)
def _section_gap(size="md"):
st.markdown(
f'',
unsafe_allow_html=True
)
def _info_chip(label, value, color="#3b6cf7"):
st.markdown(f"""
""", unsafe_allow_html=True)
def _pill(col, icon, bg, color, dark, label, val, sub):
"""Render metric pill card with consistent styling"""
with col:
fs = "1.25rem" if len(str(val)) <= 8 else "1rem"
st.markdown(f"""
{icon}
{label}
{val}
{sub}
""", unsafe_allow_html=True)
def _render_pie_chart(pos_n, neu_n, neg_n, total, filter_label):
_section_header(
"๐ต Sebaran Sentimen",
f"Periode {filter_label} ยท Diupdate: {format_now()}"
)
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="%{label}
%{value:,} tweet โ %{percent}",
direction="clockwise",
sort=False,
)
])
fig.add_annotation(
text=f"{dom_pct}%
{dom_emoji} {dominant[0]}",
x=0.5,
y=0.5,
showarrow=False,
align="center",
font=dict(size=20, color="#0f172a")
)
fig.update_layout(
height=300,
margin=dict(l=10, r=10, t=10, b=10),
showlegend=True,
legend=dict(
orientation="h",
y=-0.08,
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, filter_label, total):
_section_header(
"๐ Perbandingan Jumlah Tweet per Sentimen",
f"Periode {filter_label} ยท Diupdate: {format_now()}"
)
categories = ["Positif ๐", "Netral ๐", "Negatif ๐"]
values = [pos_n, neu_n, neg_n]
colors = ["#16a34a", "#94a3b8", "#ef4444"]
# Hitung persentase
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),
text=[f"{val:,}"],
textposition="outside",
textfont=dict(size=14, color="#0f172a"),
width=0.5,
hovertemplate=f"{cat}
{val:,} tweet ({pct:.1f}%)",
)
)
fig.update_layout(
height=320,
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),
yaxis=dict(
showgrid=True,
gridcolor="rgba(226,232,240,0.8)",
griddash="dot",
),
)
st.plotly_chart(
fig,
width="stretch",
config={"displayModeBar": False}
)
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 = [
("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:
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"{sent}
%{{x}}: %{{y}} tweet",
)
)
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)"),
yaxis=dict(showgrid=True, gridcolor="rgba(226,232,240,0.6)", griddash="dot"),
hovermode="x unified",
)
st.plotly_chart(
fig,
width="stretch",
config={"displayModeBar": False}
)
def _render_wordcloud(fdf):
_section_header(
"โ๏ธ Kata-Kata Populer per Sentimen",
"Word Cloud 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"""
{title}
""", 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.")
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
def _render_policy_recommendations(dominant, pos_n, neu_n, neg_n, total, filter_label):
dom_name = dominant[0]
dom_pct = dominant[1] / total * 100 if total else 0
_section_header(
"๐ฏ Rekomendasi Tindakan & Insight Kebijakan",
f"Berdasarkan analisis {total:,} tweet ยท {filter_label}"
)
c1, c2, c3, c4 = st.columns(4, gap="medium")
_pill(
c1,
{"Positif": "๐", "Netral": "๐", "Negatif": "๐"}.get(dom_name, "๐"),
{"Positif": "#f0fdf4", "Netral": "#f8fafc", "Negatif": "#fef2f2"}.get(dom_name, "#eef2ff"),
{"Positif": "#16a34a", "Netral": "#64748b", "Negatif": "#ef4444"}.get(dom_name, "#3b6cf7"),
{"Positif": "#14532d", "Netral": "#334155", "Negatif": "#7f1d1d"}.get(dom_name, "#1e3a8a"),
"Sentimen Dominan",
f"{dom_pct:.1f}%",
dom_name
)
_pill(
c2, "๐", "#f0fdf4", "#16a34a", "#14532d",
"Positif", f"{pos_n:,}", "Total tweet positif"
)
_pill(
c3, "๐", "#f8fafc", "#64748b", "#334155",
"Netral", f"{neu_n:,}", "Total tweet netral"
)
_pill(
c4, "๐", "#fef2f2", "#ef4444", "#7f1d1d",
"Negatif", f"{neg_n:,}", "Total tweet negatif"
)
_section_gap("sm")
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
rows = []
if neg_pct >= 40:
rows.append((
"๐ด URGENT",
"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",
"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",
"Tingkatkan Sosialisasi",
f"Sentimen netral {neu_pct:.1f}% menunjukkan banyak publik belum berpihak",
"Perbanyak konten edukatif dan FAQ resmi",
"Tim Komunikasi"
))
if pos_pct >= 40:
rows.append((
"๐ข INFO",
"Pertahankan Momentum Positif",
f"Sentimen positif {pos_pct:.1f}%",
"Perkuat narasi positif dan gunakan kanal resmi secara konsisten",
"Tim Media Sosial"
))
rows.append((
"๐ต RUTIN",
"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",
"Tindakan",
"Dasar Analisis",
"Rekomendasi Konkret",
"Penanggung Jawab"
]
)
priority_color = {
"๐ด URGENT": ("#fef2f2", "#7f1d1d", "#ef4444"),
"๐ TINGGI": ("#fff7ed", "#7c2d12", "#ea580c"),
"๐ก SEDANG": ("#fefce8", "#713f12", "#ca8a04"),
"๐ข INFO": ("#f0fdf4", "#14532d", "#16a34a"),
"๐ต RUTIN": ("#eff6ff", "#1e3a8a", "#3b6cf7"),
}
for _, row in df_rek.iterrows():
bg, tc, bc = priority_color.get(
row["Prioritas"],
("#f8fafc", "#0f172a", "#64748b")
)
st.markdown(f"""
{row["Prioritas"]}
{row["Tindakan"]}
๐ {row["Dasar Analisis"]}
โ
{row["Rekomendasi Konkret"]}
๐ค {row["Penanggung Jawab"]}
""", unsafe_allow_html=True)
return df_rek
def show():
st.markdown("""
""", unsafe_allow_html=True)
_render_sentiment_styles()
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", "๐")
)
st.markdown(f"""
{mode_icon}
{mode_display}
Periode: {filter_label}
""", unsafe_allow_html=True)
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)
# Filter dengan lebih toleran - gunakan < untuk end_date karena sudah incremented
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
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}"
)
# Clear old cache entries untuk memastikan data selalu fresh
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]
_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"""
Mode {escape(str(mode_display))}
Periode {escape(str(filter_label))}
""", 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
_section_header(
"๐ Ringkasan Sentimen",
f"Berdasarkan tanggal asli tweet ยท {filter_label}"
)
_section_gap("sm")
c1, c2, c3, c4 = st.columns(4, gap="medium")
_pill(
c1, "๐", "#eef2ff", "#3b6cf7", "#1e3a8a",
"Total Tweet Dianalisis", f"{total:,}", f"Periode {filter_label}"
)
_pill(
c2, "๐", "#f0fdf4", "#16a34a", "#14532d",
"Positif", f"{pos_n:,}", f"{pos_p:.1f}% dari total"
)
_pill(
c3, "๐", "#f8fafc", "#64748b", "#334155",
"Netral", f"{neu_n:,}", f"{neu_p:.1f}% dari total"
)
_pill(
c4, "๐", "#fef2f2", "#ef4444", "#7f1d1d",
"Negatif", f"{neg_n:,}", f"{neg_p:.1f}% dari total"
)
_section_gap("lg")
col_left, col_right = st.columns(2, gap="medium")
with col_left:
dominant = _render_pie_chart(
pos_n,
neu_n,
neg_n,
total,
filter_label
)
with col_right:
_render_bar_chart(
pos_n,
neu_n,
neg_n,
filter_label,
total
)
_section_gap("lg")
_render_trend_chart(
fdf,
filter_label,
start_date,
end_date
)
_section_gap("lg")
_render_wordcloud(fdf)
_section_gap("lg")
tdf = _render_tweet_table(
fdf,
filter_label
)
_section_gap("lg")
df_rek = _render_policy_recommendations(
dominant,
pos_n,
neu_n,
neg_n,
total,
filter_label
)
_section_gap("lg")
_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"
)