import sys import os from PyQt5.QtWidgets import * from PyQt5.QtCore import * from PyQt5.QtCore import pyqtSignal from PyQt5.QtGui import * from PyQt5.QtWidgets import QDialog, QVBoxLayout, QListWidget, QPushButton, QDialogButtonBox, QLabel, QStyledItemDelegate from models.knn_classifier import JurusanKNNClassifier from models.data_processor import DataProcessor import config import pandas as pd from db.database import ambil_semua_hasil_prediksi, simpan_hasil_prediksi, hapus_hasil_prediksi, update_hasil_prediksi import matplotlib.pyplot as plt import seaborn as sns import numpy as np from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.figure import Figure from sklearn.metrics import roc_curve, auc from sklearn.preprocessing import label_binarize from itertools import cycle from gui.custom_notifications import CustomNotificationDialog, StudentNamePickerDialog class LineEditDelegate(QStyledItemDelegate): def createEditor(self, parent, option, index): editor = QLineEdit(parent) editor.setPlaceholderText("0-100") editor.setAlignment(Qt.AlignCenter) editor.setValidator(QIntValidator(0, 100, editor)) editor.setInputMethodHints(Qt.ImhDigitsOnly) editor.setStyleSheet(""" QLineEdit { border: none; border-bottom: 1px solid #e2e8f0; padding: 6px; font-size: 16px; background-color: white; color: #1e293b; font-family: 'Segoe UI', Arial, sans-serif; font-weight: 500; } QLineEdit:focus { border: none; border-bottom: 1px solid #667eea; padding: 6px; } """) return editor def setEditorData(self, editor, index): value = index.model().data(index, Qt.EditRole) if value: editor.setText(str(value)) else: editor.setText("") def setModelData(self, editor, model, index): model.setData(index, editor.text(), Qt.EditRole) def updateEditorGeometry(self, editor, option, index): editor.setGeometry(option.rect) class MainWindow(QMainWindow): logout_signal = pyqtSignal() def handle_logout(self): # untuk memastikan hanya tab logout yang aktif saat konfirmasi, set checked dulu baru tampilkan dialog self.btn_logout.setChecked(True) # bersihkan semua tab lain agar tidak terlihat aktif saat dialog muncul for tab in [self.tab_input, self.tab_data, self.tab_performa]: tab.setChecked(False) reply = CustomNotificationDialog.show_question(self, 'Konfirmasi Logout', 'Apakah Anda yakin ingin logout?', 'Ya, Logout', 'Batal') if reply: self.logout_confirmed() else: # jika batal logout, kembalikan ke tab yang sebelumnya aktif (default ke Input jika tidak ada yang aktif) self.btn_logout.setChecked(False) # Set kembali ke tab yang sebelumnya aktif, default ke Input jika tidak ada yang aktif current_index = self.stacked_widget.currentIndex() if current_index == 0: self.tab_input.setChecked(True) elif current_index == 1: self.tab_data.setChecked(True) elif current_index == 2: self.tab_performa.setChecked(True) def logout_confirmed(self): # tutup halaman utama dan kembalikan ke login self.close() self.logout_signal.emit() def parse_float_value(self, text): """ Helper function to parse float value from text input. Supports both comma (,) and dot (.) as decimal separator. Indonesian users can use 94,5 or 94.5 - both will work. """ if not text or text.strip() == "": return 0.0 # Replace comma with dot for float conversion text = text.strip().replace(',', '.') try: return float(text) except ValueError: return 0.0 def _is_valid_score_text(self, text): """Return True when score text is empty or an integer between 0 and 100.""" normalized = (text or "").strip() if not normalized: return True return normalized.isdigit() and 0 <= int(normalized) <= 100 def _get_nilai_cell_text(self, row, column): """Ambil teks dari editor aktif jika ada, fallback ke item tabel.""" if not hasattr(self, 'nilai_table'): return '' index = self.nilai_table.model().index(row, column) widget = self.nilai_table.indexWidget(index) if widget and hasattr(widget, 'text'): return widget.text().strip() item = self.nilai_table.item(row, column) return item.text().strip() if item else '' def _set_nilai_cell_text(self, row, column, text): """Set teks ke item dan editor aktif agar tampilan tetap sinkron.""" if not hasattr(self, 'nilai_table'): return item = self.nilai_table.item(row, column) if item: item.setText(text) index = self.nilai_table.model().index(row, column) widget = self.nilai_table.indexWidget(index) if widget and hasattr(widget, 'setText'): widget.blockSignals(True) widget.setText(text) widget.blockSignals(False) def _calculate_nilai_rata_row(self, row): """Hitung dan perbarui rata-rata untuk satu baris mapel.""" if not hasattr(self, 'nilai_table'): return if row < 0 or row >= self.nilai_table.rowCount(): return sem1_text = self._get_nilai_cell_text(row, 1) sem2_text = self._get_nilai_cell_text(row, 2) rata_item = self.nilai_table.item(row, 3) if not rata_item: return sem1_value = self.parse_float_value(sem1_text) sem2_value = self.parse_float_value(sem2_text) if sem1_text or sem2_text: rata_rata = (sem1_value + sem2_value) / 2 # Format rata-rata: hapus trailing zeros # 100 -> "100", 90.50 -> "90.5", 88.55 -> "88.55" formatted_rata = f"{rata_rata:.2f}".rstrip('0').rstrip('.') rata_item.setText(formatted_rata) rata_item.setForeground(QColor('#2c3e50')) else: rata_item.setText('otomatis') rata_item.setForeground(QColor('#94a3b8')) def _update_nilai_table_height(self): """Resize the input table so the last row ends at the bottom.""" if not hasattr(self, 'nilai_table'): return header_height = self.nilai_table.horizontalHeader().height() row_height = self.nilai_table.verticalHeader().defaultSectionSize() visible_rows = max(1, self.nilai_table.rowCount()) frame_height = self.nilai_table.frameWidth() * 2 self.nilai_table.setFixedHeight(header_height + (visible_rows * row_height) + frame_height + 2) def _on_nilai_editor_changed(self, row, column, text): """Update item tabel dan rata-rata saat user mengetik di editor.""" if not hasattr(self, 'nilai_table') or self.loading_data: return if column not in (1, 2): return self._nilai_syncing = True try: item = self.nilai_table.item(row, column) if item: item.setText(text) finally: self._nilai_syncing = False self._calculate_nilai_rata_row(row) def on_nilai_changed(self, item): """Calculate rata-rata when semester 1 or 2 values change""" if not hasattr(self, 'nilai_table') or getattr(self, '_nilai_syncing', False) or self.loading_data: return # Only process if item is in column 1 (Semester 1) or 2 (Semester 2) if item.column() not in [1, 2]: return self._calculate_nilai_rata_row(item.row()) def open_student_name_picker(self): """Open the name picker dialog and fill the form when a name is chosen.""" current_name = self.nama_input.text().strip() if hasattr(self, 'nama_input') else '' selected_name = StudentNamePickerDialog.show_picker(self, initial_name=current_name) if selected_name: self.nama_input.setText(selected_name) self.statusBar().showMessage(f'Nama siswa dipilih: {selected_name}') return selected_name #main window membuat claasifier def __init__(self, username="Admin"): super().__init__() icon_path = config.get_app_icon_path() if icon_path: self.setWindowIcon(QIcon(icon_path)) self.username = username # Store username self.classifier = JurusanKNNClassifier() self.data_processor = DataProcessor() self.current_selected_id = None self.loading_data = False # untuk mencegah reaksi berantai saat mengisi data ke tabel self.edit_mode = False # untuk membedakan antara prediksi baru dan edit data yang sudah ada self.original_edit_data = None # Snapshot data awal saat masuk mode edit # Pagination variables self.current_page = 1 self.items_per_page = 10 self.total_items = 0 self.all_data = [] # Store all data for pagination self.initUI() #membangun tampilan self.load_table_data() #menampilkan data dari database ke tabel # Initialize button states - start in create mode if hasattr(self, 'btn_predict'): self.btn_predict.setEnabled(True) self.btn_predict.setToolTip("Memprediksi dan menyimpan sebagai data baru") if hasattr(self, 'btn_edit'): self.btn_edit.setEnabled(False) self.btn_edit.setToolTip("Pilih data terlebih dahulu untuk mengedit") if hasattr(self, 'btn_preview'): self.btn_preview.setEnabled(False) self.btn_preview.setToolTip("Pilih data terlebih dahulu untuk melihat preview prediksi") if hasattr(self, 'btn_update'): self.btn_update.setEnabled(False) self.btn_update.setToolTip("Pilih data terlebih dahulu untuk mengupdate") def _get_current_form_snapshot(self): """Ambil snapshot isi form saat ini untuk validasi perubahan.""" nilai_dict = {} subject_keys = [ 'nilai_informatika', 'nilai_fisika', 'nilai_kimia', 'nilai_biologi', 'nilai_big_lanjut', 'nilai_ekonomi', 'nilai_mat_lanjut', 'nilai_sej_lanjut', 'nilai_sosiologi', 'nilai_geografi' ] if hasattr(self, 'nilai_table'): for row in range(min(len(subject_keys), self.nilai_table.rowCount())): key = subject_keys[row] rata_item = self.nilai_table.item(row, 3) if rata_item: nilai_dict[key] = self.parse_float_value(rata_item.text()) return { 'nama': self.nama_input.text().strip(), 'nilai_dict': nilai_dict } def _has_form_changes(self): """Cek apakah data form berbeda dari snapshot awal mode edit.""" if not self.original_edit_data: return True current_data = self._get_current_form_snapshot() if current_data['nama'] != self.original_edit_data['nama']: return True for key, current_value in current_data['nilai_dict'].items(): original_value = self.original_edit_data['nilai_dict'].get(key, 0.0) if not np.isclose(current_value, original_value, atol=1e-9): return True return False def initUI(self): self.setWindowTitle('Sistem Klasifikasi Jurusan') self.setMinimumSize(1024, 700) # Keep default look on normal monitors while fitting smaller screens safely. screen = QApplication.primaryScreen() if screen: available = screen.availableGeometry() width = min(1200, max(1024, int(available.width() * 0.95))) height = min(800, max(720, int(available.height() * 0.95))) self.resize(width, height) self.move( available.x() + (available.width() - width) // 2, available.y() + (available.height() - height) // 2 ) else: self.setGeometry(100, 100, 1200, 800) # Central widget central_widget = QWidget() self.setCentralWidget(central_widget) # Main horizontal layout (sidebar + content) main_layout = QHBoxLayout() main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) # Create sidebar sidebar = QWidget() sidebar.setFixedWidth(250) sidebar.setStyleSheet(""" QWidget { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #667eea, stop:1 #764ba2); } """) sidebar_layout = QVBoxLayout() sidebar_layout.setContentsMargins(0, 0, 0, 0) sidebar_layout.setSpacing(0) # Header Section with logo and title in sidebar header_widget = QWidget() header_widget.setStyleSheet("background: transparent; padding: 25px 20px;") header_layout = QVBoxLayout() header_layout.setAlignment(Qt.AlignCenter) header_layout.setSpacing(0) header_layout.setContentsMargins(0, 0, 0, 0) # Logo logo_label = QLabel() logo_label.setFixedSize(100, 100) logo_label.setStyleSheet("background: transparent; border: none;") logo_label.setAlignment(Qt.AlignCenter) logo_path = config.get_resource_path(os.path.join('img', 'logo_sekolah.png')) logo_pixmap = QPixmap(logo_path) def crop_transparent(pixmap): if pixmap.isNull(): return pixmap img = pixmap.toImage().convertToFormat(QImage.Format_ARGB32) w, h = img.width(), img.height() left, top = w, h right, bottom = 0, 0 for y in range(h): for x in range(w): alpha = (img.pixel(x, y) >> 24) & 0xFF if alpha != 0: if x < left: left = x if x > right: right = x if y < top: top = y if y > bottom: bottom = y if right < left or bottom < top: return pixmap cropped = img.copy(left, top, right - left + 1, bottom - top + 1) return QPixmap.fromImage(cropped) if not logo_pixmap.isNull(): # Crop away transparent padding so logo doesn't appear 'boxed' logo_pixmap = crop_transparent(logo_pixmap) logo_label.setPixmap(logo_pixmap.scaled(80, 80, Qt.KeepAspectRatio, Qt.SmoothTransformation)) # Title section title_main = QLabel("JURUSIFY") title_main.setStyleSheet( "color: white; font-size: 22px; font-weight: bold; margin: 0px; padding: 0px;" " background: transparent; border: none; font-family: 'Segoe UI', Arial, sans-serif; letter-spacing: 0.5px;" ) title_main.setAlignment(Qt.AlignCenter) # Layout adjustments for header - vertical stacking with no extra frames header_layout = QVBoxLayout() header_layout.setContentsMargins(0, 0, 0, 0) header_layout.setSpacing(0) logo_label.setContentsMargins(0, 0, 0, 0) title_main.setContentsMargins(0, 0, 0, 0) header_layout.addWidget(logo_label, alignment=Qt.AlignHCenter) header_layout.addWidget(title_main, alignment=Qt.AlignHCenter) header_widget = QWidget() # Increase top padding so the logo is positioned slightly lower above the title header_widget.setStyleSheet("background: transparent; border: none; padding: 28px 0 6px 0;") header_widget.setLayout(header_layout) # Add header layout to sidebar layout and insert larger spacing below title sidebar_layout.addWidget(header_widget) sidebar_layout.addSpacing(30) # Navigation menu nav_widget = QWidget() nav_widget.setStyleSheet("background: transparent;") nav_layout = QVBoxLayout() nav_layout.setContentsMargins(10, 15, 10, 10) nav_layout.setSpacing(5) # Tab buttons self.tab_input = QPushButton("▸ Input Data") self.tab_data = QPushButton("▸ Data Siswa") self.tab_performa = QPushButton("▸ Performa") self.btn_logout = QPushButton("▸ Logout") # Style for sidebar navigation sidebar_nav_style = """ QPushButton { border: none; padding: 16px 22px; font-size: 18px; color: rgba(255, 255, 255, 0.9); background-color: transparent; text-align: left; font-weight: 500; margin: 0; border-radius: 8px; font-family: 'Segoe UI', Arial, sans-serif; } QPushButton:hover { background-color: rgba(255, 255, 255, 0.15); color: white; } QPushButton:checked { background-color: rgba(255, 255, 255, 0.25); color: white; font-weight: 600; border-left: 3px solid #ffffff; border-radius: 8px 0 0 8px; } """ logout_style = """ QPushButton { border: none; padding: 16px 22px; font-size: 18px; color: rgba(255, 255, 255, 0.9); background-color: transparent; text-align: left; font-weight: 500; margin: 8px 0 0 0; border-radius: 8px; font-family: 'Segoe UI', Arial, sans-serif; } QPushButton:hover { background-color: rgba(239, 68, 68, 0.9); color: white; } """ # Apply styles and make checkable for tab in [self.tab_input, self.tab_data, self.tab_performa]: tab.setCheckable(True) tab.setFocusPolicy(Qt.NoFocus) tab.setStyleSheet(sidebar_nav_style) nav_layout.addWidget(tab) # Add separator before logout nav_layout.addStretch() separator = QFrame() separator.setFrameShape(QFrame.HLine) separator.setStyleSheet("background-color: rgba(255, 255, 255, 0.2); max-height: 1px; margin: 10px 15px;") nav_layout.addWidget(separator) self.btn_logout.setStyleSheet(logout_style) self.btn_logout.setFocusPolicy(Qt.NoFocus) nav_layout.addWidget(self.btn_logout) nav_layout.addSpacing(10) nav_widget.setLayout(nav_layout) sidebar_layout.addWidget(nav_widget) sidebar.setLayout(sidebar_layout) main_layout.addWidget(sidebar) # Content area content_widget = QWidget() content_widget.setStyleSheet("background-color: #f8f9fa;") content_layout = QVBoxLayout() content_layout.setContentsMargins(0, 0, 0, 0) # Header for content area content_header = QWidget() content_header.setFixedHeight(65) content_header.setStyleSheet("background-color: white; border-bottom: 2px solid #e0e6ed;") content_header_layout = QHBoxLayout() content_header_layout.setContentsMargins(30, 0, 30, 0) # Spacer to push username and icon to the right content_header_layout.addStretch() # Username label self.username_label = QLabel(f"{self.username}") self.username_label.setStyleSheet(""" color: #64748b; font-size: 16px; font-weight: 600; margin-right: 8px; font-family: 'Segoe UI', Arial, sans-serif; """) content_header_layout.addWidget(self.username_label) # User icon with modern styling user_icon_container = QWidget() user_icon_container.setFixedSize(36, 36) user_icon_container.setStyleSheet(""" QWidget { background-color: #667eea; border-radius: 18px; border: none; outline: none; } """) user_icon_layout = QVBoxLayout(user_icon_container) user_icon_layout.setContentsMargins(0, 0, 0, 0) user_icon_layout.setAlignment(Qt.AlignCenter) user_icon = QLabel("👤") user_icon.setStyleSheet("color: white; font-size: 18px; background: transparent; border: none; font-family: 'Segoe UI', Arial, sans-serif;") user_icon.setAlignment(Qt.AlignCenter) user_icon_layout.addWidget(user_icon) content_header_layout.addWidget(user_icon_container) content_header.setLayout(content_header_layout) content_layout.addWidget(content_header) # Stacked Widget for different tabs self.stacked_widget = QStackedWidget() self.stacked_widget.setStyleSheet("background-color: #f8f9fa;") # Create tab pages self.create_input_tab() self.create_data_tab() self.create_performa_tab() content_layout.addWidget(self.stacked_widget) content_widget.setLayout(content_layout) main_layout.addWidget(content_widget) central_widget.setLayout(main_layout) # Connect signals self.tab_input.clicked.connect(lambda: self.switch_tab(0)) self.tab_data.clicked.connect(lambda: self.switch_tab(1)) self.tab_performa.clicked.connect(lambda: self.switch_tab(2)) self.btn_logout.clicked.connect(self.handle_logout) # Set default tab self.switch_tab(0) # Load initial stats and performance print("Loading model and performance metrics...") self.generate_performance_display() # Train first to get fresh accuracy self.refresh_performa_stats() # Then update stats with fresh accuracy print("✓ Application ready!\n") # Load model from writable path first, then bundled model, then retrain. self.initialize_model() #load model dulu, train kalau belum ada def initialize_model(self): """Inisialisasi classifier.""" model_candidates = [config.MODEL_PATH] bundled_model = getattr(config, 'BUNDLED_MODEL_PATH', None) if bundled_model and bundled_model not in model_candidates: model_candidates.append(bundled_model) for model_path in model_candidates: try: if os.path.exists(model_path): self.classifier.load_model(model_path) self.statusBar().showMessage('Model berhasil dimuat') return except Exception as e: print(f"Gagal memuat model dari {model_path}: {e}") # untuk training awal, coba muat dataset dari path yang bisa ditulis (jika ada), jika tidak fallback ke dataset sumber self.train_model_background() def switch_tab(self, index): """Switch between tabs""" # If switching away from Input tab (index 0), reset edit mode if hasattr(self, 'stacked_widget') and self.stacked_widget.currentIndex() == 0 and index != 0: self.edit_mode = False # Update tab appearance tabs = [self.tab_input, self.tab_data, self.tab_performa] for i, tab in enumerate(tabs): tab.setChecked(i == index) # Switch stacked widget page self.stacked_widget.setCurrentIndex(index) # Update status based on tab if index == 0: # When switching to Input tab, clear form if not in edit mode if not self.edit_mode: self.clear_form() self.statusBar().showMessage('Input Data - Masukkan data siswa untuk prediksi') elif index == 1: self.statusBar().showMessage('Data Siswa - Kelola data hasil prediksi') # Always reset search state when returning to Data Siswa. if hasattr(self, 'search_input'): self.search_input.blockSignals(True) self.search_input.clear() self.search_input.blockSignals(False) self.current_page = 1 if hasattr(self, 'load_table_data'): self.load_table_data() # Clear table selection when switching to Data Siswa tab if no data should be selected if hasattr(self, 'table') and not self.current_selected_id: self.table.clearSelection() # Ensure buttons are disabled if hasattr(self, 'btn_edit'): self.btn_edit.setEnabled(False) if hasattr(self, 'btn_preview'): self.btn_preview.setEnabled(False) # Avoid auto-focus on search field so it doesn't look pre-clicked. if hasattr(self, 'search_input'): self.search_input.clearFocus() if hasattr(self, 'table'): self.table.clearFocus() self.stacked_widget.setFocus() elif index == 2: self.statusBar().showMessage('Performa - Analisis performa sistem') # Only refresh stats (lightweight), don't retrain model every time self.refresh_performa_stats() # Performance display already generated on init, no need to regenerate def create_input_tab(self): """Create Input Data tab with modern simplified design""" input_page = QWidget() input_page.setStyleSheet(""" QWidget { background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #f5f7fa, stop:1 #e8ecf1); } """) page_layout = QVBoxLayout(input_page) page_layout.setContentsMargins(0, 0, 0, 0) scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) scroll_area.setFrameShape(QFrame.NoFrame) scroll_area.setStyleSheet("QScrollArea { border: none; background: transparent; }") input_widget = QWidget() input_widget.setStyleSheet("background: transparent;") # Main layout input_layout = QVBoxLayout(input_widget) input_layout.setContentsMargins(40, 30, 40, 30) input_layout.setSpacing(20) # Header with gradient header_widget = QWidget() header_widget.setStyleSheet(""" QWidget { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #667eea, stop:1 #764ba2); border-radius: 12px; } """) header_layout = QVBoxLayout(header_widget) header_layout.setContentsMargins(30, 20, 30, 20) header_layout.setSpacing(5) title_label = QLabel("📝 Input Data Siswa") title_label.setStyleSheet(""" font-size: 30px; font-weight: bold; color: white; background: transparent; font-family: 'Segoe UI', Arial, sans-serif; """) header_layout.addWidget(title_label) subtitle_label = QLabel("Masukkan data siswa untuk mendapatkan prediksi jurusan yang sesuai") subtitle_label.setStyleSheet(""" font-size: 18px; color: rgba(255, 255, 255, 0.9); background: transparent; font-family: 'Segoe UI', Arial, sans-serif; """) header_layout.addWidget(subtitle_label) input_layout.addWidget(header_widget) # Main Form Card - Simple white card form_card = QWidget() form_card.setStyleSheet(""" QWidget { background-color: white; border-radius: 12px; border: 1px solid #e0e6ed; } """) shadow = QGraphicsDropShadowEffect() shadow.setBlurRadius(15) shadow.setXOffset(0) shadow.setYOffset(5) shadow.setColor(QColor(0, 0, 0, 30)) form_card.setGraphicsEffect(shadow) form_layout = QVBoxLayout(form_card) form_layout.setContentsMargins(30, 30, 30, 30) form_layout.setSpacing(25) # Nama Siswa Section - plain label nama_label = QLabel("Nama Lengkap Siswa") nama_label.setStyleSheet(""" font-size: 19px; color: #2c3e50; font-weight: bold; background: transparent; border: none; font-family: 'Segoe UI', Arial, sans-serif; """) form_layout.addWidget(nama_label) self.nama_input = QLineEdit() self.nama_input.setMinimumHeight(56) self.nama_input.setMaximumHeight(56) self.nama_input.setMaxLength(50) self.nama_input.setReadOnly(True) self.nama_input.setCursor(Qt.PointingHandCursor) self.nama_input.setToolTip("Klik untuk memilih nama siswa") self.nama_input.setStyleSheet(""" QLineEdit { border: 2px solid #e0e6ed; border-radius: 8px; padding: 8px 14px; font-size: 19px; font-weight: 400; background-color: #f8fafc; color: #1e293b; font-family: 'Segoe UI', Arial, sans-serif; } QLineEdit:focus { border-color: #667eea; background-color: white; } QLineEdit:hover { border-color: #cbd5e1; } """) self.nama_input.installEventFilter(self) self.nama_input.setPlaceholderText("Klik untuk pilih nama siswa") self.nama_input.textChanged.connect(self.validate_nama_input) form_layout.addWidget(self.nama_input) # Separator line separator = QFrame() separator.setFrameShape(QFrame.HLine) separator.setStyleSheet("background-color: #e0e6ed; max-height: 1px;") form_layout.addWidget(separator) # Nilai Section - plain label nilai_label = QLabel("Nilai Mata Pelajaran (Range: 0-100)") nilai_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) nilai_label.setIndent(0) nilai_label.setContentsMargins(0, 0, 0, 0) nilai_label.setStyleSheet(""" font-size: 19px; font-weight: bold; color: #2c3e50; background: transparent; border: none; font-family: 'Segoe UI', Arial, sans-serif; margin-bottom: 5px; """) form_layout.addWidget(nilai_label) # Create table widget for nilai input self.nilai_table = QTableWidget() self.nilai_table.setColumnCount(4) self.nilai_table.setHorizontalHeaderLabels(['Mapel', 'Semester 1', 'Semester 2', 'Rata-rata']) self.nilai_table.setMinimumHeight(350) self.nilai_table.setStyleSheet(""" QTableWidget { border: 1px solid #e2e8f0; border-radius: 6px; background-color: #ffffff; gridline-color: #e2e8f0; } QTableWidget::item { padding: 4px; background-color: white; } QHeaderView::section { background-color: #f1f5f9; color: #2c3e50; padding: 14px 8px; border: none; border-right: 1px solid #e2e8f0; border-bottom: 1px solid #e2e8f0; font-weight: bold; font-size: 18px; } """) # Set header alignment and properties header = self.nilai_table.horizontalHeader() header.setStretchLastSection(False) header.setSectionResizeMode(0, QHeaderView.Stretch) # Mapel header.setSectionResizeMode(1, QHeaderView.Stretch) # Semester 1 header.setSectionResizeMode(2, QHeaderView.Stretch) # Semester 2 header.setSectionResizeMode(3, QHeaderView.Stretch) # Rata-rata self.nilai_table.verticalHeader().setVisible(False) self.nilai_table.setSelectionBehavior(QAbstractItemView.SelectRows) self.nilai_table.setSelectionMode(QAbstractItemView.NoSelection) self.nilai_table.setFocusPolicy(Qt.NoFocus) # Set custom delegate for input columns (Semester 1 and 2) # Must set delegate before creating persistent editors so the editors # created by openPersistentEditor inherit the delegate's alignment/style. delegate = LineEditDelegate() self.nilai_table.setItemDelegateForColumn(1, delegate) self.nilai_table.setItemDelegateForColumn(2, delegate) # Mata pelajaran list mapel_list = [ ('Informatika', 'nilai_informatika'), ('Fisika', 'nilai_fisika'), ('Kimia', 'nilai_kimia'), ('Biologi', 'nilai_biologi'), ('BIG Lanjut', 'nilai_big_lanjut'), ('Ekonomi', 'nilai_ekonomi'), ('MAT Lanjut', 'nilai_mat_lanjut'), ('SEJ Lanjut', 'nilai_sej_lanjut'), ('Sosiologi', 'nilai_sosiologi'), ('Geografi', 'nilai_geografi') ] self.nilai_inputs = {} self.nilai_sem1_inputs = {} # Store semester 1 inputs self.nilai_sem2_inputs = {} # Store semester 2 inputs self.nilai_table.setRowCount(len(mapel_list)) for row, (mapel, key) in enumerate(mapel_list): # Column 0: Mapel name (read-only) mapel_item = QTableWidgetItem(mapel) mapel_item.setFlags(mapel_item.flags() & ~Qt.ItemIsEditable) mapel_item.setFont(QFont('Segoe UI', 10)) mapel_item.setForeground(QColor('#2c3e50')) self.nilai_table.setItem(row, 0, mapel_item) # Column 1: Semester 1 input sem1_item = QTableWidgetItem('') sem1_item.setFlags(sem1_item.flags() | Qt.ItemIsEditable) sem1_item.setTextAlignment(Qt.AlignCenter) sem1_item.setFont(QFont('Segoe UI', 14)) self.nilai_table.setItem(row, 1, sem1_item) self.nilai_sem1_inputs[key] = sem1_item self.nilai_table.openPersistentEditor(sem1_item) # Column 2: Semester 2 input sem2_item = QTableWidgetItem('') sem2_item.setFlags(sem2_item.flags() | Qt.ItemIsEditable) sem2_item.setTextAlignment(Qt.AlignCenter) sem2_item.setFont(QFont('Segoe UI', 14)) self.nilai_table.setItem(row, 2, sem2_item) self.nilai_sem2_inputs[key] = sem2_item self.nilai_table.openPersistentEditor(sem2_item) # Column 3: Rata-rata (read-only, calculated) rata_item = QTableWidgetItem('otomatis') rata_item.setFlags(rata_item.flags() & ~Qt.ItemIsEditable) rata_item.setTextAlignment(Qt.AlignCenter) rata_item.setFont(QFont('Segoe UI', 10)) rata_item.setForeground(QColor('#94a3b8')) self.nilai_table.setItem(row, 3, rata_item) # Store in nilai_inputs for backward compatibility with predict function self.nilai_inputs[key] = rata_item # Connect persistent editors so rata-rata updates as soon as user types sem1_index = self.nilai_table.model().index(row, 1) sem2_index = self.nilai_table.model().index(row, 2) sem1_editor = self.nilai_table.indexWidget(sem1_index) sem2_editor = self.nilai_table.indexWidget(sem2_index) if sem1_editor and hasattr(sem1_editor, 'textChanged'): sem1_editor.textChanged.connect( lambda text, r=row: self._on_nilai_editor_changed(r, 1, text) ) if sem2_editor and hasattr(sem2_editor, 'textChanged'): sem2_editor.textChanged.connect( lambda text, r=row: self._on_nilai_editor_changed(r, 2, text) ) # Set row height for row in range(len(mapel_list)): self.nilai_table.setRowHeight(row, 45) self._update_nilai_table_height() # Connect item changed signal to calculate rata-rata self.nilai_table.itemChanged.connect(self.on_nilai_changed) form_layout.addWidget(self.nilai_table) input_layout.addWidget(form_card) # Action Buttons - Simple layout without card btn_layout = QHBoxLayout() btn_layout.setSpacing(15) btn_layout.setContentsMargins(0, 10, 0, 0) # Predict button self.btn_predict = QPushButton("🔮 Prediksi") self.btn_predict.setFixedHeight(50) self.btn_predict.setCursor(Qt.PointingHandCursor) self.btn_predict.setStyleSheet(""" QPushButton { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #667eea, stop:1 #764ba2); color: white; border: none; border-radius: 8px; font-size: 17px; font-weight: bold; padding: 12px 32px; font-family: 'Segoe UI', Arial, sans-serif; } QPushButton:hover { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #5568d3, stop:1 #6a3b91); } QPushButton:pressed { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #4a5abc, stop:1 #5a3080); } QPushButton:disabled { background: #cbd5e1; color: #94a3b8; } """) self.btn_predict.clicked.connect(self.predict_and_save_data) self.btn_predict.setToolTip("Memprediksi dan menyimpan sebagai data baru") btn_layout.addWidget(self.btn_predict) # Update button self.btn_update = QPushButton("✏️ Update") self.btn_update.setFixedHeight(50) self.btn_update.setCursor(Qt.PointingHandCursor) self.btn_update.setStyleSheet(""" QPushButton { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #11998e, stop:1 #38ef7d); color: white; border: none; border-radius: 8px; font-size: 17px; font-weight: bold; padding: 12px 32px; font-family: 'Segoe UI', Arial, sans-serif; } QPushButton:hover { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #0d7a72, stop:1 #2dd465); } QPushButton:disabled { background: #cbd5e1; color: #94a3b8; } """) self.btn_update.clicked.connect(self.update_data) self.btn_update.setToolTip("Mengupdate data yang dipilih dengan prediksi baru") btn_layout.addWidget(self.btn_update) # Clear Form button self.btn_clear = QPushButton("🗑️ Clear Form") self.btn_clear.setFixedHeight(50) self.btn_clear.setCursor(Qt.PointingHandCursor) self.btn_clear.setStyleSheet(""" QPushButton { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #ee0979, stop:1 #ff6a00); color: white; border: none; border-radius: 8px; font-size: 17px; font-weight: bold; padding: 12px 32px; font-family: 'Segoe UI', Arial, sans-serif; } QPushButton:hover { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #d4076a, stop:1 #e65900); } """) self.btn_clear.clicked.connect(lambda: self.clear_form(show_empty_notification=True)) btn_layout.addWidget(self.btn_clear) btn_layout.addStretch() input_layout.addLayout(btn_layout) input_layout.addStretch() scroll_area.setWidget(input_widget) page_layout.addWidget(scroll_area) self.stacked_widget.addWidget(input_page) #ui untuk tab data siswa def create_data_tab(self): data_widget = QWidget() data_widget.setStyleSheet(""" QWidget { background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #f5f7fa, stop:1 #e8ecf1); } """) # Main layout data_layout = QVBoxLayout(data_widget) data_layout.setContentsMargins(32, 20, 32, 20) data_layout.setSpacing(14) # Header with gradient header_widget = QWidget() header_widget.setStyleSheet(""" QWidget { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #667eea, stop:1 #764ba2); border-radius: 12px; } """) header_layout = QVBoxLayout(header_widget) header_layout.setContentsMargins(30, 20, 30, 20) header_layout.setSpacing(5) title_label = QLabel("📊 Data Siswa") title_label.setStyleSheet(""" font-size: 30px; font-weight: bold; color: white; background: transparent; font-family: 'Segoe UI', Arial, sans-serif; """) header_layout.addWidget(title_label) subtitle_label = QLabel("Kelola dan lihat semua data hasil prediksi siswa") subtitle_label.setStyleSheet(""" font-size: 18px; color: rgba(255, 255, 255, 0.9); background: transparent; font-family: 'Segoe UI', Arial, sans-serif; """) header_layout.addWidget(subtitle_label) data_layout.addWidget(header_widget) # Main Content Card content_card = QWidget() content_card.setObjectName("dataContentCard") content_card.setStyleSheet(""" QWidget#dataContentCard { background-color: white; border-radius: 12px; border: 1px solid #e0e6ed; } """) shadow = QGraphicsDropShadowEffect() shadow.setBlurRadius(15) shadow.setXOffset(0) shadow.setYOffset(5) shadow.setColor(QColor(0, 0, 0, 30)) content_card.setGraphicsEffect(shadow) content_layout = QVBoxLayout(content_card) content_layout.setContentsMargins(24, 18, 24, 18) content_layout.setSpacing(14) # Search Section search_layout = QHBoxLayout() search_layout.setSpacing(10) search_label = QLabel("Pencarian:") search_label.setStyleSheet(""" font-size: 16px; color: #2c3e50; font-weight: bold; background: transparent; font-family: 'Segoe UI', Arial, sans-serif; """) search_layout.addWidget(search_label) self.search_input = QLineEdit() self.search_input.setPlaceholderText("Cari nama siswa...") self.search_input.setMinimumHeight(56) self.search_input.setMaximumHeight(56) self.search_input.setMinimumWidth(300) self.search_input.setStyleSheet(""" QLineEdit { border: 2px solid #e0e6ed; border-radius: 8px; padding: 8px 14px; font-size: 19px; background-color: #f8fafc; color: #1e293b; font-family: 'Segoe UI', Arial, sans-serif; } QLineEdit:focus { border-color: #667eea; background-color: white; } QLineEdit:hover { border-color: #cbd5e1; } """) # Validator untuk pencarian - hanya huruf dan spasi search_validator = QRegExpValidator(QRegExp("[a-zA-Z ]*")) self.search_input.setValidator(search_validator) self.search_input.setFocusPolicy(Qt.ClickFocus) # Connect search input signals self.search_input.textChanged.connect(self.search_by_name) self.search_input.returnPressed.connect(self.search_by_name) search_layout.addWidget(self.search_input) # Clear search button btn_clear_search = QPushButton("✖ Clear") btn_clear_search.setFixedHeight(56) btn_clear_search.setCursor(Qt.PointingHandCursor) btn_clear_search.setStyleSheet(""" QPushButton { background-color: #dc3545; color: white; border: none; border-radius: 8px; font-weight: bold; font-size: 16px; padding: 10px 20px; font-family: 'Segoe UI', Arial, sans-serif; } QPushButton:hover { background-color: #c82333; } """) btn_clear_search.clicked.connect(self.clear_search) btn_clear_search.setToolTip("Hapus pencarian") search_layout.addWidget(btn_clear_search) search_layout.addStretch() content_layout.addLayout(search_layout) # Action buttons action_layout = QHBoxLayout() action_layout.setSpacing(12) # Delete button btn_delete = QPushButton("Delete") btn_delete.setFixedHeight(45) btn_delete.setCursor(Qt.PointingHandCursor) btn_delete.setStyleSheet(""" QPushButton { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #ee0979, stop:1 #ff6a00); color: white; border: none; border-radius: 8px; font-weight: bold; font-size: 16px; padding: 10px 24px; font-family: 'Segoe UI', Arial, sans-serif; } QPushButton:hover { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #d4076a, stop:1 #e65900); } """) btn_delete.clicked.connect(self.delete_data) action_layout.addWidget(btn_delete) # Edit button self.btn_edit = QPushButton("Edit") self.btn_edit.setFixedHeight(45) self.btn_edit.setCursor(Qt.PointingHandCursor) self.btn_edit.setStyleSheet(""" QPushButton { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #667eea, stop:1 #764ba2); color: white; border: none; border-radius: 8px; font-weight: bold; font-size: 16px; padding: 10px 24px; font-family: 'Segoe UI', Arial, sans-serif; } QPushButton:hover { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #5568d3, stop:1 #6a3b91); } QPushButton:disabled { background: #cbd5e1; color: #94a3b8; } """) self.btn_edit.clicked.connect(self.edit_selected_data) self.btn_edit.setEnabled(False) self.btn_edit.setToolTip("Pilih data terlebih dahulu untuk mengedit") action_layout.addWidget(self.btn_edit) # Preview button self.btn_preview = QPushButton("Preview") self.btn_preview.setFixedHeight(45) self.btn_preview.setCursor(Qt.PointingHandCursor) self.btn_preview.setStyleSheet(""" QPushButton { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #a770ef, stop:1 #cf8bf3); color: white; border: none; border-radius: 8px; font-weight: bold; font-size: 16px; padding: 10px 24px; font-family: 'Segoe UI', Arial, sans-serif; } QPushButton:hover { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #9560d8, stop:1 #bd7ae0); } QPushButton:disabled { background: #cbd5e1; color: #94a3b8; } """) self.btn_preview.clicked.connect(self.preview_prediction) self.btn_preview.setEnabled(False) self.btn_preview.setToolTip("Pilih data terlebih dahulu untuk melihat preview prediksi") action_layout.addWidget(self.btn_preview) # Export button btn_export = QPushButton("Export") btn_export.setFixedHeight(45) btn_export.setCursor(Qt.PointingHandCursor) btn_export.setStyleSheet(""" QPushButton { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #11998e, stop:1 #38ef7d); color: white; border: none; border-radius: 8px; font-weight: bold; font-size: 16px; padding: 10px 24px; font-family: 'Segoe UI', Arial, sans-serif; } QPushButton:hover { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #0d7a72, stop:1 #2dd465); } """) btn_export.clicked.connect(self.export_to_excel) action_layout.addWidget(btn_export) action_layout.addStretch() content_layout.addLayout(action_layout) # Hidden info label (masih dibutuhkan untuk fungsi internal tapi tidak ditampilkan) self.info_label = QLabel() self.info_label.setVisible(False) # Table self.table = QTableWidget() self.setup_table() self.table.installEventFilter(self) self.table.viewport().installEventFilter(self) content_layout.addWidget(self.table, 1) # Pagination pagination_layout = QHBoxLayout() pagination_layout.setContentsMargins(0, 4, 0, 0) pagination_layout.setSpacing(14) pagination_layout.setAlignment(Qt.AlignCenter) pagination_layout.addStretch() self.btn_prev = QPushButton("◀ Previous") self.btn_prev.setFixedHeight(40) self.btn_prev.setCursor(Qt.PointingHandCursor) self.btn_prev.setStyleSheet(""" QPushButton { background-color: #64748b; color: white; padding: 10px 20px; font-weight: bold; font-weight: 600; font-size: 16px; border-radius: 8px; border: none; font-family: 'Segoe UI', Arial, sans-serif; } QPushButton:hover { background-color: #475569; } QPushButton:disabled { background-color: #cbd5e1; color: #94a3b8; } """) self.btn_prev.clicked.connect(self.previous_page) pagination_layout.addWidget(self.btn_prev) self.page_label = QLabel() self.page_label.setAlignment(Qt.AlignCenter) self.page_label.setMinimumWidth(180) self.page_label.setStyleSheet(""" margin: 0px 20px; font-weight: 600; font-size: 16px; color: #2c3e50; background: transparent; font-family: 'Segoe UI', Arial, sans-serif; """) pagination_layout.addWidget(self.page_label) self.btn_next = QPushButton("Next ▶") self.btn_next.setFixedHeight(40) self.btn_next.setCursor(Qt.PointingHandCursor) self.btn_next.setStyleSheet(""" QPushButton { background-color: #64748b; color: white; padding: 10px 20px; font-weight: bold; font-weight: 600; font-size: 18px; border-radius: 8px; border: none; font-family: 'Segoe UI', Arial, sans-serif; } QPushButton:hover { background-color: #475569; } QPushButton:disabled { background-color: #cbd5e1; color: #94a3b8; } """) self.btn_next.clicked.connect(self.next_page) pagination_layout.addWidget(self.btn_next) pagination_layout.addStretch() pagination_widget = QWidget() pagination_widget.setStyleSheet("background: transparent;") pagination_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) pagination_widget.setFixedHeight(52) pagination_widget.setLayout(pagination_layout) content_layout.addWidget(pagination_widget) # Let the content card consume available vertical space so table rows remain visible. data_layout.addWidget(content_card, 1) # Install event filter to detect clicks outside table data_widget.installEventFilter(self) self.stacked_widget.addWidget(data_widget) #ui untuk tab performa sistem def create_performa_tab(self): performa_widget = QWidget() performa_widget.setStyleSheet(""" QWidget { background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #f5f7fa, stop:1 #e8ecf1); } """) # Main layout dengan scroll area main_performa_layout = QVBoxLayout(performa_widget) main_performa_layout.setContentsMargins(0, 0, 0, 0) # Create scroll area scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) scroll_area.setFrameShape(QFrame.NoFrame) scroll_area.setStyleSheet("QScrollArea { border: none; background: transparent; }") # Content widget for scroll area content_widget = QWidget() content_widget.setStyleSheet("background: transparent;") performa_layout = QVBoxLayout(content_widget) performa_layout.setContentsMargins(40, 30, 40, 30) performa_layout.setSpacing(25) # Header with gradient header_widget = QWidget() header_widget.setStyleSheet(""" QWidget { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #667eea, stop:1 #764ba2); border-radius: 12px; } """) header_layout = QVBoxLayout(header_widget) header_layout.setContentsMargins(30, 20, 30, 20) header_layout.setSpacing(5) title_label = QLabel("📈 Performa Sistem") title_label.setStyleSheet(""" font-size: 30px; font-weight: bold; color: white; background: transparent; font-family: 'Segoe UI', Arial, sans-serif; """) header_layout.addWidget(title_label) subtitle_label = QLabel("Analisis dan evaluasi performa klasifikasi K-Nearest Neighbors") subtitle_label.setStyleSheet(""" font-size: 18px; color: rgba(255, 255, 255, 0.9); background: transparent; font-family: 'Segoe UI', Arial, sans-serif; """) header_layout.addWidget(subtitle_label) performa_layout.addWidget(header_widget) # Statistics Cards Container self.stats_container = QWidget() self.stats_container.setStyleSheet("background-color: transparent;") self.stats_layout = QHBoxLayout(self.stats_container) self.stats_layout.setSpacing(15) self.stats_layout.setContentsMargins(0, 0, 0, 0) performa_layout.addWidget(self.stats_container) # Section 1: Confusion Matrix confusion_card = QWidget() confusion_card.setStyleSheet(""" QWidget { background-color: white; border-radius: 12px; border: 1px solid #e0e6ed; } """) shadow1 = QGraphicsDropShadowEffect() shadow1.setBlurRadius(15) shadow1.setXOffset(0) shadow1.setYOffset(5) shadow1.setColor(QColor(0, 0, 0, 30)) confusion_card.setGraphicsEffect(shadow1) confusion_card_layout = QVBoxLayout(confusion_card) confusion_card_layout.setContentsMargins(25, 20, 25, 20) confusion_card_layout.setSpacing(15) confusion_title = QLabel("Confusion Matrix") confusion_title.setStyleSheet(""" font-size: 17px; font-weight: bold; color: #2c3e50; background: transparent; border: none; font-family: 'Segoe UI', Arial, sans-serif; margin-bottom: 5px; """) confusion_card_layout.addWidget(confusion_title) # Horizontal layout for matrix and explanation confusion_content = QHBoxLayout() confusion_content.setSpacing(20) # Matrix visualization self.perf_figure = Figure(figsize=(6, 4), dpi=100) self.perf_figure.patch.set_facecolor('white') self.perf_canvas = FigureCanvas(self.perf_figure) self.perf_canvas.setFixedHeight(320) confusion_content.addWidget(self.perf_canvas, 3) # Explanation self.perf_confusion_explanation = QLabel() self.perf_confusion_explanation.setFont(QFont("Segoe UI", 11)) self.perf_confusion_explanation.setWordWrap(True) self.perf_confusion_explanation.setAlignment(Qt.AlignTop | Qt.AlignLeft) self.perf_confusion_explanation.setStyleSheet(""" QLabel { background: transparent; color: #2c3e50; font-family: 'Segoe UI', Arial, sans-serif; line-height: 1.6; padding: 10px; } """) confusion_content.addWidget(self.perf_confusion_explanation, 2) confusion_card_layout.addLayout(confusion_content) performa_layout.addWidget(confusion_card) # Section 2: ROC Curves roc_card = QWidget() roc_card.setStyleSheet(""" QWidget { background-color: white; border-radius: 12px; border: 1px solid #e0e6ed; } """) shadow2 = QGraphicsDropShadowEffect() shadow2.setBlurRadius(15) shadow2.setXOffset(0) shadow2.setYOffset(5) shadow2.setColor(QColor(0, 0, 0, 30)) roc_card.setGraphicsEffect(shadow2) roc_card_layout = QVBoxLayout(roc_card) roc_card_layout.setContentsMargins(25, 20, 25, 20) roc_card_layout.setSpacing(15) roc_title = QLabel("ROC AUC Curve") roc_title.setStyleSheet(""" font-size: 17px; font-weight: bold; color: #2c3e50; background: transparent; border: none; font-family: 'Segoe UI', Arial, sans-serif; margin-bottom: 5px; """) roc_card_layout.addWidget(roc_title) # Horizontal layout for ROC and explanation roc_content = QHBoxLayout() roc_content.setSpacing(20) # ROC visualization self.roc_figure = Figure(figsize=(6, 4), dpi=100) self.roc_figure.patch.set_facecolor('white') self.roc_canvas = FigureCanvas(self.roc_figure) self.roc_canvas.setFixedHeight(320) roc_content.addWidget(self.roc_canvas, 3) # Explanation self.perf_roc_explanation = QLabel() self.perf_roc_explanation.setFont(QFont("Segoe UI", 11)) self.perf_roc_explanation.setWordWrap(True) self.perf_roc_explanation.setAlignment(Qt.AlignTop | Qt.AlignLeft) self.perf_roc_explanation.setStyleSheet(""" QLabel { background: transparent; color: #2c3e50; font-family: 'Segoe UI', Arial, sans-serif; line-height: 1.6; padding: 10px; } """) roc_content.addWidget(self.perf_roc_explanation, 2) roc_card_layout.addLayout(roc_content) performa_layout.addWidget(roc_card) # Section 3: Classification Report class_card = QWidget() class_card.setStyleSheet(""" QWidget { background-color: white; border-radius: 12px; border: 1px solid #e0e6ed; } """) shadow3 = QGraphicsDropShadowEffect() shadow3.setBlurRadius(15) shadow3.setXOffset(0) shadow3.setYOffset(5) shadow3.setColor(QColor(0, 0, 0, 30)) class_card.setGraphicsEffect(shadow3) class_card_layout = QVBoxLayout(class_card) class_card_layout.setContentsMargins(25, 20, 25, 20) class_card_layout.setSpacing(15) class_title = QLabel("Classification Report") class_title.setStyleSheet(""" font-size: 17px; font-weight: bold; color: #2c3e50; background: transparent; border: none; font-family: 'Segoe UI', Arial, sans-serif; margin-bottom: 5px; """) class_card_layout.addWidget(class_title) # Horizontal layout for report and explanation class_content = QHBoxLayout() class_content.setSpacing(20) # Classification report self.perf_report_text = QTextEdit() self.perf_report_text.setReadOnly(True) self.perf_report_text.setFont(QFont("Consolas", 11)) self.perf_report_text.setFixedHeight(300) self.perf_report_text.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.perf_report_text.setWordWrapMode(QTextOption.NoWrap) self.perf_report_text.setFrameShape(QFrame.NoFrame) self.perf_report_text.setStyleSheet(""" QTextEdit { background: transparent; border: none; padding: 0px; font-family: 'Consolas', 'Monaco', monospace; line-height: 1.4; color: #2c3e50; } """) class_content.addWidget(self.perf_report_text, 3) # Explanation self.perf_class_explanation = QLabel() self.perf_class_explanation.setFont(QFont("Segoe UI", 11)) self.perf_class_explanation.setWordWrap(True) self.perf_class_explanation.setAlignment(Qt.AlignTop | Qt.AlignLeft) self.perf_class_explanation.setStyleSheet(""" QLabel { background: transparent; color: #2c3e50; font-family: 'Segoe UI', Arial, sans-serif; line-height: 1.6; padding: 10px; } """) class_content.addWidget(self.perf_class_explanation, 2) class_card_layout.addLayout(class_content) performa_layout.addWidget(class_card) performa_layout.addStretch() # Set content widget to scroll area and add to main layout scroll_area.setWidget(content_widget) main_performa_layout.addWidget(scroll_area) self.stacked_widget.addWidget(performa_widget) #fungsi untuk update total siswa dan jumlah siswa per paket pada halaman performa def refresh_performa_stats(self): try: # Clear existing cards for i in reversed(range(self.stats_layout.count())): child = self.stats_layout.itemAt(i).widget() if child: child.deleteLater() # Get data from database data = ambil_semua_hasil_prediksi() total_siswa = len(data) # Count students by paket paket_counts = {} for item in data: paket = item.get('paket_prediksi', 0) paket_counts[paket] = paket_counts.get(paket, 0) + 1 # Define modern colors for each paket paket_colors = { 1: "#667eea", # Purple-blue (primary theme) 2: "#764ba2", # Purple 3: "#f093fb", # Pink 4: "#4facfe", # Light blue 5: "#43e97b", # Green 6: "#fa709a" # Rose } # Create Total Siswa card total_card = self.create_stat_card("Total Siswa", str(total_siswa), "#2c3e50") self.stats_layout.addWidget(total_card) # Create cards for each paket (1-6) for paket in range(1, 7): count = paket_counts.get(paket, 0) color = paket_colors.get(paket, "#95a5a6") paket_card = self.create_stat_card(f"Paket {paket}", str(count), color) self.stats_layout.addWidget(paket_card) except Exception as e: print(f"Error refreshing performa stats: {str(e)}") # Create default cards if error total_card = self.create_stat_card("Total Siswa", "0", "#34495e") self.stats_layout.addWidget(total_card) for paket in range(1, 7): paket_card = self.create_stat_card(f"Paket {paket}", "0", "#95a5a6") self.stats_layout.addWidget(paket_card) #Fungsi halaman performa untuk latih ulang model dan tampilkan metrik performa terbaru def generate_performance_display(self): try: # Always retrain model to get current accurate performance # print("🔄 Retraining model for current performance metrics...") # Create fresh classifier instance fresh_classifier = JurusanKNNClassifier() # Train with current dataset to get fresh results accuracy, _ = fresh_classifier.train( config.DATASET_PATH, test_data_path=getattr(config, 'TEST_DATASET_PATH', None) ) # Update main classifier with fresh results self.classifier = fresh_classifier # print(f"✅ Fresh model accuracy: {accuracy:.4f} ({accuracy:.2%})") # Use fresh evaluation results if (hasattr(self.classifier, 'confusion_matrix') and hasattr(self.classifier, 'y_test') and hasattr(self.classifier, 'y_pred')): cm = self.classifier.confusion_matrix y_test = self.classifier.y_test y_pred = self.classifier.y_pred # Generate confusion matrix plot self.plot_confusion_matrix_perf(cm, sorted(set(y_test))) self.display_confusion_matrix_explanation_perf() # Generate ROC AUC curve plot if hasattr(self.classifier, 'y_pred_proba'): self.plot_roc_curve_perf(y_test, self.classifier.y_pred_proba) else: # Fallback: use predicted classes (less accurate for ROC) self.plot_roc_curve_perf(y_test, y_pred) self.display_roc_explanation_perf() # Generate classification report with fresh data from sklearn.metrics import classification_report report = classification_report(y_test, y_pred, output_dict=True, zero_division=0) self.display_classification_report_perf(report) # Update status bar with current accuracy self.statusBar().showMessage(f'Performance updated - Current Accuracy: {accuracy:.2%}') # print(f"✅ Performance display updated successfully with accuracy: {accuracy:.2%}") else: self.show_error_perf("Fresh training data not available") except Exception as e: # print(f"Error in generate_performance_display: {str(e)}") self.show_error_perf(f"Error generating performance report: {str(e)}") # Fungsi untuk plot confusion matrix pada halaman performa def plot_confusion_matrix_perf(self, cm, classes): # Clear previous plot self.perf_figure.clear() ax = self.perf_figure.add_subplot(111) im = ax.imshow(cm, interpolation='nearest', cmap=plt.cm.Blues, aspect='auto') # Add colorbar cbar = self.perf_figure.colorbar(im, ax=ax, shrink=0.8) cbar.ax.tick_params(labelsize=10) # Set labels ax.set_xticks(np.arange(cm.shape[1])) ax.set_yticks(np.arange(cm.shape[0])) ax.set_xticklabels(classes, fontsize=11) ax.set_yticklabels(classes, fontsize=11) # Set axis labels ax.set_ylabel('True Labels', fontsize=12, fontweight='bold') ax.set_xlabel('Predicted Labels', fontsize=12, fontweight='bold') # Rotate the tick labels plt.setp(ax.get_xticklabels(), rotation=45, ha="right", rotation_mode="anchor") # Add text annotations thresh = cm.max() / 2. for i in range(cm.shape[0]): for j in range(cm.shape[1]): text_color = "white" if cm[i, j] > thresh else "black" ax.text(j, i, format(cm[i, j], 'd'), ha="center", va="center", color=text_color, fontweight='bold', fontsize=11) # Set title with accuracy information - REMOVED to avoid text cutoff # accuracy = self.classifier.training_accuracy if hasattr(self.classifier, 'training_accuracy') else 0 # ax.set_title(f"Confusion Matrix\nAccuracy: {accuracy:.2%}", fontweight='bold', fontsize=14, pad=20) # Adjust layout with better margins self.perf_figure.subplots_adjust(left=0.12, bottom=0.25, right=0.9, top=0.95) self.perf_canvas.draw() # Fungsi untuk plot ROC curve pada halaman performa def display_classification_report_perf(self, report_dict): # Get accuracy for header accuracy = report_dict['accuracy'] # Create header with prominent accuracy display accuracy_header = f"🎯 SYSTEM ACCURACY: {accuracy:.4f} ({accuracy:.2%})\n" accuracy_header += f"{'='*65}\n\n" # Create formatted text report header = f"{'':>12} {'precision':>10} {'recall':>10} {'f1-score':>10} {'support':>8}\n" separator = "=" * 65 + "\n" report_text = accuracy_header + header + separator # Add metrics for each class classes = [key for key in report_dict.keys() if key not in ['accuracy', 'macro avg', 'weighted avg']] # Sort classes numerically try: sorted_classes = sorted(classes, key=lambda x: int(x)) except (ValueError, TypeError): sorted_classes = sorted(classes) for class_name in sorted_classes: metrics = report_dict[class_name] class_label = f"Paket {class_name}" if str(class_name).isdigit() else str(class_name) report_text += f"{class_label:>12} {metrics['precision']:>10.2f} {metrics['recall']:>10.2f} {metrics['f1-score']:>10.2f} {int(metrics['support']):>8}\n" # Add separator line report_text += "\n" + "-" * 65 + "\n" # Add accuracy total_support = sum(report_dict[key]['support'] for key in classes) report_text += f"{'accuracy':>12} {'':>10} {'':>10} {accuracy:>10.2f} {int(total_support):>8}\n" # Add macro avg macro_avg = report_dict['macro avg'] report_text += f"{'macro avg':>12} {macro_avg['precision']:>10.2f} {macro_avg['recall']:>10.2f} {macro_avg['f1-score']:>10.2f} {int(macro_avg['support']):>8}\n" # Add weighted avg weighted_avg = report_dict['weighted avg'] report_text += f"{'weighted avg':>12} {weighted_avg['precision']:>10.2f} {weighted_avg['recall']:>10.2f} {weighted_avg['f1-score']:>10.2f} {int(weighted_avg['support']):>8}\n" # Add footer note report_text += f"\n{'='*65}\n" report_text += f"📊 Test samples: {int(total_support)} | Train source: {config.DATASET_PATH}\n" report_text += ( f"🔧 Algorithm: K-NN (k={self.classifier.knn.n_neighbors}, " f"weights={self.classifier.knn.weights}, metric={self.classifier.knn.metric}) | " f"CV: {config.CROSS_VALIDATION_FOLDS}-fold\n" ) self.perf_report_text.setPlainText(report_text) # Also display explanation self.display_classification_explanation_perf() #penjelasan grafik def display_confusion_matrix_explanation_perf(self): cm = getattr(self.classifier, 'confusion_matrix', None) if cm is not None and cm.sum() > 0: total_prediksi_benar = int(np.trace(cm)) total_data = int(cm.sum()) summary = f"Total prediksi benar yang berada pada diagonal adalah {total_prediksi_benar} dari {total_data} data." else: summary = "Data confusion matrix belum tersedia." explanation_text = ( "Grafik ini menggambarkan sebaran prediksi untuk setiap kelas.\n" "Nilai pada diagonal menunjukkan prediksi yang tepat, sedangkan perbedaan intensitas warna biru menunjukkan banyaknya jumlah data pada setiap sel.\n\n" f"{summary}" ) self.perf_confusion_explanation.setText(explanation_text) def display_roc_explanation_perf(self): """Display explanation for ROC curves in performa tab""" auc_values = getattr(self, 'perf_auc_values', []) if auc_values: avg_auc = float(np.mean(auc_values)) min_auc = float(np.min(auc_values)) summary = f"Nilai AUC rata-rata sebesar {avg_auc:.3f} dan nilai minimum {min_auc:.3f}, yang menunjukkan masih ada kelas yang relatif lebih sulit dibedakan." else: summary = "Nilai AUC belum tersedia." explanation_text = """ Grafik ini menunjukkan kemampuan model dalam membedakan setiap paket. Semakin mendekati 1, nilai AUC menandakan kemampuan pemisahan kelas yang semakin baik. {summary} """ self.perf_roc_explanation.setText( explanation_text.strip().format(summary=summary) ) def display_classification_explanation_perf(self): acc = getattr(self.classifier, 'training_accuracy', None) if acc is None: result = "Akurasi belum tersedia." else: level = "Sangat Baik" if acc >= 0.85 else "Baik" if acc >= 0.75 else "Cukup" if acc >= 0.65 else "Perlu perbaikan" result = f"Akurasi model mencapai {acc:.2%}, sehingga performanya tergolong {level.lower()}." explanation_text = """ Grafik ini merangkum kualitas prediksi untuk setiap paket. Precision menunjukkan ketepatan prediksi, recall menunjukkan kemampuan menemukan data yang benar, dan F1-score menggambarkan keseimbangan keduanya. {result} """ self.perf_class_explanation.setText(explanation_text.strip().format(result=result)) def plot_roc_curve_perf(self, y_test, y_pred_proba): """Plot ROC AUC curve for performa tab""" try: # Clear previous plot self.roc_figure.clear() self.perf_auc_values = [] # Get unique classes from classifier classes = self.classifier.knn.classes_ n_classes = len(classes) # Binarize the true labels y_test_bin = label_binarize(y_test, classes=classes) # Handle single class case if n_classes == 2: # For binary classification, use probability of positive class y_score = y_pred_proba[:, 1] y_true = (y_test == classes[1]).astype(int) # Compute ROC curve and ROC area fpr, tpr, _ = roc_curve(y_true, y_score) roc_auc = auc(fpr, tpr) self.perf_auc_values = [float(roc_auc)] # Plot ROC curve ax = self.roc_figure.add_subplot(111) ax.plot(fpr, tpr, color='darkorange', lw=2, label=f'ROC curve (AUC = {roc_auc:.3f})') ax.plot([0, 1], [0, 1], color='navy', lw=2, linestyle='--', label='Random Classifier') ax.set_xlim([0.0, 1.0]) ax.set_ylim([0.0, 1.05]) ax.set_xlabel('False Positive Rate', fontsize=10) ax.set_ylabel('True Positive Rate', fontsize=10) ax.set_title('ROC Curve (Binary)', fontweight='bold', fontsize=11) ax.legend(loc="lower right", fontsize=9) ax.grid(True, alpha=0.3) else: # Multi-class ROC ax = self.roc_figure.add_subplot(111) # Compute ROC curve and ROC area for each class fpr = dict() tpr = dict() roc_auc = dict() for i in range(n_classes): # Get true binary labels for this class y_true_class = (y_test == classes[i]).astype(int) # Get predicted probabilities for this class y_score_class = y_pred_proba[:, i] fpr[i], tpr[i], _ = roc_curve(y_true_class, y_score_class) roc_auc[i] = auc(fpr[i], tpr[i]) self.perf_auc_values = [float(roc_auc[i]) for i in range(n_classes)] # Plot ROC curve for each class colors = ['blue', 'red', 'green', 'orange', 'purple', 'brown'] for i in range(n_classes): color = colors[i % len(colors)] ax.plot(fpr[i], tpr[i], color=color, lw=2, label=f'Paket {classes[i]} (AUC = {roc_auc[i]:.3f})') # Plot random classifier line ax.plot([0, 1], [0, 1], 'k--', lw=2, label='Random Classifier', alpha=0.8) # Compute macro-average ROC curve and ROC area try: all_fpr = np.unique(np.concatenate([ fpr[i] for i in range(n_classes) ])) mean_tpr = np.zeros_like(all_fpr) for i in range(n_classes): mean_tpr += np.interp(all_fpr, fpr[i], tpr[i]) mean_tpr /= n_classes roc_auc_macro = float(np.mean([roc_auc[i] for i in range(n_classes)])) ax.plot(all_fpr, mean_tpr, label=f'Macro-avg (AUC = {roc_auc_macro:.3f})', color='gold', linestyle=':', linewidth=3) except: pass # Skip macro-average if calculation fails ax.set_xlim([0.0, 1.0]) ax.set_ylim([0.0, 1.05]) ax.set_xlabel('False Positive Rate', fontsize=10) ax.set_ylabel('True Positive Rate', fontsize=10) ax.set_title('Multi-class ROC Curves', fontweight='bold', fontsize=11) # Position legend to avoid overlap ax.legend(loc="center left", bbox_to_anchor=(1, 0.5), fontsize=8) ax.grid(True, alpha=0.3) # Adjust layout self.roc_figure.subplots_adjust(left=0.15, bottom=0.15, right=0.75, top=0.9) self.roc_canvas.draw() except Exception as e: print(f"Error plotting ROC curve: {e}") # Show error in ROC canvas ax = self.roc_figure.add_subplot(111) ax.text(0.5, 0.5, f'Error plotting ROC curve:\\n{str(e)}', ha='center', va='center', transform=ax.transAxes, fontsize=10, color='red') ax.set_title("ROC Plot Error", fontweight='bold') self.roc_canvas.draw() #ketika terjadi error pada halaman performa, tampilkan pesan error di semua area yang relevan def show_error_perf(self, message): # Show error in confusion matrix ax1 = self.perf_figure.add_subplot(111) ax1.text(0.5, 0.5, message, ha='center', va='center', transform=ax1.transAxes, fontsize=12, color='red') ax1.set_title("Error", fontweight='bold') self.perf_canvas.draw() # Show error in ROC curve ax2 = self.roc_figure.add_subplot(111) ax2.text(0.5, 0.5, message, ha='center', va='center', transform=ax2.transAxes, fontsize=12, color='red') ax2.set_title("Error", fontweight='bold') self.roc_canvas.draw() # Show error in all text areas self.perf_report_text.setPlainText(f"Error: {message}") self.perf_confusion_explanation.setText(f"Error: {message}") self.perf_roc_explanation.setText(f"Error: {message}") self.perf_class_explanation.setText(f"Error: {message}") def create_stat_card(self, title, value, color): """Create a modern statistics card""" card = QWidget() card.setFixedHeight(110) card.setStyleSheet(f""" QWidget {{ background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 {color}, stop:1 {self.darken_color(color)}); border-radius: 10px; }} """) # Add shadow effect shadow = QGraphicsDropShadowEffect() shadow.setBlurRadius(12) shadow.setXOffset(0) shadow.setYOffset(4) shadow.setColor(QColor(0, 0, 0, 25)) card.setGraphicsEffect(shadow) layout = QVBoxLayout(card) layout.setAlignment(Qt.AlignCenter) layout.setContentsMargins(15, 15, 15, 15) value_label = QLabel(value) value_label.setStyleSheet(""" font-family: 'Segoe UI', Arial, sans-serif; font-size: 40px; font-weight: bold; color: white; background: transparent; """) value_label.setAlignment(Qt.AlignCenter) layout.addWidget(value_label) title_label = QLabel(title) title_label.setStyleSheet(""" font-family: 'Segoe UI', Arial, sans-serif; font-size: 18px; color: rgba(255, 255, 255, 0.95); background: transparent; margin-top: -5px; """) title_label.setAlignment(Qt.AlignCenter) layout.addWidget(title_label) return card def darken_color(self, hex_color): """Darken a hex color by 15% for gradient effect""" # Remove # if present hex_color = hex_color.lstrip('#') # Convert to RGB r, g, b = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) # Darken by 15% r = max(0, int(r * 0.85)) g = max(0, int(g * 0.85)) b = max(0, int(b * 0.85)) # Convert back to hex return f'#{r:02x}{g:02x}{b:02x}' # Update existing data self.btn_update = QPushButton("✏️ Update") self.btn_update.setStyleSheet(""" background-color: #2196F3; color: white; padding: 10px 20px; font-weight: bold; font-size: 14px; border-radius: 5px; margin: 2px; """) self.btn_update.clicked.connect(self.update_data) self.btn_update.setToolTip("Mengupdate data yang dipilih dengan prediksi baru") btn_row1_layout.addWidget(self.btn_update) # Clear form self.btn_clear = QPushButton("🗑️ Clear Form") self.btn_clear.setStyleSheet(""" background-color: #607D8B; color: white; padding: 10px 20px; font-weight: bold; font-size: 14px; border-radius: 5px; margin: 2px; """) self.btn_clear.clicked.connect(self.clear_form) btn_row1_layout.addWidget(self.btn_clear) btn_container.addLayout(btn_row1_layout) # Add a small separator line between button rows separator = QFrame() separator.setFrameShape(QFrame.HLine) separator.setFrameShadow(QFrame.Sunken) separator.setStyleSheet("color: #cccccc; margin: 5px 0px;") btn_container.addWidget(separator) # Label for secondary actions secondary_label = QLabel("🔧 Manajemen Tabel & Analisis:") secondary_label.setStyleSheet("font-weight: bold; color: #1565C0; margin: 2px 0px 2px 0px; font-size: 12px; font-family: 'Segoe UI', Arial, sans-serif;") btn_container.addWidget(secondary_label) # Row 2: Table Management & Analysis Actions (Secondary Actions) btn_row2_layout = QHBoxLayout() # Search data self.btn_search = QPushButton("🔍 Search") self.btn_search.setStyleSheet(""" background-color: #9C27B0; color: white; padding: 10px 20px; font-family: 'Segoe UI', Arial, sans-serif; font-weight: bold; font-size: 14px; border-radius: 5px; margin: 2px; """) self.btn_search.clicked.connect(self.search_data) btn_row2_layout.addWidget(self.btn_search) # Delete selected data self.btn_delete_new = QPushButton("🗂️ Delete") self.btn_delete_new.setStyleSheet(""" background-color: #FF9800; color: white; padding: 10px 20px; font-family: 'Segoe UI', Arial, sans-serif; font-weight: bold; font-size: 14px; border-radius: 5px; margin: 2px; """) self.btn_delete_new.clicked.connect(self.delete_data) btn_row2_layout.addWidget(self.btn_delete_new) # Refresh table self.btn_refresh = QPushButton("🔄 Refresh Table") self.btn_refresh.setStyleSheet(""" background-color: #00BCD4; color: white; padding: 10px 20px; font-family: 'Segoe UI', Arial, sans-serif; font-weight: bold; font-size: 14px; border-radius: 5px; margin: 2px; """) self.btn_refresh.clicked.connect(self.refresh_table) btn_row2_layout.addWidget(self.btn_refresh) # Show performance report self.btn_performance = QPushButton("📊 Performance") self.btn_performance.setStyleSheet(""" background-color: #FF5722; color: white; padding: 10px 20px; font-family: 'Segoe UI', Arial, sans-serif; font-weight: bold; font-size: 14px; border-radius: 5px; margin: 2px; """) self.btn_performance.clicked.connect(self.show_performance) btn_row2_layout.addWidget(self.btn_performance) # Export data to Excel self.btn_export = QPushButton("📁 Export") self.btn_export.setStyleSheet(""" background-color: #4CAF50; color: white; padding: 10px 20px; font-family: 'Segoe UI', Arial, sans-serif; font-weight: bold; font-size: 14px; border-radius: 5px; margin: 2px; """) self.btn_export.clicked.connect(self.export_to_excel) btn_row2_layout.addWidget(self.btn_export) btn_container.addLayout(btn_row2_layout) layout.addLayout(btn_container) # Table Section self.table = QTableWidget() self.setup_table() layout.addWidget(self.table) # Pagination Section pagination_layout = QHBoxLayout() pagination_layout.setAlignment(Qt.AlignCenter) # Previous button self.btn_prev = QPushButton("◀ Previous") self.btn_prev.setStyleSheet(""" QPushButton { background-color: #6c757d; color: white; padding: 8px 18px; font-family: 'Segoe UI', Arial, sans-serif; font-weight: bold; border-radius: 5px; margin: 2px; } QPushButton:hover { background-color: #5a6268; } QPushButton:disabled { background-color: #cccccc; color: #666666; } """) self.btn_prev.clicked.connect(self.previous_page) pagination_layout.addWidget(self.btn_prev) # Page info label self.page_info_label = QLabel("Halaman 1 dari 1") self.page_info_label.setStyleSheet(""" QLabel { font-family: 'Segoe UI', Arial, sans-serif; font-weight: bold; padding: 8px 16px; color: #495057; } """) pagination_layout.addWidget(self.page_info_label) # Next button self.btn_next = QPushButton("Next ▶") self.btn_next.setStyleSheet(""" QPushButton { background-color: #6c757d; color: white; padding: 8px 16px; font-family: 'Segoe UI', Arial, sans-serif; font-weight: bold; border-radius: 5px; margin: 2px; } QPushButton:hover { background-color: #5a6268; } QPushButton:disabled { background-color: #cccccc; color: #666666; } """) self.btn_next.clicked.connect(self.next_page) pagination_layout.addWidget(self.btn_next) # Items per page info items_info_label = QLabel(f"Menampilkan {self.items_per_page} data per halaman") items_info_label.setStyleSheet(""" QLabel { font-style: italic; color: #6c757d; padding: 8px 16px; } """) pagination_layout.addWidget(items_info_label) layout.addLayout(pagination_layout) # Status bar self.statusBar().showMessage('Ready') central_widget.setLayout(layout) # Load trained model if exists try: self.classifier.load_model(config.MODEL_PATH) except: # Train model with dummy data if no model exists self.train_model_background() #struktur tabel data siswa def setup_table(self): columns = ['No', 'Nama Siswa', 'Informatika', 'Fisika', 'Kimia', 'Biologi', 'BIG Lanjut', 'Ekonomi', 'MAT Lanjut', 'SEJ Lanjut', 'Sosiologi', 'Geografi', 'Paket Prediksi', 'ID'] self.table.setColumnCount(len(columns)) self.table.setHorizontalHeaderLabels(columns) # Make sure horizontal header is visible self.table.horizontalHeader().setVisible(True) # Set header properties header = self.table.horizontalHeader() header.setDefaultSectionSize(100) header.setMinimumSectionSize(50) header.setSectionResizeMode(QHeaderView.Interactive) header.setStretchLastSection(True) header.setDefaultAlignment(Qt.AlignCenter) # Set header height header.setMinimumHeight(55) header.setMaximumHeight(55) # Hide the ID column self.table.setColumnHidden(13, True) # Hide vertical header (row numbers) since we have our own No column self.table.verticalHeader().setVisible(False) # Set column widths - lebih lebar untuk menampung text header self.table.setColumnWidth(0, 60) # No self.table.setColumnWidth(1, 250) # Nama Siswa - diperlebar self.table.setColumnWidth(2, 130) # Informatika self.table.setColumnWidth(3, 90) # Fisika self.table.setColumnWidth(4, 90) # Kimia self.table.setColumnWidth(5, 90) # Biologi self.table.setColumnWidth(6, 120) # BIG Lanjut - diperlebar lagi self.table.setColumnWidth(7, 100) # Ekonomi self.table.setColumnWidth(8, 120) # MAT Lanjut - diperlebar lagi self.table.setColumnWidth(9, 120) # SEJ Lanjut - diperlebar lagi self.table.setColumnWidth(10, 100) # Sosiologi self.table.setColumnWidth(11, 100) # Geografi self.table.setColumnWidth(12, 150) # Paket Prediksi self.table.setSelectionBehavior(QAbstractItemView.SelectRows) self.table.setSelectionMode(QAbstractItemView.SingleSelection) self.table.setFocusPolicy(Qt.NoFocus) self.table.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel) self.table.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel) self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.table.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) # Connect signals self.table.cellClicked.connect(self.on_table_cell_clicked) self.table.itemSelectionChanged.connect(self.on_table_selection_changed) # Table styling self.table.setShowGrid(True) self.table.setGridStyle(Qt.SolidLine) self.table.setAlternatingRowColors(True) # Keep row height compact so 10 rows can fit in the available viewport. row_height = 36 self.table.verticalHeader().setDefaultSectionSize(row_height) # Set stylesheet for table with modern clean styling self.table.setStyleSheet(""" QTableWidget { gridline-color: #e0e6ed; background-color: white; alternate-background-color: #f8fafc; selection-background-color: #667eea; selection-color: white; font-family: 'Segoe UI', Arial, sans-serif; font-size: 18px; border: 1px solid #e0e6ed; border-radius: 8px; } QTableWidget::item { padding: 14px 8px; } QTableWidget::item:selected { background-color: #667eea; color: white; } QTableWidget::item:hover { background-color: #f1f5f9; } QTableWidget::item:focus { outline: none; border: none; } QHeaderView::section { background-color: #f8fafc; border: 0px; border-right: 1px solid #e0e6ed; border-bottom: 1px solid #e0e6ed; padding: 14px 8px; font-weight: bold; font-size: 18px; color: #2c3e50; text-align: center; } QHeaderView::section:hover { background-color: #e8ecf1; } """) # Keep table responsive on smaller screens while preserving enough rows. header_height = 55 table_min_height = header_height + (4 * row_height) + 8 self.table.setMinimumHeight(table_min_height) self.table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) def format_nilai(self, nilai): """ Format nilai untuk tampilan di tabel. Menghilangkan trailing zeros, tetapi tetap mempertahankan dua digit jika memang ada nilai desimal. Contoh: 94.50 -> 94.50 ; 77.00 -> 77 ; 80.0 -> 80 """ try: # Convert to float first if isinstance(nilai, str): nilai = float(nilai.replace(',', '.')) else: nilai = float(nilai) # Format: jika bulat tampilkan tanpa desimal, jika desimal hapus trailing zero if nilai == int(nilai): return str(int(nilai)) else: # Format with two decimals then strip trailing zero if present formatted = f"{nilai:.2f}".rstrip('0').rstrip('.') return formatted except (ValueError, TypeError): return str(nilai) #untuk load data ke tabel def load_table_data(self): try: # Disconnect signals to prevent conflicts self.table.itemSelectionChanged.disconnect() # Set flag to prevent selection change handling during load self.loading_data = True # Load all data from database self.all_data = ambil_semua_hasil_prediksi() self.total_items = len(self.all_data) # print(f"Loading {self.total_items} total rows of data") # Display current page self.display_current_page() except Exception as e: CustomNotificationDialog.show_error(self, 'Error!', f'Gagal memuat data:\n{str(e)}', 'OK') # Debug: print(f"Error loading table data: {str(e)}") finally: # Always reset the flag and reconnect signals self.loading_data = False # Reconnect the signal self.table.itemSelectionChanged.connect(self.on_table_selection_changed) def show_table_empty_message(self, message): """Show a centered message inside table when there is no data to display.""" self.table.clearContents() self.table.clearSpans() self.table.setSelectionMode(QAbstractItemView.NoSelection) self.table.setRowCount(1) # Keep the hidden ID column (index 13) excluded from span. visible_columns = max(1, self.table.columnCount() - 1) self.table.setSpan(0, 0, 1, visible_columns) message_item = QTableWidgetItem(message) message_item.setTextAlignment(Qt.AlignCenter) message_item.setFlags(Qt.ItemIsEnabled) font = message_item.font() font.setPointSize(12) font.setItalic(True) message_item.setFont(font) message_item.setForeground(QBrush(QColor("#64748b"))) self.table.setItem(0, 0, message_item) self.table.setRowHeight(0, 55) #menampilkan data sesuai halaman yang dipilih def display_current_page(self): try: total_pages = max(1, (self.total_items + self.items_per_page - 1) // self.items_per_page) if self.current_page > total_pages: self.current_page = total_pages # Calculate pagination start_index = (self.current_page - 1) * self.items_per_page end_index = start_index + self.items_per_page current_page_data = self.all_data[start_index:end_index] if not current_page_data: search_text = self.search_input.text().strip() if hasattr(self, 'search_input') else "" empty_message = "Data tidak ditemukan" if search_text else "Belum ada data siswa" self.show_table_empty_message(empty_message) self.update_pagination_controls() self.current_selected_id = None self.table.clearSelection() if hasattr(self, 'btn_edit'): self.btn_edit.setEnabled(False) self.btn_edit.setToolTip("Pilih data terlebih dahulu untuk mengedit") if hasattr(self, 'btn_preview'): self.btn_preview.setEnabled(False) self.btn_preview.setToolTip("Pilih data terlebih dahulu untuk melihat preview prediksi") if hasattr(self, 'btn_update'): self.btn_update.setEnabled(False) self.btn_update.setToolTip("Pilih data terlebih dahulu untuk mengupdate") if hasattr(self, 'btn_predict'): self.btn_predict.setEnabled(True) self.btn_predict.setToolTip("Memprediksi dan menyimpan sebagai data baru") return self.table.setSelectionMode(QAbstractItemView.SingleSelection) self.table.clearContents() self.table.clearSpans() # Set table row count self.table.setRowCount(len(current_page_data)) # Populate table with current page data for row, item in enumerate(current_page_data): # Calculate correct row number (continuous across pages) actual_row_number = start_index + row + 1 # Create table items with proper alignment no_item = QTableWidgetItem(str(actual_row_number)) no_item.setTextAlignment(Qt.AlignCenter) self.table.setItem(row, 0, no_item) nama_item = QTableWidgetItem(str(item.get('nama_siswa', ''))) nama_item.setTextAlignment(Qt.AlignLeft | Qt.AlignVCenter) self.table.setItem(row, 1, nama_item) # Mata pelajaran scores for col, field in enumerate(['nilai_informatika', 'nilai_fisika', 'nilai_kimia', 'nilai_biologi', 'nilai_big_lanjut', 'nilai_ekonomi', 'nilai_mat_lanjut', 'nilai_sej_lanjut', 'nilai_sosiologi', 'nilai_geografi'], start=2): # Format nilai tanpa trailing zeros nilai_raw = item.get(field, 0) nilai_formatted = self.format_nilai(nilai_raw) score_item = QTableWidgetItem(nilai_formatted) score_item.setTextAlignment(Qt.AlignCenter) self.table.setItem(row, col, score_item) # Paket Prediksi paket_item = QTableWidgetItem(str(item.get('paket_prediksi', ''))) paket_item.setTextAlignment(Qt.AlignCenter) self.table.setItem(row, 12, paket_item) # Hidden ID column id_item = QTableWidgetItem(str(item.get('id', ''))) self.table.setItem(row, 13, id_item) # Set row height consistently with default section size. self.table.setRowHeight(row, 36) # Update pagination controls self.update_pagination_controls() # Clear selection self.current_selected_id = None self.table.clearSelection() # Disable edit and preview buttons when no selection if hasattr(self, 'btn_edit'): self.btn_edit.setEnabled(False) self.btn_edit.setToolTip("Pilih data terlebih dahulu untuk mengedit") if hasattr(self, 'btn_preview'): self.btn_preview.setEnabled(False) self.btn_preview.setToolTip("Pilih data terlebih dahulu untuk melihat preview prediksi") # print(f"Displayed page {self.current_page} with {len(current_page_data)} items") except Exception as e: # print(f"Error displaying current page: {str(e)}") pass #untuk update tombol pagination dan label halaman def update_pagination_controls(self): total_pages = max(1, (self.total_items + self.items_per_page - 1) // self.items_per_page) # Update page info label self.page_label.setText(f"Halaman {self.current_page} dari {total_pages}") # Update button states self.btn_prev.setEnabled(self.current_page > 1) self.btn_next.setEnabled(self.current_page < total_pages) if self.total_items == 0: self.statusBar().showMessage('Tidak ada data untuk ditampilkan') return # Update status bar start_item = (self.current_page - 1) * self.items_per_page + 1 end_item = min(self.current_page * self.items_per_page, self.total_items) self.statusBar().showMessage(f'Menampilkan {start_item}-{end_item} dari {self.total_items} data') #button pagination previous dan next def previous_page(self): if self.current_page > 1: self.current_page -= 1 self.display_current_page() def next_page(self): total_pages = max(1, (self.total_items + self.items_per_page - 1) // self.items_per_page) if self.current_page < total_pages: self.current_page += 1 self.display_current_page() def debug_table_contents(self, context=""): """Debug function - disabled for production""" # Uncomment below for debugging pass # print(f"=== DEBUG TABLE CONTENTS ({context}) ===") # print(f"Table row count: {self.table.rowCount()}") # print(f"Table column count: {self.table.columnCount()}") # for row in range(min(3, self.table.rowCount())): # row_data = [] # for col in range(self.table.columnCount()): # item = self.table.item(row, col) # if item: # row_data.append(f"Col{col}:'{item.text()}'") # else: # row_data.append(f"Col{col}:None") # print(f"Row {row}: {', '.join(row_data)}") # print("=== END DEBUG ===") #untuk menangani perubahan seleksi pada tabel def on_table_selection_changed(self): # Prevent recursion when loading data if self.loading_data: # print("Loading data in progress, skipping selection change") return # Debug table contents before processing self.debug_table_contents("before selection change") current_row = self.table.currentRow() # print(f"Selection changed - Current row: {current_row}") # print(f"Table row count: {self.table.rowCount()}") # Check if table has valid data if self.table.rowCount() == 0: # print("Table is empty, clearing form") self.clear_form() self.current_selected_id = None return # Only process if a valid row is selected if current_row >= 0 and current_row < self.table.rowCount(): # Set flag to prevent recursion self.loading_data = True try: # First check if the row has valid data (nama siswa di kolom 1) nama_item = self.table.item(current_row, 1) if nama_item is None or not nama_item.text().strip(): # print(f"Row {current_row} has no name data, keeping current form state") self.loading_data = False # Reset flag before returning return # print(f"Loading data from valid row {current_row}") # Clear current selection ID first self.current_selected_id = None # Don't load data to form here - only when Edit button is clicked # self.load_selected_data_to_form(current_row) # Get the ID (sekarang di kolom 13) id_item = self.table.item(current_row, 13) # Use hidden ID column print(f"ID item for row {current_row}: {id_item}") if id_item is not None and id_item.text().strip(): self.current_selected_id = id_item.text().strip() print(f"Successfully selected ID: {self.current_selected_id}") self.statusBar().showMessage(f'Data dipilih: {self.current_selected_id}') # Enable Edit, Preview, and Update buttons when data is selected if hasattr(self, 'btn_edit'): self.btn_edit.setEnabled(True) self.btn_edit.setToolTip("Klik untuk mengedit data yang dipilih") if hasattr(self, 'btn_preview'): self.btn_preview.setEnabled(True) self.btn_preview.setToolTip("Klik untuk melihat preview prediksi data yang dipilih") if hasattr(self, 'btn_update'): self.btn_update.setEnabled(True) self.btn_update.setToolTip("Klik untuk mengupdate data yang dipilih dengan prediksi baru") # Keep predict button enabled when data is selected (not in edit mode yet) if hasattr(self, 'btn_predict'): self.btn_predict.setEnabled(True) self.btn_predict.setToolTip("Memprediksi dan menyimpan sebagai data baru") else: print(f"No valid ID found for row {current_row}") self.statusBar().showMessage('Data dipilih tanpa ID') # Disable Edit, Preview, and Update buttons if no valid ID if hasattr(self, 'btn_edit'): self.btn_edit.setEnabled(False) if hasattr(self, 'btn_preview'): self.btn_preview.setEnabled(False) if hasattr(self, 'btn_update'): self.btn_update.setEnabled(False) except Exception as e: print(f"Error in selection changed: {str(e)}") self.statusBar().showMessage(f'Error: {str(e)}') self.debug_table_contents("after error") finally: # Always reset the flag self.loading_data = False else: # Only clear form if explicitly no selection (current_row == -1) if current_row == -1: print("No row selected, clearing form") self.clear_form() self.current_selected_id = None self.statusBar().showMessage('Tidak ada data yang dipilih') # Disable Edit, Preview, and Update buttons when no selection if hasattr(self, 'btn_edit'): self.btn_edit.setEnabled(False) self.btn_edit.setToolTip("Pilih data terlebih dahulu untuk mengedit") if hasattr(self, 'btn_preview'): self.btn_preview.setEnabled(False) self.btn_preview.setToolTip("Pilih data terlebih dahulu untuk melihat preview prediksi") if hasattr(self, 'btn_update'): self.btn_update.setEnabled(False) self.btn_update.setToolTip("Pilih data terlebih dahulu untuk mengupdate") # Enable predict button when no selection (create mode) if hasattr(self, 'btn_predict'): self.btn_predict.setEnabled(True) self.btn_predict.setToolTip("Memprediksi dan menyimpan sebagai data baru") else: print(f"Invalid row {current_row}, keeping current form state") # Debug table contents after processing self.debug_table_contents("after selection change") #untuk load data yang dipilih ke form untuk diedit def load_selected_data_to_form(self, row, edit_mode=False): """Load selected row data to form for editing""" try: self.loading_data = True # Check if row is valid if row < 0 or row >= self.table.rowCount(): print(f"Invalid row: {row}") return print(f"Loading data from row {row}, edit_mode: {edit_mode}") # Fill nama siswa (kolom index 1, karena index 0 adalah kolom No) nama_item = self.table.item(row, 1) if nama_item and nama_item.text(): nama = nama_item.text().strip() self.nama_input.setText(nama) print(f"Nama yang dimuat: {nama}") # Logging tambahan else: print("Nama item is None or empty") self.nama_input.clear() # Fill nilai mata pelajaran (mulai dari kolom index 2) # Fill into nilai_table semester values and auto-calculate rata-rata subject_keys = [ 'nilai_informatika', 'nilai_fisika', 'nilai_kimia', 'nilai_biologi', 'nilai_big_lanjut', 'nilai_ekonomi', 'nilai_mat_lanjut', 'nilai_sej_lanjut', 'nilai_sosiologi', 'nilai_geografi' ] if hasattr(self, 'nilai_table'): for i, key in enumerate(subject_keys): col_index = i + 2 # Mulai dari kolom 2 (karena 0=No, 1=Nama) value_item = self.table.item(row, col_index) if value_item and value_item.text().strip(): try: # Parse nilai value = self.parse_float_value(value_item.text().strip()) # Check if database has semester 1 and 2 values # Get the data dict from current selected row in all_data if hasattr(self, 'all_data') and len(self.all_data) > 0: # Find the current data in all_data current_data = None current_page_start = (self.current_page - 1) * self.items_per_page page_row_index = current_page_start + row if page_row_index < len(self.all_data): current_data = self.all_data[page_row_index] if current_data: # Try to get semester 1 and 2 values from database sem1_key = f'{key}_sem1' sem2_key = f'{key}_sem2' sem1_value = current_data.get(sem1_key) sem2_value = current_data.get(sem2_key) sem1_item = self.nilai_table.item(i, 1) sem2_item = self.nilai_table.item(i, 2) # Fill semester 1 dan 2 secara sinkron ke item dan editor if sem1_item: if sem1_value is not None and sem1_value != '': sem1_text = self.format_nilai(sem1_value) else: sem1_text = "" self._set_nilai_cell_text(i, 1, sem1_text) print(f"Loaded {key} semester 1: {sem1_value}") if sem2_item: if sem2_value is not None and sem2_value != '': sem2_text = self.format_nilai(sem2_value) else: sem2_text = "" self._set_nilai_cell_text(i, 2, sem2_text) print(f"Loaded {key} semester 2: {sem2_value}") self._calculate_nilai_rata_row(i) except Exception as err: print(f"Error converting {key} value: {value_item.text()} - {err}") else: print(f"Empty or None value for {key}") print(f"Successfully loaded data from row {row}") self.statusBar().showMessage('Data berhasil dimuat ke form untuk diedit') if edit_mode: self.original_edit_data = self._get_current_form_snapshot() # Enable edit and preview buttons when data is loaded if hasattr(self, 'btn_edit'): self.btn_edit.setEnabled(True) self.btn_edit.setToolTip("Mengedit data yang dipilih") if hasattr(self, 'btn_preview'): self.btn_preview.setEnabled(True) self.btn_preview.setToolTip("Melihat preview prediksi untuk data yang dipilih") # Only disable predict button when explicitly in edit mode if edit_mode and hasattr(self, 'btn_predict'): self.btn_predict.setEnabled(False) self.btn_predict.setToolTip("Gunakan tombol Update untuk memperbarui data yang dipilih") elif hasattr(self, 'btn_predict'): self.btn_predict.setEnabled(True) self.btn_predict.setToolTip("Memprediksi dan menyimpan sebagai data baru") self.loading_data = False except Exception as e: self.loading_data = False print(f"Error loading data to form: {str(e)}") CustomNotificationDialog.show_error(self, 'Error!', f'Gagal memuat data ke form:\n{str(e)}', 'OK') def add_data(self): """Add new student data""" if not self.validate_form(): return try: nama = self.nama_input.text().strip() # Extract nilai dari tabel (simpan semester 1, semester 2, dan rata-rata) nilai_dict = {} subject_keys = [ 'nilai_informatika', 'nilai_fisika', 'nilai_kimia', 'nilai_biologi', 'nilai_big_lanjut', 'nilai_ekonomi', 'nilai_mat_lanjut', 'nilai_sej_lanjut', 'nilai_sosiologi', 'nilai_geografi' ] if hasattr(self, 'nilai_table'): for row in range(min(len(subject_keys), self.nilai_table.rowCount())): key = subject_keys[row] # Get semester 1 (kolom 1) sem1_item = self.nilai_table.item(row, 1) sem1_value = self.parse_float_value(sem1_item.text()) if sem1_item else 0 # Get semester 2 (kolom 2) sem2_item = self.nilai_table.item(row, 2) sem2_value = self.parse_float_value(sem2_item.text()) if sem2_item else 0 # Get rata-rata (kolom 3) rata_item = self.nilai_table.item(row, 3) rata_value = self.parse_float_value(rata_item.text()) if rata_item else 0 # Store all three values nilai_dict[f'{key}_sem1'] = sem1_value nilai_dict[f'{key}_sem2'] = sem2_value nilai_dict[key] = rata_value # Predict paket (using rata-rata values) prediction_dict = {key: nilai_dict[key] for key in subject_keys if key in nilai_dict} if self.classifier.is_trained: prediction, _ = self.classifier.predict(prediction_dict) else: prediction = 1 # Default simpan_hasil_prediksi(nama, nilai_dict, prediction) self.load_table_data() self.clear_form() CustomNotificationDialog.show_success(self, 'Berhasil!', 'Data berhasil ditambahkan!', 'OK') self.statusBar().showMessage('Data berhasil ditambahkan') except Exception as e: CustomNotificationDialog.show_error(self, 'Error!', f'Gagal menambah data:\n{str(e)}', 'OK') #Fungsi Update Data dengan Prediksi Baru def update_data(self): if not self.current_selected_id: CustomNotificationDialog.show_warning(self, 'Warning!', 'Pilih data yang akan diupdate!', 'OK') return if not self.validate_form(): return if self.edit_mode and not self._has_form_changes(): CustomNotificationDialog.show_warning( self, 'Tidak Ada Perubahan', 'Data belum diubah. Silakan lakukan perubahan terlebih dahulu sebelum update.', 'OK' ) return if not self.classifier.is_trained: CustomNotificationDialog.show_warning(self, 'Warning!', 'Model belum dilatih!\nLatih model terlebih dahulu.', 'OK') return try: # Get form data nama = self.nama_input.text().strip() # Extract nilai dari tabel (simpan semester 1, semester 2, dan rata-rata) nilai_dict = {} subject_keys = [ 'nilai_informatika', 'nilai_fisika', 'nilai_kimia', 'nilai_biologi', 'nilai_big_lanjut', 'nilai_ekonomi', 'nilai_mat_lanjut', 'nilai_sej_lanjut', 'nilai_sosiologi', 'nilai_geografi' ] if hasattr(self, 'nilai_table'): for row in range(min(len(subject_keys), self.nilai_table.rowCount())): key = subject_keys[row] # Get semester 1 (kolom 1) sem1_item = self.nilai_table.item(row, 1) sem1_value = self.parse_float_value(sem1_item.text()) if sem1_item else 0 # Get semester 2 (kolom 2) sem2_item = self.nilai_table.item(row, 2) sem2_value = self.parse_float_value(sem2_item.text()) if sem2_item else 0 # Get rata-rata (kolom 3) rata_item = self.nilai_table.item(row, 3) rata_value = self.parse_float_value(rata_item.text()) if rata_item else 0 # Store all three values nilai_dict[f'{key}_sem1'] = sem1_value nilai_dict[f'{key}_sem2'] = sem2_value nilai_dict[key] = rata_value # Make new prediction with updated values (using rata-rata) prediction_dict = {key: nilai_dict[key] for key in subject_keys if key in nilai_dict} prediction, top_3 = self.classifier.predict(prediction_dict) # Show updated prediction result paket_info = self.data_processor.get_paket_info(prediction) kelas_info = f" ({paket_info.get('kelas_tersedia', 1)} kelas tersedia)" result_text = f"Prediksi Ulang untuk {nama}:\n\n" result_text += f"Paket Terpilih: {prediction}\n" result_text += f"Detail: {paket_info.get('nama', 'Unknown')}{kelas_info}\n" result_text += "\nData akan diupdate dengan hasil prediksi terbaru." # Show prediction result reply = CustomNotificationDialog.show_question(self, "Konfirmasi Update", result_text + "\n\nLanjutkan update?", "Ya, Update", "Batal") if reply: # Update data with new prediction update_hasil_prediksi(self.current_selected_id, nama, nilai_dict, prediction) # Refresh table and clear form self.load_table_data() self.clear_form() # Show success message CustomNotificationDialog.show_success(self, 'Berhasil!', 'Data berhasil diupdate dengan prediksi terbaru!', 'OK') self.statusBar().showMessage('Data berhasil diupdate dengan prediksi terbaru') except Exception as e: CustomNotificationDialog.show_error(self, 'Error!', f'Gagal mengupdate data:\n{str(e)}', 'OK') def hapus_data(self): """Hapus data yang dipilih dari tabel""" if not self.current_selected_id: CustomNotificationDialog.show_warning(self, 'Warning!', 'Pilih data yang akan dihapus!', 'OK') return reply = CustomNotificationDialog.show_question(self, 'Konfirmasi Hapus', 'Apakah Anda yakin ingin menghapus data ini?', 'Ya, Hapus', 'Batal') if reply: try: hapus_hasil_prediksi(self.current_selected_id) self.load_table_data() self.clear_form() CustomNotificationDialog.show_success(self, 'Berhasil!', 'Data berhasil dihapus!', 'OK') self.statusBar().showMessage('Data berhasil dihapus') except Exception as e: CustomNotificationDialog.show_error(self, 'Error!', f'Gagal menghapus data:\n{str(e)}', 'OK') def search_data(self): """Search data by name and update table to show only matching results""" search_text, ok = QInputDialog.getText(self, 'Search', 'Masukkan nama siswa:') if ok and search_text.strip(): try: # Set loading flag to prevent selection events during search self.loading_data = True # Get all data and filter all_data = ambil_semua_hasil_prediksi() filtered_data = [item for item in all_data if search_text.lower() in item['nama_siswa'].lower()] print(f"Search found {len(filtered_data)} results") # Update pagination data with filtered results self.all_data = filtered_data self.total_items = len(filtered_data) self.current_page = 1 # Reset to first page # Display current page self.display_current_page() if not filtered_data: CustomNotificationDialog.show_warning(self, 'Tidak Ditemukan', 'Data tidak ditemukan!\nCoba dengan kata kunci lain.', 'OK') # Reset to all data if no results found self.load_table_data() else: print(f"Search completed successfully - showing {len(filtered_data)} results") except Exception as e: print(f"Error in search_data: {str(e)}") CustomNotificationDialog.show_error(self, 'Error!', f'Gagal mencari data:\n{str(e)}', 'OK') finally: # Reset loading flag self.loading_data = False elif ok and not search_text.strip(): # If empty search, reload all data self.load_table_data() #pencarian data berdasarkan nama def search_by_name(self): search_text = self.search_input.text().strip() try: # untuk mencegah diklik berkali-kali saat loading data self.loading_data = True # Ambil semua data dari database all_data = ambil_semua_hasil_prediksi() if search_text: # Filter data berdasarkan nama siswa filtered_data = [item for item in all_data if search_text.lower() in item['nama_siswa'].lower()] print(f"Search by name found {len(filtered_data)} results for '{search_text}'") else: # Jika search kosong, tampilkan semua data filtered_data = all_data # Update data untuk pagination self.all_data = filtered_data self.total_items = len(filtered_data) self.current_page = 1 # Reset ke halaman pertama # Display current page self.display_current_page() self.update_pagination_controls() # Update info label if search_text: self.info_label.setText(f"Menampilkan {len(filtered_data)} hasil pencarian untuk '{search_text}'") else: self.info_label.setText(f"Menampilkan semua data ({len(filtered_data)} total)") except Exception as e: print(f"Error in search_by_name: {str(e)}") CustomNotificationDialog.show_error(self, 'Error!', f'Gagal mencari data:\n{str(e)}', 'OK') finally: # Reset loading flag self.loading_data = False #menghapus hasil pencarian dan menampilkan semua data kembali def clear_search(self): self.search_input.clear() self.load_table_data() self.info_label.setText("") def clear_form(self, show_empty_notification=False): """Clear all form inputs""" form_has_data = False if hasattr(self, 'nama_input') and self.nama_input.text().strip(): form_has_data = True elif hasattr(self, 'nilai_table'): for row in range(self.nilai_table.rowCount()): sem1_item = self.nilai_table.item(row, 1) sem2_item = self.nilai_table.item(row, 2) if (sem1_item and sem1_item.text().strip()) or (sem2_item and sem2_item.text().strip()): form_has_data = True break if show_empty_notification and not form_has_data: CustomNotificationDialog.show_warning(self, 'Info', 'Tidak ada data dalam form.', 'OK') return # Set loading flag to prevent recursion self.loading_data = True # Clear table selection first to prevent on_table_selection_changed from being called if hasattr(self, 'table'): self.table.clearSelection() self.nama_input.clear() # Clear nilai table if hasattr(self, 'nilai_table'): for row in range(self.nilai_table.rowCount()): sem1_item = self.nilai_table.item(row, 1) sem2_item = self.nilai_table.item(row, 2) rata_item = self.nilai_table.item(row, 3) if sem1_item: self._set_nilai_cell_text(row, 1, '') if sem2_item: self._set_nilai_cell_text(row, 2, '') if rata_item: rata_item.setText('otomatis') rata_item.setForeground(QColor('#94a3b8')) self.current_selected_id = None self.edit_mode = False # Reset edit mode when clearing form self.original_edit_data = None # Disable edit and preview buttons when form is cleared if hasattr(self, 'btn_edit'): self.btn_edit.setEnabled(False) self.btn_edit.setToolTip("Pilih data terlebih dahulu untuk mengedit") if hasattr(self, 'btn_preview'): self.btn_preview.setEnabled(False) self.btn_preview.setToolTip("Pilih data terlebih dahulu untuk melihat preview prediksi") if hasattr(self, 'btn_update'): self.btn_update.setEnabled(False) self.btn_update.setToolTip("Pilih data terlebih dahulu untuk mengupdate") # Enable predict button when form is cleared (create mode) if hasattr(self, 'btn_predict'): self.btn_predict.setEnabled(True) self.btn_predict.setToolTip("Memprediksi dan menyimpan sebagai data baru") # Reset loading flag self.loading_data = False #mengahapus data terpilih dari database def delete_data(self): if not self.current_selected_id: CustomNotificationDialog.show_warning(self, 'Warning!', 'Pilih data yang akan dihapus!', 'OK') return reply = CustomNotificationDialog.show_question(self, 'Konfirmasi Hapus', 'Apakah Anda yakin ingin menghapus data ini?', 'Ya, Hapus', 'Batal') if reply: try: hapus_hasil_prediksi(self.current_selected_id) self.load_table_data() self.clear_form() CustomNotificationDialog.show_success(self, 'Berhasil!', 'Data berhasil dihapus!', 'OK') self.statusBar().showMessage('Data berhasil dihapus') except Exception as e: CustomNotificationDialog.show_error(self, 'Error!', f'Gagal menghapus data:\n{str(e)}', 'OK') #fungsi edit data def edit_selected_data(self): """Edit selected data by switching to Input tab with data loaded""" print(f"Edit button clicked. Current selected ID: {self.current_selected_id}") if not self.current_selected_id: CustomNotificationDialog.show_warning(self, 'Warning!', 'Pilih data yang akan diedit!', 'OK') return try: # Set edit mode flag BEFORE switching tab self.edit_mode = True print(f"Edit mode set to: {self.edit_mode}") # Load data to form from the selected row selected_rows = self.table.selectedIndexes() print(f"Selected rows count: {len(selected_rows)}") if selected_rows: current_row = selected_rows[0].row() print(f"Loading data from row: {current_row}") self.load_selected_data_to_form(current_row, edit_mode=True) else: CustomNotificationDialog.show_warning(self, 'Warning!', 'Tidak ada baris yang dipilih!', 'OK') self.edit_mode = False return # Switch to Input Data tab (index 0) print("Switching to Input tab...") self.switch_tab(0) # Update status to indicate edit mode self.statusBar().showMessage(f'Mode Edit - Data ID {self.current_selected_id} dimuat untuk diedit') # Ensure predict button is disabled in edit mode if hasattr(self, 'btn_predict'): self.btn_predict.setEnabled(False) self.btn_predict.setToolTip("Gunakan tombol Update untuk memperbarui data yang dipilih") # Enable update button in edit mode if hasattr(self, 'btn_update'): self.btn_update.setEnabled(True) self.btn_update.setToolTip("Klik untuk update data dengan prediksi baru") print("Data loaded successfully, showing info dialog...") # Show info message - now using custom notification message = "Data telah dimuat ke form untuk diedit.\n\n" message += "Silakan ubah nilai yang diperlukan, kemudian:\n" message += "• Klik 'Update' untuk menyimpan perubahan dengan prediksi baru\n" message += "• Atau klik 'Clear Form' untuk kembali ke mode input baru" CustomNotificationDialog.show_success(self, "Mode Edit", message, "OK") except Exception as e: print(f"Error in edit_selected_data: {str(e)}") import traceback traceback.print_exc() CustomNotificationDialog.show_error(self, 'Error!', f'Gagal memuat data untuk edit:\n{str(e)}', 'OK') self.edit_mode = False #Fungsi Utama untuk Prediksi dan Validasi Simpan/Batal def predict_and_save_data(self): """Predict and show confirmation dialog before saving""" if not self.validate_form(): return if not self.classifier.is_trained: CustomNotificationDialog.show_warning(self, 'Warning!', 'Model belum dilatih!\nLatih model terlebih dahulu.', 'OK') return try: # mengambil nama siswa dari form input nama = self.nama_input.text().strip() # Extract nilai dari tabel (simpan semester 1, semester 2, dan rata-rata) nilai_dict = {} subject_keys = [ 'nilai_informatika', 'nilai_fisika', 'nilai_kimia', 'nilai_biologi', 'nilai_big_lanjut', 'nilai_ekonomi', 'nilai_mat_lanjut', 'nilai_sej_lanjut', 'nilai_sosiologi', 'nilai_geografi' ] if hasattr(self, 'nilai_table'): for row in range(min(len(subject_keys), self.nilai_table.rowCount())): key = subject_keys[row] # menyimpan nilai semester 1 (kolom 1) sem1_item = self.nilai_table.item(row, 1) sem1_value = self.parse_float_value(sem1_item.text()) if sem1_item else 0 # menyimpan nilai semester 2 (kolom 2) sem2_item = self.nilai_table.item(row, 2) sem2_value = self.parse_float_value(sem2_item.text()) if sem2_item else 0 # menyimpan rata-rata (kolom 3) rata_item = self.nilai_table.item(row, 3) rata_value = self.parse_float_value(rata_item.text()) if rata_item else 0 # untuk prediksi, kita akan menggunakan nilai rata-rata (kolom 3), tapi kita juga menyimpan semester 1 dan 2 untuk referensi di database nilai_dict[f'{key}_sem1'] = sem1_value nilai_dict[f'{key}_sem2'] = sem2_value nilai_dict[key] = rata_value # membuat prediksi dengan nilai rata-rata (kolom 3) untuk setiap mata pelajaran prediction_dict = {key: nilai_dict[key] for key in subject_keys if key in nilai_dict} prediction, top_3 = self.classifier.predict(prediction_dict) #menghasilkan jurusan # melihat hasil prediksi dengan informasi paket dan kelas yang tersedia paket_info = self.data_processor.get_paket_info(prediction) # untuk menampilkan hasil prediksi dengan opsi simpan atau batal, gunakan custom notification dialog from gui.custom_notifications import CustomPredictionDialog save_result = CustomPredictionDialog.show_prediction(self, nama, prediction, paket_info) # Hasil dari dialog akan menentukan apakah kita menyimpan data baru dengan prediksi atau membatalkan dan tetap di form input if save_result: # untuk menyimpan hasil prediksi sebagai data baru, kita akan menggunakan fungsi simpan_hasil_prediksi dari database handler simpan_hasil_prediksi(nama, nilai_dict, prediction) success_msg = "Data berhasil diprediksi dan disimpan sebagai data baru!" status_msg = 'Data berhasil disimpan sebagai data baru' # Refresh table and clear form self.load_table_data() self.clear_form() # Show success message CustomNotificationDialog.show_success(self, 'Berhasil!', success_msg, 'OK') self.statusBar().showMessage(status_msg) else: # Cancel - do nothing, stay on input form self.statusBar().showMessage('Prediksi dibatalkan') except Exception as e: CustomNotificationDialog.show_error(self, 'Error!', f'Gagal melakukan prediksi:\n{str(e)}', 'OK') #Fungsi Preview Prediksi def preview_prediction(self): """Preview prediction with option to save - works from both form input and selected table data""" if not self.classifier.is_trained: CustomNotificationDialog.show_warning(self, 'Warning!', 'Model belum dilatih!\nLatih model terlebih dahulu.', 'OK') return try: # Check if we're working with selected data from table or form input if hasattr(self, 'current_selected_id') and self.current_selected_id: # Working with selected data from table current_row = self.table.currentRow() if current_row < 0: CustomNotificationDialog.show_warning(self, 'Warning!', 'Pilih data dari tabel terlebih dahulu!', 'OK') return # Get data from table nama = self.table.item(current_row, 1).text() # Nama siswa di kolom 1 # Get values from table (columns 2-11 untuk mata pelajaran) # Use parse_float_value to support comma and dot format nilai_dict = { 'nilai_informatika': self.parse_float_value(self.table.item(current_row, 2).text()), 'nilai_fisika': self.parse_float_value(self.table.item(current_row, 3).text()), 'nilai_kimia': self.parse_float_value(self.table.item(current_row, 4).text()), 'nilai_biologi': self.parse_float_value(self.table.item(current_row, 5).text()), 'nilai_big_lanjut': self.parse_float_value(self.table.item(current_row, 6).text()), 'nilai_ekonomi': self.parse_float_value(self.table.item(current_row, 7).text()), 'nilai_mat_lanjut': self.parse_float_value(self.table.item(current_row, 8).text()), 'nilai_sej_lanjut': self.parse_float_value(self.table.item(current_row, 9).text()), 'nilai_sosiologi': self.parse_float_value(self.table.item(current_row, 10).text()), 'nilai_geografi': self.parse_float_value(self.table.item(current_row, 11).text()) } source_text = "dari data tabel yang dipilih" else: # Working with form input if not self.validate_form(): return # Get form data nama = self.nama_input.text().strip() # Extract nilai dari tabel (simpan semester 1, semester 2, dan rata-rata) nilai_dict = {} subject_keys = [ 'nilai_informatika', 'nilai_fisika', 'nilai_kimia', 'nilai_biologi', 'nilai_big_lanjut', 'nilai_ekonomi', 'nilai_mat_lanjut', 'nilai_sej_lanjut', 'nilai_sosiologi', 'nilai_geografi' ] if hasattr(self, 'nilai_table'): for row in range(min(len(subject_keys), self.nilai_table.rowCount())): key = subject_keys[row] # Get rata-rata (kolom 3) for prediction rata_item = self.nilai_table.item(row, 3) if rata_item: nilai_dict[key] = self.parse_float_value(rata_item.text()) source_text = "dari form input" # Make prediction prediction, top_3 = self.classifier.predict(nilai_dict) # Show prediction result paket_info = self.data_processor.get_paket_info(prediction) result_text = f"Preview Prediksi untuk {nama} ({source_text}):\n\n" result_text += f"Paket Terpilih: {prediction}\n" result_text += f"Detail: {paket_info.get('nama', 'Unknown')}" # Show preview with custom notification CustomNotificationDialog.show_success(self, "Preview Prediksi", result_text, "OK") # Just preview, don't save self.statusBar().showMessage('Preview selesai - data tidak disimpan') except Exception as e: import traceback traceback.print_exc() CustomNotificationDialog.show_error(self, 'Error!', f'Gagal melakukan preview prediksi:\n{str(e)}', 'OK') def show_performance(self): """Show performance report preview""" if not self.classifier.is_trained: # Train model first self.train_model_background() # Show performance report dialog dialog = PerformanceDialog(self.classifier, self) dialog.exec_() #Fungsi Validasi Form def validate_form(self): """Validate form inputs with enhanced validation""" nama = self.nama_input.text().strip() # Validasi nama siswa tidak boleh kosong if not nama: CustomNotificationDialog.show_warning(self, 'Validation', 'Nama siswa harus diisi!', 'OK') self.nama_input.setFocus() return False # Validasi nama siswa hanya boleh huruf, spasi, titik, tanda petik, dan tanda hubung import re if not re.match(r"^[a-zA-ZÀ-ÿ][a-zA-ZÀ-ÿ\s\.\-']*$", nama): CustomNotificationDialog.show_warning(self, 'Validation', 'Nama siswa hanya boleh berisi huruf, spasi, titik, tanda petik, dan tanda hubung!', 'OK') self.nama_input.setFocus() return False # Validasi panjang nama minimal 2 karakter dan maksimal 50 if len(nama) < 2: CustomNotificationDialog.show_warning(self, 'Validation', 'Nama siswa minimal 2 karakter!', 'OK') self.nama_input.setFocus() return False if len(nama) > 50: CustomNotificationDialog.show_warning(self, 'Validation', 'Nama siswa maksimal 50 karakter!', 'OK') self.nama_input.setFocus() return False # Validasi nama tidak boleh hanya spasi if nama.isspace(): CustomNotificationDialog.show_warning(self, 'Validation', 'Nama siswa tidak boleh hanya berisi spasi!', 'OK') self.nama_input.setFocus() return False # Validasi tidak boleh ada spasi berturut-turut if ' ' in nama: CustomNotificationDialog.show_warning(self, 'Validation', 'Nama siswa tidak boleh mengandung spasi berturut-turut!', 'OK') self.nama_input.setFocus() return False # Validasi WAJIB: Semua nilai mata pelajaran harus diisi (kedua semester atau salah satu) empty_subjects = [] subject_names = [ 'Informatika', 'Fisika', 'Kimia', 'Biologi', 'BIG Lanjut', 'Ekonomi', 'MAT Lanjut', 'SEJ Lanjut', 'Sosiologi', 'Geografi' ] if hasattr(self, 'nilai_table'): for row in range(self.nilai_table.rowCount()): sem1_item = self.nilai_table.item(row, 1) sem2_item = self.nilai_table.item(row, 2) mapel = subject_names[row] if row < len(subject_names) else f"Subject {row}" sem1_text = sem1_item.text().strip() if sem1_item else "" sem2_text = sem2_item.text().strip() if sem2_item else "" # Cek input kosong (kedua semester belum diisi) if sem1_text == "" and sem2_text == "": empty_subjects.append(mapel) # Jika ada mata pelajaran yang belum diisi if empty_subjects: if len(empty_subjects) == 1: CustomNotificationDialog.show_warning(self, 'Validasi Input', f"Nilai {empty_subjects[0]} harus diisi minimal satu semester!\n\n" f"Isi minimal Semester 1 atau Semester 2 untuk setiap mata pelajaran.", 'OK') else: subjects_text = ", ".join(empty_subjects[:-1]) + f" dan {empty_subjects[-1]}" CustomNotificationDialog.show_warning(self, 'Validasi Input', f"Nilai {subjects_text} harus diisi!\n\n" f"Isi minimal Semester 1 atau Semester 2 untuk setiap mata pelajaran.", 'OK') return False # Validasi nilai mata pelajaran dengan range yang tepat if hasattr(self, 'nilai_table'): for row in range(self.nilai_table.rowCount()): sem1_item = self.nilai_table.item(row, 1) sem2_item = self.nilai_table.item(row, 2) mapel = subject_names[row] if row < len(subject_names) else f"Subject {row}" sem1_text = sem1_item.text().strip() if sem1_item else "" sem2_text = sem2_item.text().strip() if sem2_item else "" if not self._is_valid_score_text(sem1_text): CustomNotificationDialog.show_warning(self, 'Validation', f'Nilai {mapel} Semester 1 harus berupa angka 0-100!', 'OK') return False if not self._is_valid_score_text(sem2_text): CustomNotificationDialog.show_warning(self, 'Validation', f'Nilai {mapel} Semester 2 harus berupa angka 0-100!', 'OK') return False sem1_value = self.parse_float_value(sem1_text) sem2_value = self.parse_float_value(sem2_text) # Validasi nilai dalam range 0-100 (jika diisi) if sem1_text and (sem1_value < 0 or sem1_value > 100): CustomNotificationDialog.show_warning(self, 'Validation', f'Nilai {mapel} Semester 1 harus antara 0-100!', 'OK') return False if sem2_text and (sem2_value < 0 or sem2_value > 100): CustomNotificationDialog.show_warning(self, 'Validation', f'Nilai {mapel} Semester 2 harus antara 0-100!', 'OK') return False # Validasi rata-rata nilai minimal total_nilai = 0 count_nilai = 0 if hasattr(self, 'nilai_table'): for row in range(self.nilai_table.rowCount()): rata_item = self.nilai_table.item(row, 3) if rata_item and rata_item.text() != 'otomatis': try: nilai = float(rata_item.text()) total_nilai += nilai count_nilai += 1 except ValueError: pass if count_nilai > 0: rata_rata = total_nilai / count_nilai else: rata_rata = 0 if rata_rata < 50: reply = CustomNotificationDialog.show_question(self, 'Konfirmasi', f"Rata-rata nilai ({rata_rata:.1f}) sangat rendah.\nApakah data sudah benar?", 'Ya, Benar', 'Cek Lagi') if not reply: return False return True def validate_nama_input(self, text): """Validate nama input in real-time""" # Filter out invalid characters but keep common punctuation found in dataset names import re filtered_text = re.sub(r"[^a-zA-ZÀ-ÿ\s\.\-']", '', text) # Remove multiple consecutive spaces filtered_text = re.sub(r' +', ' ', filtered_text) # If the text was changed, update the input if filtered_text != text: cursor_pos = self.nama_input.cursorPosition() self.nama_input.setText(filtered_text) # Restore cursor position (adjusted for removed characters) new_pos = min(cursor_pos, len(filtered_text)) self.nama_input.setCursorPosition(new_pos) def train_model_background(self): """Train model in background""" try: # Always train with current configured training dataset data_path = config.DATASET_PATH if os.path.exists(data_path): os.makedirs(os.path.dirname(config.MODEL_PATH), exist_ok=True) print(f"Training model with {data_path}") accuracy, _ = self.classifier.train( data_path, test_data_path=getattr(config, 'TEST_DATASET_PATH', None) ) self.classifier.save_model(config.MODEL_PATH) print(f"Model trained with accuracy: {accuracy:.2%}") self.statusBar().showMessage(f'Model trained - Accuracy: {accuracy:.2%}') else: print(f"Dataset not found: {data_path}") self.statusBar().showMessage(f'Dataset not found: {data_path}') except Exception as e: print(f"Training failed: {str(e)}") self.statusBar().showMessage(f'Training failed: {str(e)}') def refresh_table(self): """Refresh table to show all data""" self.current_page = 1 # Reset to first page self.load_table_data() self.statusBar().showMessage('Tabel berhasil dimuat ulang') def eventFilter(self, source, event): """Event filter to detect mouse clicks outside table""" if hasattr(self, 'nama_input') and source == self.nama_input and event.type() == QEvent.MouseButtonPress: if event.button() == Qt.LeftButton: self.open_student_name_picker() return True if event.type() == QEvent.MouseButtonPress: # Check if click happened outside the table if hasattr(self, 'table') and source == self.table.viewport(): # Get the position of the click relative to the table viewport pos = event.pos() item = self.table.itemAt(pos) # If click is in empty area of table (no item), clear selection if item is None: self.table.clearSelection() self.current_selected_id = None self.load_table_data() # Disable edit and preview buttons if hasattr(self, 'btn_edit'): self.btn_edit.setEnabled(False) self.btn_edit.setToolTip("Pilih data terlebih dahulu untuk mengedit") if hasattr(self, 'btn_preview'): self.btn_preview.setEnabled(False) self.btn_preview.setToolTip("Pilih data terlebih dahulu untuk melihat preview prediksi") self.statusBar().showMessage('Seleksi dibersihkan dan tabel direfresh') elif hasattr(self, 'table') and source != self.table: # Check if the click is outside the table area entirely if hasattr(source, 'geometry'): # Get global position of click global_pos = event.globalPos() # Get table geometry in global coordinates table_rect = self.table.geometry() table_global_pos = self.table.mapToGlobal(table_rect.topLeft()) table_global_rect = QRect(table_global_pos, table_rect.size()) # If click is outside table, clear selection if not table_global_rect.contains(global_pos): self.table.clearSelection() self.current_selected_id = None self.load_table_data() # Disable edit and preview buttons if hasattr(self, 'btn_edit'): self.btn_edit.setEnabled(False) self.btn_edit.setToolTip("Pilih data terlebih dahulu untuk mengedit") if hasattr(self, 'btn_preview'): self.btn_preview.setEnabled(False) self.btn_preview.setToolTip("Pilih data terlebih dahulu untuk melihat preview prediksi") self.statusBar().showMessage('Seleksi dibersihkan dan tabel direfresh') return super().eventFilter(source, event) def on_table_cell_clicked(self, row, column): """Handle cell click event by selecting the row.""" print(f"Cell clicked - Row: {row}, Column: {column}") # Ensure the row is valid and the table has data if row >= 0 and row < self.table.rowCount() and self.table.rowCount() > 0: print(f"Selecting row {row}") # Select the entire row - this will trigger itemSelectionChanged self.table.selectRow(row) # Force the selection to be processed immediately QApplication.processEvents() else: print(f"Invalid row clicked: {row} or table is empty") def export_to_excel(self): """Export data hasil prediksi ke file Excel dengan rata-rata nilai dan keterangan paket""" try: # Ambil semua data dari database data = ambil_semua_hasil_prediksi() if not data: CustomNotificationDialog.show_warning(self, 'Info', 'Tidak ada data untuk diekspor.\nTambahkan data terlebih dahulu.', 'OK') return # Konversi ke DataFrame df = pd.DataFrame(data) # Tentukan kolom yang akan diekspor (hanya rata-rata, tidak semester values) columns_to_keep = [ 'nama_siswa', 'nilai_informatika', 'nilai_fisika', 'nilai_kimia', 'nilai_biologi', 'nilai_big_lanjut', 'nilai_ekonomi', 'nilai_mat_lanjut', 'nilai_sej_lanjut', 'nilai_sosiologi', 'nilai_geografi', 'paket_prediksi' ] # Hapus kolom yang tidak diperlukan df = df[[col for col in columns_to_keep if col in df.columns]] # Tambahkan kolom No sebagai nomor urut di awal df.insert(0, 'No', range(1, len(df) + 1)) # Buat nama kolom yang lebih user-friendly column_mapping = { 'No': 'No', 'nama_siswa': 'Nama Siswa', 'nilai_informatika': 'Informatika', 'nilai_fisika': 'Fisika', 'nilai_kimia': 'Kimia', 'nilai_biologi': 'Biologi', 'nilai_big_lanjut': 'BIG Lanjut', 'nilai_ekonomi': 'Ekonomi', 'nilai_mat_lanjut': 'MAT Lanjut', 'nilai_sej_lanjut': 'SEJ Lanjut', 'nilai_sosiologi': 'Sosiologi', 'nilai_geografi': 'Geografi', 'paket_prediksi': 'Paket Prediksi' } # Rename kolom df = df.rename(columns=column_mapping) # Tambahkan kolom keterangan paket paket_info = { 1: '1 (Informatika,Fisika,Kimia,Biologi)', 2: '2 (Informatika,BIG Lanjut,Kimia,Biologi)', 3: '3 (Informatika,Ekonomi,BIG Lanjut,MAT Lanjut)', 4: '4 (Informatika,Ekonomi,MAT Lanjut,SEJ Lanjut)', 5: '5 (Informatika,Ekonomi,Sosiologi,SEJ Lanjut)', 6: '6 (Informatika,Ekonomi,Sosiologi,Geografi)' } df['Keterangan Paket'] = df['Paket Prediksi'].map(paket_info) # Hapus kolom Paket Prediksi karena sudah ada Keterangan Paket if 'Paket Prediksi' in df.columns: df = df.drop(columns=['Paket Prediksi']) # Tampilkan nilai numerik tanpa suffix .00, tetapi tetap simpan 2 desimal # bila nilai memang memiliki pecahan. score_columns = [ 'Informatika', 'Fisika', 'Kimia', 'Biologi', 'BIG Lanjut', 'Ekonomi', 'MAT Lanjut', 'SEJ Lanjut', 'Sosiologi', 'Geografi' ] for column_name in score_columns: if column_name in df.columns: df[column_name] = df[column_name].apply(self.format_nilai) # Dialog untuk memilih lokasi penyimpanan from datetime import datetime timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') file_path, _ = QFileDialog.getSaveFileName( self, "Simpan File Excel", f"Hasil_Prediksi_Jurusan_{timestamp}.xlsx", "Excel Files (*.xlsx);;All Files (*)" ) if file_path: # Pastikan file path berakhiran .xlsx if not file_path.endswith('.xlsx'): file_path += '.xlsx' # Export ke Excel dengan formatting with pd.ExcelWriter(file_path, engine='openpyxl') as writer: df.to_excel(writer, sheet_name='Hasil Prediksi', index=False) # Get worksheet untuk formatting worksheet = writer.sheets['Hasil Prediksi'] # Format header dengan openpyxl from openpyxl.styles import Font, PatternFill, Border, Side, Alignment # Define styles dengan border hitam yang tebal header_font = Font(bold=True, size=11) header_fill = PatternFill(start_color="D7E4BC", end_color="D7E4BC", fill_type="solid") # Gunakan medium border untuk semua garis tabel black_border = Border( left=Side(style='medium', color='000000'), right=Side(style='medium', color='000000'), top=Side(style='medium', color='000000'), bottom=Side(style='medium', color='000000') ) center_alignment = Alignment(horizontal='center', vertical='center') left_alignment = Alignment(horizontal='left', vertical='center') # Apply header formatting - baris pertama for col in worksheet[1]: col.font = header_font col.fill = header_fill col.border = black_border col.alignment = center_alignment # Kolom yang perlu center alignment di data center_columns = ['No', 'Informatika', 'Fisika', 'Kimia', 'Biologi', 'BIG Lanjut', 'Ekonomi', 'MAT Lanjut', 'SEJ Lanjut', 'Sosiologi', 'Geografi'] # Get column indices untuk alignment header_row = worksheet[1] column_indices = {} for idx, cell in enumerate(header_row, 1): if cell.value in center_columns: column_indices[cell.value] = idx # Apply formatting ke semua data cells for row_num in range(2, worksheet.max_row + 1): for col_num in range(1, worksheet.max_column + 1): cell = worksheet.cell(row=row_num, column=col_num) # Apply black border ke semua cell cell.border = black_border # Get header value untuk cell ini header_cell = worksheet.cell(row=1, column=col_num) if header_cell.value in center_columns: cell.alignment = center_alignment else: cell.alignment = left_alignment # Auto-adjust column width for column in worksheet.columns: max_length = 0 column_letter = column[0].column_letter for cell in column: try: if len(str(cell.value)) > max_length: max_length = len(str(cell.value)) except: pass adjusted_width = min(max_length + 2, 50) worksheet.column_dimensions[column_letter].width = adjusted_width CustomNotificationDialog.show_success( self, 'Sukses!', f"Data berhasil diekspor ke:\n{file_path}\n\nTotal {len(df)} data diekspor.", 'OK' ) self.statusBar().showMessage(f'Data berhasil diekspor ke Excel: {len(df)} records') except Exception as e: CustomNotificationDialog.show_error(self, 'Error!', f'Gagal mengekspor data:\n{str(e)}', 'OK') self.statusBar().showMessage(f'Export gagal: {str(e)}') class PerformanceDialog(QDialog): def __init__(self, classifier, parent=None): super().__init__(parent) self.classifier = classifier self.initUI() def initUI(self): self.setWindowTitle('Model Performance Report') self.setGeometry(100, 100, 1200, 900) # Increased height for vertical layout # Main vertical layout main_layout = QVBoxLayout() main_layout.setSpacing(15) main_layout.setContentsMargins(20, 20, 20, 20) # Title title_label = QLabel("📊 Model Performance Report") title_label.setAlignment(Qt.AlignCenter) title_label.setStyleSheet(""" font-weight: bold; font-size: 18px; margin-bottom: 15px; color: #2c3e50; background-color: #ecf0f1; padding: 10px; border-radius: 5px; """) main_layout.addWidget(title_label) # Content layout (vertical with 3 sections) content_layout = QVBoxLayout() content_layout.setSpacing(20) # Section 1: Confusion Matrix + Explanation confusion_section = QHBoxLayout() confusion_section.setSpacing(15) # Left - Confusion Matrix confusion_left = QWidget() confusion_left.setFixedWidth(500) confusion_left_layout = QVBoxLayout(confusion_left) confusion_left_layout.setContentsMargins(10, 10, 10, 10) confusion_label = QLabel("🔄 Confusion Matrix") confusion_label.setAlignment(Qt.AlignCenter) confusion_label.setStyleSheet(""" font-weight: bold; font-size: 14px; margin-bottom: 10px; color: #34495e; background-color: #e8f4fd; padding: 8px; border-radius: 3px; """) confusion_left_layout.addWidget(confusion_label) # Create matplotlib figure for confusion matrix self.figure = Figure(figsize=(5, 4), dpi=80) self.figure.patch.set_facecolor('white') self.canvas = FigureCanvas(self.figure) self.canvas.setMinimumHeight(320) confusion_left_layout.addWidget(self.canvas) # Right - Confusion Matrix Explanation confusion_right = QWidget() confusion_right_layout = QVBoxLayout(confusion_right) confusion_right_layout.setContentsMargins(10, 10, 10, 10) confusion_exp_label = QLabel("📖 Penjelasan Confusion Matrix") confusion_exp_label.setAlignment(Qt.AlignCenter) confusion_exp_label.setStyleSheet(""" font-weight: bold; font-size: 14px; margin-bottom: 10px; color: #34495e; background-color: #f0f8ff; padding: 8px; border-radius: 3px; """) confusion_right_layout.addWidget(confusion_exp_label) self.confusion_explanation = QTextEdit() self.confusion_explanation.setReadOnly(True) self.confusion_explanation.setFont(QFont("Segoe UI", 10)) self.confusion_explanation.setMaximumHeight(350) self.confusion_explanation.setStyleSheet(""" QTextEdit { background-color: #f8f9fa; border: 2px solid #dee2e6; border-radius: 5px; padding: 12px; font-family: 'Segoe UI', Arial, sans-serif; line-height: 1.4; } """) confusion_right_layout.addWidget(self.confusion_explanation) confusion_section.addWidget(confusion_left) confusion_section.addWidget(confusion_right) content_layout.addLayout(confusion_section) # Section 2: ROC Curves + Explanation roc_section = QHBoxLayout() roc_section.setSpacing(15) # Left - ROC Curves roc_left = QWidget() roc_left.setFixedWidth(500) roc_left_layout = QVBoxLayout(roc_left) roc_left_layout.setContentsMargins(10, 10, 10, 10) roc_label = QLabel("📈 ROC AUC Curves") roc_label.setAlignment(Qt.AlignCenter) roc_label.setStyleSheet(""" font-weight: bold; font-size: 14px; margin-bottom: 10px; color: #34495e; background-color: #fff3e0; padding: 8px; border-radius: 3px; """) roc_left_layout.addWidget(roc_label) # Create matplotlib figure for ROC curve self.roc_figure = Figure(figsize=(5, 4), dpi=80) self.roc_figure.patch.set_facecolor('white') self.roc_canvas = FigureCanvas(self.roc_figure) self.roc_canvas.setMinimumHeight(320) roc_left_layout.addWidget(self.roc_canvas) # Right - ROC Explanation roc_right = QWidget() roc_right_layout = QVBoxLayout(roc_right) roc_right_layout.setContentsMargins(10, 10, 10, 10) roc_exp_label = QLabel("📖 Penjelasan ROC AUC") roc_exp_label.setAlignment(Qt.AlignCenter) roc_exp_label.setStyleSheet(""" font-weight: bold; font-size: 14px; margin-bottom: 10px; color: #34495e; background-color: #fffaf0; padding: 8px; border-radius: 3px; """) roc_right_layout.addWidget(roc_exp_label) self.roc_explanation = QTextEdit() self.roc_explanation.setReadOnly(True) self.roc_explanation.setFont(QFont("Segoe UI", 10)) self.roc_explanation.setMaximumHeight(350) self.roc_explanation.setStyleSheet(""" QTextEdit { background-color: #f8f9fa; border: 2px solid #dee2e6; border-radius: 5px; padding: 12px; font-family: 'Segoe UI', Arial, sans-serif; line-height: 1.4; } """) roc_right_layout.addWidget(self.roc_explanation) roc_section.addWidget(roc_left) roc_section.addWidget(roc_right) content_layout.addLayout(roc_section) # Section 3: Classification Report + Explanation class_section = QHBoxLayout() class_section.setSpacing(15) # Left - Classification Report class_left = QWidget() class_left.setFixedWidth(500) class_left_layout = QVBoxLayout(class_left) class_left_layout.setContentsMargins(10, 10, 10, 10) class_label = QLabel("📋 Classification Report") class_label.setAlignment(Qt.AlignCenter) class_label.setStyleSheet(""" font-weight: bold; font-size: 14px; margin-bottom: 10px; color: #34495e; background-color: #e8f6f3; padding: 8px; border-radius: 3px; """) class_left_layout.addWidget(class_label) # Text area for classification report self.report_text = QTextEdit() self.report_text.setReadOnly(True) self.report_text.setFont(QFont("Consolas", 10)) self.report_text.setMaximumHeight(300) self.report_text.setStyleSheet(""" QTextEdit { background-color: #f8f9fa; border: 2px solid #dee2e6; border-radius: 5px; padding: 12px; font-family: 'Consolas', 'Monaco', monospace; line-height: 1.3; } """) class_left_layout.addWidget(self.report_text) # Right - Classification Report Explanation class_right = QWidget() class_right_layout = QVBoxLayout(class_right) class_right_layout.setContentsMargins(10, 10, 10, 10) class_exp_label = QLabel("📖 Penjelasan Classification Report") class_exp_label.setAlignment(Qt.AlignCenter) class_exp_label.setStyleSheet(""" font-weight: bold; font-size: 14px; margin-bottom: 10px; color: #34495e; background-color: #f0fff0; padding: 8px; border-radius: 3px; """) class_right_layout.addWidget(class_exp_label) self.class_explanation = QTextEdit() self.class_explanation.setReadOnly(True) self.class_explanation.setFont(QFont("Segoe UI", 10)) self.class_explanation.setMaximumHeight(300) self.class_explanation.setStyleSheet(""" QTextEdit { background-color: #f8f9fa; border: 2px solid #dee2e6; border-radius: 5px; padding: 12px; font-family: 'Segoe UI', Arial, sans-serif; line-height: 1.4; } """) class_right_layout.addWidget(self.class_explanation) class_section.addWidget(class_left) class_section.addWidget(class_right) content_layout.addLayout(class_section) # Add content to main layout main_layout.addLayout(content_layout) # Close button at bottom btn_layout = QHBoxLayout() btn_layout.addStretch() btn_close = QPushButton("✖️ Close") btn_close.setFixedSize(120, 40) btn_close.clicked.connect(self.close) btn_close.setStyleSheet(""" QPushButton { background-color: #e74c3c; color: white; padding: 10px 20px; font-weight: bold; border-radius: 8px; font-size: 12px; border: none; } QPushButton:hover { background-color: #c0392b; } QPushButton:pressed { background-color: #a93226; } """) btn_layout.addWidget(btn_close) btn_layout.addStretch() main_layout.addLayout(btn_layout) self.setLayout(main_layout) # Generate performance report self.generate_performance_report() def generate_performance_report(self): """Generate confusion matrix and classification report""" try: from sklearn.metrics import confusion_matrix, classification_report import numpy as np # Get test data predictions data_path = config.DATASET_PATH if os.path.exists(data_path): test_path = getattr(config, 'TEST_DATASET_PATH', None) if test_path and os.path.exists(test_path): X_test, y_test = self.classifier.prepare_data(test_path) else: X, y = self.classifier.prepare_data(data_path) from sklearn.model_selection import train_test_split _, X_test, _, y_test = train_test_split( X, y, test_size=config.TEST_SIZE, random_state=config.RANDOM_STATE, stratify=y ) # Scale test data X_test_scaled = self.classifier.scaler.transform(X_test) y_pred = self.classifier.knn.predict(X_test_scaled) y_pred_proba = self.classifier.knn.predict_proba(X_test_scaled) # Generate confusion matrix cm = confusion_matrix(y_test, y_pred) self.plot_confusion_matrix(cm, sorted(set(y_test))) self.display_confusion_matrix_explanation() # Generate ROC curve self.plot_roc_curve(y_test, y_pred_proba) self.display_roc_explanation() # Generate classification report report = classification_report(y_test, y_pred, output_dict=True, zero_division=0) self.display_classification_report(report) else: self.show_error("No training data available") except Exception as e: self.show_error(f"Error generating performance report: {str(e)}") def plot_confusion_matrix(self, cm, classes): """Plot confusion matrix""" # Clear previous plot self.figure.clear() ax = self.figure.add_subplot(111) im = ax.imshow(cm, interpolation='nearest', cmap=plt.cm.Blues, aspect='auto') # Add colorbar with proper sizing cbar = self.figure.colorbar(im, ax=ax, shrink=0.8) cbar.ax.tick_params(labelsize=9) # Set labels with better formatting ax.set_xticks(np.arange(cm.shape[1])) ax.set_yticks(np.arange(cm.shape[0])) ax.set_xticklabels(classes, fontsize=9) ax.set_yticklabels(classes, fontsize=9) # Set axis labels ax.set_ylabel('True Labels', fontsize=11, fontweight='bold') ax.set_xlabel('Predicted Labels', fontsize=11, fontweight='bold') # Rotate the tick labels and set their alignment plt.setp(ax.get_xticklabels(), rotation=45, ha="right", rotation_mode="anchor") # Add text annotations with better formatting thresh = cm.max() / 2. for i in range(cm.shape[0]): for j in range(cm.shape[1]): text_color = "white" if cm[i, j] > thresh else "black" ax.text(j, i, format(cm[i, j], 'd'), ha="center", va="center", color=text_color, fontweight='bold', fontsize=10) # Set title with better spacing ax.set_title("Confusion Matrix", fontweight='bold', fontsize=12, pad=15) # Adjust layout to prevent clipping self.figure.subplots_adjust(left=0.15, bottom=0.2, right=0.85, top=0.9) self.canvas.draw() def plot_roc_curve(self, y_test, y_pred_proba): """Plot ROC AUC curve for performance dialog""" try: # Clear previous plot self.roc_figure.clear() # Get unique classes from classifier classes = self.classifier.knn.classes_ n_classes = len(classes) # Handle single class case if n_classes == 2: # For binary classification, use probability of positive class y_score = y_pred_proba[:, 1] y_true = (y_test == classes[1]).astype(int) # Compute ROC curve and ROC area fpr, tpr, _ = roc_curve(y_true, y_score) roc_auc = auc(fpr, tpr) # Plot ROC curve ax = self.roc_figure.add_subplot(111) ax.plot(fpr, tpr, color='darkorange', lw=2, label=f'ROC curve (AUC = {roc_auc:.3f})') ax.plot([0, 1], [0, 1], color='navy', lw=2, linestyle='--', label='Random Classifier') ax.set_xlim([0.0, 1.0]) ax.set_ylim([0.0, 1.05]) ax.set_xlabel('False Positive Rate', fontsize=9) ax.set_ylabel('True Positive Rate', fontsize=9) ax.set_title('ROC Curve (Binary)', fontweight='bold', fontsize=10) ax.legend(loc="lower right", fontsize=8) ax.grid(True, alpha=0.3) else: # Multi-class ROC ax = self.roc_figure.add_subplot(111) # Compute ROC curve and ROC area for each class fpr = dict() tpr = dict() roc_auc = dict() for i in range(n_classes): # Get true binary labels for this class y_true_class = (y_test == classes[i]).astype(int) # Get predicted probabilities for this class y_score_class = y_pred_proba[:, i] fpr[i], tpr[i], _ = roc_curve(y_true_class, y_score_class) roc_auc[i] = auc(fpr[i], tpr[i]) # Plot ROC curve for each class colors = ['blue', 'red', 'green', 'orange', 'purple', 'brown'] for i in range(n_classes): color = colors[i % len(colors)] ax.plot(fpr[i], tpr[i], color=color, lw=1.5, label=f'Paket {classes[i]} (AUC = {roc_auc[i]:.3f})') # Plot random classifier line ax.plot([0, 1], [0, 1], 'k--', lw=1.5, label='Random', alpha=0.8) ax.set_xlim([0.0, 1.0]) ax.set_ylim([0.0, 1.05]) ax.set_xlabel('False Positive Rate', fontsize=9) ax.set_ylabel('True Positive Rate', fontsize=9) ax.set_title('Multi-class ROC Curves', fontweight='bold', fontsize=10) # Position legend to avoid overlap ax.legend(loc="center left", bbox_to_anchor=(1, 0.5), fontsize=7) ax.grid(True, alpha=0.3) # Adjust layout self.roc_figure.subplots_adjust(left=0.15, bottom=0.15, right=0.7, top=0.9) self.roc_canvas.draw() except Exception as e: print(f"Error plotting ROC curve: {e}") # Show error in ROC canvas ax = self.roc_figure.add_subplot(111) ax.text(0.5, 0.5, f'Error plotting ROC curve:\\n{str(e)}', ha='center', va='center', transform=ax.transAxes, fontsize=9, color='red') ax.set_title("ROC Plot Error", fontweight='bold') self.roc_canvas.draw() def display_confusion_matrix_explanation(self): """Display explanation for confusion matrix""" explanation_text = """ 🔍 CONFUSION MATRIX - Analisis Prediksi vs Aktual Confusion Matrix adalah tabel yang menunjukkan performa klasifikasi dengan membandingkan prediksi model terhadap label aktual. 📊 INTERPRETASI: • Baris (True Labels): Kelas sebenarnya dari data • Kolom (Predicted Labels): Kelas yang diprediksi oleh model • Diagonal utama: Prediksi yang BENAR • Di luar diagonal: Prediksi yang SALAH 🎯 METRIK PENTING: • True Positive (TP): Prediksi benar untuk kelas positif • True Negative (TN): Prediksi benar untuk kelas negatif • False Positive (FP): Salah prediksi sebagai positif • False Negative (FN): Salah prediksi sebagai negatif 💡 CARA MEMBACA: • Nilai tinggi di diagonal = performa bagus • Nilai tinggi di luar diagonal = banyak kesalahan • Semakin gelap warna biru = semakin banyak prediksi ✅ TARGET IDEAL: Confusion matrix yang baik memiliki nilai tinggi di diagonal utama dan nilai rendah di tempat lain, menunjukkan model dapat membedakan kelas dengan akurat. """ self.confusion_explanation.setPlainText(explanation_text.strip()) def display_roc_explanation(self): """Display explanation for ROC curves""" explanation_text = """ 📈 ROC AUC CURVES - Evaluasi Performa Klasifikasi ROC (Receiver Operating Characteristic) Curve menunjukkan performa model klasifikasi pada berbagai threshold dengan plot TPR vs FPR. 📊 KOMPONEN KURVA: • True Positive Rate (TPR/Recall): TP/(TP+FN) • False Positive Rate (FPR): FP/(FP+TN) • AUC (Area Under Curve): Luas di bawah kurva ROC 🎯 INTERPRETASI AUC: • AUC = 1.0: Klasifikasi sempurna • AUC = 0.9-1.0: Performa sangat baik • AUC = 0.8-0.9: Performa baik • AUC = 0.7-0.8: Performa sedang • AUC = 0.6-0.7: Performa buruk • AUC = 0.5: Tidak lebih baik dari tebakan acak 📍 ANALISIS KURVA: • Kurva mendekati sudut kiri atas = baik • Kurva mendekati garis diagonal = buruk • Garis putus-putus = Random Classifier (baseline) 🏆 MULTI-CLASS: Untuk klasifikasi multi-class, setiap paket memiliki kurva ROC tersendiri dengan strategi One-vs-All. ✅ TARGET IDEAL: AUC > 0.8 untuk setiap kelas menunjukkan model dapat membedakan kelas dengan baik. """ self.roc_explanation.setPlainText(explanation_text.strip()) def display_classification_report(self, report_dict): """Display classification report in table format similar to sklearn output""" # Create formatted text report with better alignment header = f"{'':>15} {'precision':>12} {'recall':>12} {'f1-score':>12} {'support':>12}\n" separator = "=" * 75 + "\n" report_text = header + separator # Add metrics for each class (sorted by class number) classes = [key for key in report_dict.keys() if key not in ['accuracy', 'macro avg', 'weighted avg']] # Sort classes numerically if they are numbers, otherwise alphabetically try: sorted_classes = sorted(classes, key=lambda x: int(x)) except (ValueError, TypeError): sorted_classes = sorted(classes) for class_name in sorted_classes: metrics = report_dict[class_name] class_label = f"Paket {class_name}" if str(class_name).isdigit() else str(class_name) report_text += f"{class_label:>15} {metrics['precision']:>12.2f} {metrics['recall']:>12.2f} {metrics['f1-score']:>12.2f} {int(metrics['support']):>12}\n" # Add separator line report_text += "\n" + "-" * 75 + "\n" # Add accuracy accuracy = report_dict['accuracy'] total_support = sum(report_dict[key]['support'] for key in classes) report_text += f"{'accuracy':>15} {'':>12} {'':>12} {accuracy:>12.2f} {int(total_support):>12}\n" # Add macro avg macro_avg = report_dict['macro avg'] report_text += f"{'macro avg':>15} {macro_avg['precision']:>12.2f} {macro_avg['recall']:>12.2f} {macro_avg['f1-score']:>12.2f} {int(macro_avg['support']):>12}\n" # Add weighted avg weighted_avg = report_dict['weighted avg'] report_text += f"{'weighted avg':>15} {weighted_avg['precision']:>12.2f} {weighted_avg['recall']:>12.2f} {weighted_avg['f1-score']:>12.2f} {int(weighted_avg['support']):>12}\n" # Add some explanation at the bottom report_text += "\n" + "=" * 75 + "\n" report_text += "\nPenjelasan:\n" report_text += "• Precision: Proporsi prediksi positif yang benar\n" report_text += "• Recall: Proporsi kelas aktual yang diprediksi dengan benar\n" report_text += "• F1-score: Rata-rata harmonik dari precision dan recall\n" report_text += "• Support: Jumlah sampel untuk setiap kelas\n" report_text += "• Macro avg: Rata-rata tanpa mempertimbangkan ketidakseimbangan kelas\n" report_text += "• Weighted avg: Rata-rata dengan mempertimbangkan support setiap kelas" self.report_text.setPlainText(report_text) # Also display detailed explanation self.display_classification_explanation() def display_classification_explanation(self): """Display detailed explanation for classification report""" explanation_text = """ 📋 CLASSIFICATION REPORT - Analisis Detail Performa Classification Report memberikan metrik evaluasi yang komprehensif untuk setiap kelas dalam klasifikasi multi-class. 🎯 METRIK UTAMA: 1️⃣ PRECISION (Ketepatan) • Formula: TP / (TP + FP) • Arti: Dari semua yang diprediksi positif, berapa yang benar-benar positif? • Tinggi = sedikit False Positive 2️⃣ RECALL (Sensitivitas/TPR) • Formula: TP / (TP + FN) • Arti: Dari semua yang sebenarnya positif, berapa yang berhasil diprediksi? • Tinggi = sedikit False Negative 3️⃣ F1-SCORE (Keseimbangan) • Formula: 2 × (Precision × Recall) / (Precision + Recall) • Arti: Rata-rata harmonik precision dan recall • Baik ketika precision dan recall seimbang tinggi 📊 RATA-RATA: ▶️ MACRO AVERAGE: • Rata-rata sederhana dari semua kelas • Tidak mempertimbangkan ketidakseimbangan data • Memberikan bobot sama untuk setiap kelas ▶️ WEIGHTED AVERAGE: • Rata-rata berbobot berdasarkan support • Mempertimbangkan jumlah sampel per kelas • Lebih representatif untuk data tidak seimbang ✅ INTERPRETASI SKOR: • 0.9-1.0: Sangat Baik • 0.8-0.9: Baik • 0.7-0.8: Cukup Baik • 0.6-0.7: Perlu Perbaikan • <0.6: Kurang Baik """ self.class_explanation.setPlainText(explanation_text.strip()) def show_error(self, message): """Show error message""" ax = self.figure.add_subplot(111) ax.text(0.5, 0.5, message, ha='center', va='center', transform=ax.transAxes) self.canvas.draw() ax2 = self.roc_figure.add_subplot(111) ax2.text(0.5, 0.5, message, ha='center', va='center', transform=ax2.transAxes) self.roc_canvas.draw() self.report_text.setPlainText(f"Error: {message}") self.confusion_explanation.setPlainText(f"Error: {message}") self.roc_explanation.setPlainText(f"Error: {message}") self.class_explanation.setPlainText(f"Error: {message}")