import sys
import os
import time
from functools import partial
from PySide6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QCheckBox,
                               QGroupBox, QMenu, QLabel, QFrame, QPushButton,
                               QGraphicsLineItem, QGraphicsRectItem, QGraphicsEllipseItem, QGraphicsTextItem, QGraphicsItem,
                               QDialog, QFormLayout, QSpinBox, QDialogButtonBox, QColorDialog, QApplication, QComboBox, QTextEdit, QMessageBox, QLineEdit,
                               QDateEdit)
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QValueAxis, QDateTimeAxis
from PySide6.QtCore import Qt, Signal, QPointF, QDateTime, QMargins, QRectF, QDate
from PySide6.QtGui import QPainter, QPen, QColor, QFont, QBrush, QPolygonF, QCursor, QAction, QFontMetrics, QTextCharFormat

# 동기화 모듈 (없으면 더미 처리)
try:
    from app.core.sync_controller import sync_manager
except ImportError:
    class MockSyncManager:
        def request_sync(self, index, data, panel_id): pass
        def request_x_range_sync(self, min_ms, max_ms, panel_id): pass
        class _DummySignal:
            def connect(self, *_args, **_kwargs): pass
        x_range_changed = _DummySignal()
    sync_manager = MockSyncManager()

# --- [1. 역정보 매핑 테이블 (부산 1호선 - stationcode.cs 기준)] ---
STATION_CODE_MAP = {
    # 특수 코드
    89: "신평(TC2)", 90: "신평(TC1)",
    # 다대포 방면 (95~101)
    95: "다대포해수욕장", 96: "다대포항", 97: "낫개", 98: "신장림", 99: "장림",
    100: "동매", 101: "신평",
    # 본선 (102~135)
    102: "하단", 103: "당리", 104: "사하", 105: "괴정", 106: "대티",
    107: "서대신", 108: "동대신", 109: "토성", 110: "자갈치", 111: "남포",
    112: "중앙", 113: "부산역", 114: "초량", 115: "부산진", 116: "좌천",
    117: "범일", 118: "범내골", 119: "서면", 120: "부전", 121: "양정",
    122: "시청", 123: "연산", 124: "교대", 125: "동래", 126: "명륜",
    127: "온천장", 128: "부산대", 129: "장전", 130: "구서", 131: "두실",
    132: "남산", 133: "범어사", 134: "노포",
}

# --- [2. 커스텀 그리기 도구 아이템들] ---
class DraggableItemMixin:
    """드래그 및 우클릭 메뉴 공통 기능"""
    def __init__(self):
        self.current_color = QColor(255, 0, 0, 100)  # 빨간색 75% 투명도
        self.current_width = 2
        self.fill_color = QColor(255, 0, 0, 100)  # 채우기 색상 (기본값)
        self.fill_opacity = 100  # 채우기 투명도 (75%)
        self.timestamp = None  # 시간 고정용 타임스탬프
        self.y_value = None    # Y축 값 고정용

    def contextMenuEvent(self, event):
        menu = QMenu()
        action_color = menu.addAction("색상 변경")
        action_width = menu.addAction("선 굵기 변경")
        menu.addSeparator()
        action_delete = menu.addAction("삭제")
        
        selected = menu.exec(event.screenPos())
        
        if selected == action_color:
            c = QColorDialog.getColor(self.current_color, None, "색상 선택")
            if c.isValid():
                self.current_color = c
                self.update_appearance()
        elif selected == action_width:
            # 간단한 입력 다이얼로그 대용 (실제로는 커스텀 다이얼로그 권장)
            self.current_width = (self.current_width % 5) + 1 # 1~5 순환
            self.update_appearance()
        elif selected == action_delete:
            self.scene().removeItem(self)

    def update_appearance(self):
        pass

    def mouseDoubleClickEvent(self, event):
        """더블클릭 시 속성 다이얼로그 표시"""
        dialog = ShapePropertiesDialog(self)
        if dialog.exec():
            self.update_appearance()
        super().mouseDoubleClickEvent(event)

    def itemChange(self, change, value):
        """아이템 변경 이벤트 - X축은 시간에 고정, Y축은 값에 고정"""
        if change == QGraphicsItem.ItemPositionChange and hasattr(self, 'timestamp') and self.timestamp is not None:
            # X축은 시간에 고정
            if hasattr(self.scene(), 'parent_view') and self.scene().parent_view:
                chart = self.scene().parent_view.chart
                # 시간 값을 픽셀 좌표로 변환
                pixel_x = chart.mapToPosition(QPointF(self.timestamp, 0)).x()

                # 타임라인의 경우 Y축은 자유롭게 움직임
                if isinstance(self, CustomTimelineItem):
                    return QPointF(pixel_x, value.y())
                # 다른 객체들은 Y축도 값에 고정
                elif hasattr(self, 'y_value') and self.y_value is not None:
                    pixel_pos = chart.mapToPosition(QPointF(self.timestamp, self.y_value))
                    return QPointF(pixel_x, value.y())
                else:
                    return QPointF(pixel_x, value.y())
        return super().itemChange(change, value)

class CustomLineItem(QGraphicsLineItem, DraggableItemMixin):
    def __init__(self, x, y, height, parent=None):
        QGraphicsLineItem.__init__(self, parent)
        DraggableItemMixin.__init__(self)
        self.setFlags(QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable | QGraphicsItem.ItemSendsGeometryChanges)
        self.setLine(x, y, x, y + height)
        self.update_appearance()
        
    def update_appearance(self):
        self.setPen(QPen(self.current_color, self.current_width, Qt.DashLine))

class CustomTimelineItem(QGraphicsLineItem, DraggableItemMixin):
    def __init__(self, x, y, height, timestamp, parent=None):
        QGraphicsLineItem.__init__(self, parent)
        DraggableItemMixin.__init__(self)
        self.setFlags(QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable | QGraphicsItem.ItemSendsGeometryChanges)

        self.timestamp = timestamp
        self.y_value = 0  # 타임라인은 Y축 고정 필요 없음

        # 시간 라벨 생성
        self.time_label = QGraphicsTextItem(self)

        self.setLine(x, y, x, y + height)
        self.update_appearance()

    def update_appearance(self):
        # 설정된 색상과 굵기로 선 그리기
        self.setPen(QPen(self.current_color, self.current_width))

    def update_time_label(self):
        """시간 라벨 업데이트"""
        # 타임스탬프를 시간으로 변환
        dt = QDateTime.fromMSecsSinceEpoch(int(self.timestamp))
        time_str = dt.toString("HH:mm:ss")

        # 라벨 설정
        self.time_label.setPlainText(time_str)
        self.time_label.setDefaultTextColor(QColor("#2196F3"))
        self.time_label.setFont(QFont("Malgun Gothic", 8, QFont.Bold))

        # 라벨 위치 설정 (선 상단 중앙)
        label_rect = self.time_label.boundingRect()
        label_x = self.line().x1() - label_rect.width() / 2
        label_y = self.line().y1() - label_rect.height() - 2
        self.time_label.setPos(label_x, label_y)

    def setLine(self, x1, y1, x2, y2):
        """선 설정 및 라벨 위치 업데이트"""
        super().setLine(x1, y1, x2, y2)
        self.update_time_label()

    def mouseDoubleClickEvent(self, event):
        """더블클릭 시 타임라인 속성 다이얼로그"""
        dialog = TimelinePropertiesDialog(self)
        if dialog.exec():
            self.update_appearance()
        super().mouseDoubleClickEvent(event)

class CustomRectItem(QGraphicsRectItem, DraggableItemMixin):
    def __init__(self, x, y, w, h, parent=None):
        QGraphicsRectItem.__init__(self, parent)
        DraggableItemMixin.__init__(self)
        self.setFlags(QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable | QGraphicsItem.ItemSendsGeometryChanges)
        self.setRect(x, y, w, h)
        self.current_color = QColor(255, 0, 0, 255)  # 빨간 테두리
        self.fill_color = QColor(255, 0, 0, 100)  # 빨간 채우기 (75% 투명도)

        self.update_appearance()

    def update_appearance(self):
        self.setPen(QPen(self.current_color, self.current_width))
        self.setBrush(QBrush(self.fill_color))

class CustomEllipseItem(QGraphicsEllipseItem, DraggableItemMixin):
    def __init__(self, x, y, w, h, parent=None):
        QGraphicsEllipseItem.__init__(self, parent)
        DraggableItemMixin.__init__(self)
        self.setFlags(QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable | QGraphicsItem.ItemSendsGeometryChanges)
        self.setRect(x, y, w, h)
        self.current_color = QColor(255, 0, 0, 255)  # 빨간 테두리
        self.fill_color = QColor(255, 0, 0, 100)  # 빨간 채우기 (75% 투명도)

        self.update_appearance()

    def update_appearance(self):
        self.setPen(QPen(self.current_color, self.current_width))
        self.setBrush(QBrush(self.fill_color))


class CustomTextItem(QGraphicsTextItem, DraggableItemMixin):
    def __init__(self, x, y, text="텍스트", parent=None):
        QGraphicsTextItem.__init__(self, parent)
        DraggableItemMixin.__init__(self)
        self.setFlags(QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable | QGraphicsItem.ItemSendsGeometryChanges)
        self.setPos(x, y)
        self.setPlainText(text)

        # 텍스트 속성 초기화
        self.text_color = QColor("white")
        self.font_family = "Malgun Gothic"
        self.font_size = 12
        self.font_bold = False
        self.font_italic = False
        self.text_shadow = False
        self.shadow_type = "black_white"  # "black_white" 또는 "white_black"

        self.update_appearance()

    def update_appearance(self):
        # 폰트 설정
        font = QFont(self.font_family, self.font_size)
        font.setBold(self.font_bold)
        font.setItalic(self.font_italic)
        self.setFont(font)

        # 색상 설정
        self.setDefaultTextColor(self.text_color)

    def mouseDoubleClickEvent(self, event):
        """더블클릭 시 속성 다이얼로그 표시"""
        dialog = TextPropertiesDialog(self)
        if dialog.exec():
            self.update_appearance()
        super().mouseDoubleClickEvent(event)

    def paint(self, painter, option, widget):
        """그림자 효과가 있는 텍스트 렌더링"""
        if self.text_shadow:
            self.draw_shadow_text(painter)
        else:
            super().paint(painter, option, widget)

    def draw_shadow_text(self, painter):
        """그림자 효과 적용한 텍스트 그리기"""
        # 현재 텍스트와 폰트 정보
        text = self.toPlainText()
        font = self.font()
        color = self.defaultTextColor()

        # 그림자 오프셋 계산 (폰트 크기에 비례)
        shadow_offset = max(1, font.pointSize() // 8)

        # 그림자 색상 결정
        if self.shadow_type == "black_white":
            shadow_color = QColor("black") if color.lightness() > 128 else QColor("white")
        else:  # "white_black"
            shadow_color = QColor("white") if color.lightness() > 128 else QColor("black")

        # 8방향 그림자 그리기
        painter.setFont(font)
        painter.setPen(shadow_color)

        directions = [
            (-shadow_offset, -shadow_offset), (0, -shadow_offset), (shadow_offset, -shadow_offset),
            (-shadow_offset, 0),                           (shadow_offset, 0),
            (-shadow_offset, shadow_offset), (0, shadow_offset), (shadow_offset, shadow_offset)
        ]

        for dx, dy in directions:
            painter.drawText(self.boundingRect().translated(dx, dy), Qt.AlignLeft, text)

        # 본문 텍스트 그리기
        painter.setPen(color)
        painter.drawText(self.boundingRect(), Qt.AlignLeft, text)


# --- [3. 라인 속성 변경 팝업] ---
class SeriesPropDialog(QDialog):
    def __init__(self, series, parent=None):
        super().__init__(parent)
        self.setWindowTitle(f"{series.name()} - 속성 설정")
        self.series = series
        self.resize(350, 280)

        # 배경색 설정 (다크모드)
        self.setStyleSheet("""
            QDialog {
                background-color: #2D2D30;
                color: white;
            }
            QLabel {
                color: white;
            }
            QGroupBox {
                color: white;
                font-weight: bold;
                border: 1px solid #3E3E42;
                border-radius: 4px;
                margin-top: 10px;
            }
            QGroupBox::title {
                subcontrol-origin: margin;
                left: 10px;
                padding: 0 3px;
                color: white;
            }
            QComboBox {
                color: white;
                background-color: #3C3C3C;
                border: 1px solid #555555;
            }
            QComboBox QAbstractItemView {
                background-color: #2D2D30;
                color: white;
                selection-background-color: #0078D7;
            }
            QSpinBox {
                color: white;
                background-color: #3C3C3C;
                border: 1px solid #555555;
            }
            QCheckBox {
                color: white;
            }
        """)

        layout = QVBoxLayout(self)
        layout.setContentsMargins(15, 15, 15, 15)
        layout.setSpacing(10)

        # 라인 정보 섹션
        info_group = QGroupBox("라인 정보")
        info_layout = QFormLayout(info_group)

        self.lb_name = QLabel(series.name())
        self.lb_name.setStyleSheet("font-weight: bold; font-size: 11px;")
        info_layout.addRow("신호 이름:", self.lb_name)

        # 신호 설명 추가
        descriptions = {
            "SPEED (속도)": "열차의 현재 속도 (km/h)",
            "PWM": "모터 제어 신호 (0-100%)",
            "TASC (목표)": "자동 속도 제어 목표값",
            "ATC Code": "ATC 안전 코드 값",
            "DTG (잔여거리)": "목적지까지 남은 거리 (m)"
        }
        desc_text = descriptions.get(series.name(), "데이터 시각화")
        self.lb_desc = QLabel(desc_text)
        self.lb_desc.setStyleSheet("color: #666666; font-size: 10px;")
        info_layout.addRow("설명:", self.lb_desc)

        layout.addWidget(info_group)

        # 속성 설정 섹션
        prop_group = QGroupBox("속성 설정")
        prop_layout = QFormLayout(prop_group)

        # 색상
        self.btn_color = QPushButton()
        self.current_color = series.pen().color()
        self.btn_color.setStyleSheet(f"""
            QPushButton {{
                background-color: {self.current_color.name()};
                border: 1px solid gray;
                border-radius: 4px;
                min-width: 60px;
                min-height: 25px;
            }}
        """)
        self.btn_color.clicked.connect(self.choose_color)
        prop_layout.addRow("색상:", self.btn_color)

        # 선 굵기
        self.sb_width = QSpinBox()
        self.sb_width.setRange(1, 10)
        self.sb_width.setValue(series.pen().width())
        self.sb_width.setFixedWidth(80)
        prop_layout.addRow("선 굵기:", self.sb_width)

        # 선 스타일
        self.cb_style = QComboBox()
        self.cb_style.addItems(["실선", "점선", "대시선", "점대시선"])
        current_style = series.pen().style()
        style_map = {
            Qt.SolidLine: 0,
            Qt.DotLine: 1,
            Qt.DashLine: 2,
            Qt.DashDotLine: 3
        }
        self.cb_style.setCurrentIndex(style_map.get(current_style, 0))
        prop_layout.addRow("선 스타일:", self.cb_style)

        # 라벨 표시
        self.chk_show_label = QCheckBox("차트에 라벨 표시")
        self.chk_show_label.setChecked(False)  # 기본적으로 라벨 숨김
        prop_layout.addRow("", self.chk_show_label)

        layout.addWidget(prop_group)

        # 버튼들
        buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
        buttons.accepted.connect(self.accept)
        buttons.rejected.connect(self.reject)
        layout.addWidget(buttons)

    def choose_color(self):
        c = QColorDialog.getColor(self.current_color, self, "색상 선택")
        if c.isValid():
            self.current_color = c
            self.btn_color.setStyleSheet(f"background-color: {c.name()}; border: 1px solid gray;")

    def apply_settings(self):
        pen = self.series.pen()
        pen.setColor(self.current_color)
        pen.setWidth(self.sb_width.value())

        # 선 스타일 설정
        style_map = [Qt.SolidLine, Qt.DotLine, Qt.DashLine, Qt.DashDotLine]
        pen.setStyle(style_map[self.cb_style.currentIndex()])

        self.series.setPen(pen)

        # 라벨 표시 설정 (시리즈 이름 변경으로 구현)
        if self.chk_show_label.isChecked():
            self.series.setName(f"{self.series.name()} ✓")
        else:
            # 체크 표시 제거 (원래 이름 복원)
            name = self.series.name().replace(" ✓", "")
            self.series.setName(name)


# --- [3.5. 도형 속성 변경 팝업] ---
class ShapePropertiesDialog(QDialog):
    def __init__(self, shape_obj, parent=None):
        super().__init__(parent)
        self.shape_obj = shape_obj
        self._deleted = False  # 삭제 여부
        self.setWindowTitle("도형 속성 설정")
        self.resize(350, 300)

        # 배경색 설정 (다크모드)
        self.setStyleSheet("""
            QDialog {
                background-color: #2D2D30;
                color: white;
            }
            QLabel {
                color: white;
            }
            QGroupBox {
                color: white;
                font-weight: bold;
                border: 1px solid #3E3E42;
                border-radius: 4px;
                margin-top: 10px;
            }
            QGroupBox::title {
                subcontrol-origin: margin;
                left: 10px;
                padding: 0 3px;
                color: white;
            }
            QComboBox {
                color: white;
                background-color: #3C3C3C;
                border: 1px solid #555555;
            }
            QComboBox QAbstractItemView {
                background-color: #2D2D30;
                color: white;
                selection-background-color: #0078D7;
            }
            QSpinBox {
                color: white;
                background-color: #3C3C3C;
                border: 1px solid #555555;
            }
            QCheckBox {
                color: white;
            }
        """)

        layout = QVBoxLayout(self)
        layout.setContentsMargins(15, 15, 15, 15)
        layout.setSpacing(10)

        # 속성 설정 그룹
        prop_group = QGroupBox("속성 설정")
        prop_layout = QFormLayout(prop_group)

        # 테두리 색상
        self.btn_border_color = QPushButton()
        self.border_color = shape_obj['border_color']
        self.btn_border_color.setStyleSheet(f"""
            QPushButton {{
                background-color: {self.border_color.name()};
                border: 1px solid gray;
                border-radius: 4px;
                min-width: 60px;
                min-height: 25px;
            }}
        """)
        self.btn_border_color.clicked.connect(self.choose_border_color)
        prop_layout.addRow("테두리 색상:", self.btn_border_color)

        # 테두리 굵기
        self.sb_border_width = QSpinBox()
        self.sb_border_width.setRange(1, 10)
        self.sb_border_width.setValue(shape_obj['border_width'])
        self.sb_border_width.setFixedWidth(80)
        prop_layout.addRow("테두리 굵기:", self.sb_border_width)

        # 채우기 색상
        self.btn_fill_color = QPushButton()
        self.fill_color = shape_obj['fill_color']
        self.btn_fill_color.setStyleSheet(f"""
            QPushButton {{
                background-color: {self.fill_color.name()};
                border: 1px solid gray;
                border-radius: 4px;
                min-width: 60px;
                min-height: 25px;
            }}
        """)
        self.btn_fill_color.clicked.connect(self.choose_fill_color)
        prop_layout.addRow("채우기 색상:", self.btn_fill_color)

        # 채우기 투명도
        self.sb_fill_opacity = QSpinBox()
        self.sb_fill_opacity.setRange(0, 255)
        self.sb_fill_opacity.setValue(shape_obj['fill_opacity'])
        self.sb_fill_opacity.setFixedWidth(80)
        prop_layout.addRow("채우기 투명도:", self.sb_fill_opacity)

        layout.addWidget(prop_group)

        # 삭제 버튼
        btn_delete = QPushButton("🗑️ 삭제")
        btn_delete.setStyleSheet("""
            QPushButton {
                background-color: #E53935;
                color: white;
                border: none;
                border-radius: 4px;
                padding: 8px;
                font-weight: bold;
            }
            QPushButton:hover {
                background-color: #F44336;
            }
            QPushButton:pressed {
                background-color: #C62828;
            }
        """)
        btn_delete.clicked.connect(self.delete_shape)
        layout.addWidget(btn_delete)

        # 버튼들
        buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
        buttons.accepted.connect(self.accept)
        buttons.rejected.connect(self.reject)
        layout.addWidget(buttons)

    def delete_shape(self):
        """도형 삭제"""
        self._deleted = True
        self.reject()

    def choose_border_color(self):
        color = QColorDialog.getColor(self.border_color, self, "테두리 색상 선택")
        if color.isValid():
            self.border_color = color
            self.btn_border_color.setStyleSheet(f"""
                QPushButton {{
                    background-color: {color.name()};
                    border: 1px solid gray;
                    border-radius: 4px;
                    min-width: 60px;
                    min-height: 25px;
                }}
            """)

    def choose_fill_color(self):
        color = QColorDialog.getColor(self.fill_color, self, "채우기 색상 선택")
        if color.isValid():
            # 투명도 적용
            color.setAlpha(self.sb_fill_opacity.value())
            self.fill_color = color
            self.btn_fill_color.setStyleSheet(f"""
                QPushButton {{
                    background-color: {color.name()};
                    border: 1px solid gray;
                    border-radius: 4px;
                    min-width: 60px;
                    min-height: 25px;
                }}
            """)

    def accept(self):
        # 설정 적용
        self.shape_obj['border_color'] = self.border_color
        self.shape_obj['border_width'] = self.sb_border_width.value()

        fill_color = QColor(self.fill_color)
        fill_color.setAlpha(self.sb_fill_opacity.value())
        self.shape_obj['fill_color'] = fill_color
        self.shape_obj['fill_opacity'] = self.sb_fill_opacity.value()

        super().accept()


# --- [3.7. 타임라인 속성 변경 팝업] ---
class TimelinePropertiesDialog(QDialog):
    deleted = Signal()  # 삭제 시그널

    def __init__(self, timeline_obj, parent=None, graph_view=None):
        super().__init__(parent)
        self.timeline_obj = timeline_obj
        self.graph_view = graph_view
        self.setWindowTitle("타임라인 속성 설정")
        self.resize(400, 400)
        self._deleted = False

        # 배경색 설정 (다크모드)
        self.setStyleSheet("""
            QDialog {
                background-color: #2D2D30;
                color: white;
            }
            QLabel {
                color: white;
            }
            QGroupBox {
                color: white;
                font-weight: bold;
                border: 1px solid #3E3E42;
                border-radius: 4px;
                margin-top: 10px;
            }
            QGroupBox::title {
                subcontrol-origin: margin;
                left: 10px;
                padding: 0 3px;
                color: white;
            }
            QComboBox {
                color: white;
                background-color: #3C3C3C;
                border: 1px solid #555555;
            }
            QComboBox QAbstractItemView {
                background-color: #2D2D30;
                color: white;
                selection-background-color: #0078D7;
            }
            QSpinBox {
                color: white;
                background-color: #3C3C3C;
                border: 1px solid #555555;
            }
            QLineEdit {
                color: white;
                background-color: #3C3C3C;
                border: 1px solid #555555;
            }
        """)

        layout = QVBoxLayout(self)
        layout.setContentsMargins(15, 15, 15, 15)
        layout.setSpacing(10)

        # 시간 정보 표시
        time_label = QLabel(f"⏱️ 시간: {timeline_obj['label']}")
        time_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #2196F3;")
        layout.addWidget(time_label)

        # 활성화된 데이터 값 표시
        if graph_view:
            data_group = QGroupBox("현재 위치 데이터 값")
            data_layout = QFormLayout(data_group)
            
            # 타임라인 위치에서 데이터 값 조회
            timestamp = timeline_obj['timestamp']
            data_values = self._get_data_at_timestamp(timestamp)
            
            for key, (name, value, visible) in data_values.items():
                if visible:
                    value_label = QLabel(f"{value}")
                    value_label.setStyleSheet("color: #00FF00; font-weight: bold;")
                    data_layout.addRow(f"{name}:", value_label)
            
            layout.addWidget(data_group)

        # 속성 설정 그룹
        prop_group = QGroupBox("속성 설정")
        prop_layout = QFormLayout(prop_group)

        # 선 색상
        self.btn_color = QPushButton()
        self.line_color = timeline_obj['color']
        self.btn_color.setStyleSheet(f"""
            QPushButton {{
                background-color: {self.line_color.name()};
                border: 1px solid gray;
                border-radius: 4px;
                min-width: 60px;
                min-height: 25px;
            }}
        """)
        self.btn_color.clicked.connect(self.choose_color)
        prop_layout.addRow("선 색상:", self.btn_color)

        # 선 굵기
        self.sb_width = QSpinBox()
        self.sb_width.setRange(1, 10)
        self.sb_width.setValue(timeline_obj['width'])
        self.sb_width.setFixedWidth(80)
        prop_layout.addRow("선 굵기:", self.sb_width)

        # 선 스타일
        self.cb_style = QComboBox()
        self.cb_style.addItems(["실선", "점선", "대시선", "점대시선"])
        style_map = {
            Qt.SolidLine: 0,
            Qt.DotLine: 1,
            Qt.DashLine: 2,
            Qt.DashDotLine: 3
        }
        self.cb_style.setCurrentIndex(style_map.get(timeline_obj['style'], 0))
        prop_layout.addRow("선 스타일:", self.cb_style)

        # 라벨 텍스트
        self.le_label = QLineEdit()
        self.le_label.setText(timeline_obj['label'])
        prop_layout.addRow("라벨 텍스트:", self.le_label)

        layout.addWidget(prop_group)

        # 삭제 버튼
        btn_delete = QPushButton("🗑️ 삭제")
        btn_delete.setStyleSheet("""
            QPushButton {
                background-color: #E53935;
                color: white;
                border: none;
                border-radius: 4px;
                padding: 8px;
                font-weight: bold;
            }
            QPushButton:hover {
                background-color: #F44336;
            }
            QPushButton:pressed {
                background-color: #C62828;
            }
        """)
        btn_delete.clicked.connect(self.delete_timeline)
        layout.addWidget(btn_delete)

        # 버튼들
        buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
        buttons.accepted.connect(self.accept)
        buttons.rejected.connect(self.reject)
        layout.addWidget(buttons)

    def _get_data_at_timestamp(self, timestamp):
        """해당 타임스탬프에서의 데이터 값 조회"""
        result = {}
        if not self.graph_view or not self.graph_view.data_list:
            return result
        
        # 인덱스 계산
        idx = int((timestamp - self.graph_view.start_timestamp) / 1000)
        if idx < 0 or idx >= len(self.graph_view.data_list):
            return result
        
        # 각 시리즈의 값과 가시성 상태 조회
        for key, info in self.graph_view.signals_map.items():
            is_visible = info['chk'].isChecked()
            if idx < len(info['data']):
                val = info['data'][idx]
                if key in ['speed', 'dtg']:
                    result[key] = (info['base'], f"{val:.1f}", is_visible)
                else:
                    result[key] = (info['base'], f"{int(val)}", is_visible)
        
        return result

    def choose_color(self):
        color = QColorDialog.getColor(self.line_color, self, "선 색상 선택")
        if color.isValid():
            self.line_color = color
            self.btn_color.setStyleSheet(f"""
                QPushButton {{
                    background-color: {color.name()};
                    border: 1px solid gray;
                    border-radius: 4px;
                    min-width: 60px;
                    min-height: 25px;
                }}
            """)

    def delete_timeline(self):
        """타임라인 삭제"""
        self._deleted = True
        self.reject()  # 다이얼로그 닫기

    def accept(self):
        # 설정 적용
        self.timeline_obj['color'] = self.line_color
        self.timeline_obj['width'] = self.sb_width.value()

        # 선 스타일 설정
        style_map = [Qt.SolidLine, Qt.DotLine, Qt.DashLine, Qt.DashDotLine]
        self.timeline_obj['style'] = style_map[self.cb_style.currentIndex()]

        # 라벨 업데이트
        self.timeline_obj['label'] = self.le_label.text()

        super().accept()


# --- [3.8. 마커 속성 변경 팝업] ---
class MarkerPropertiesDialog(QDialog):
    def __init__(self, marker_type, marker_data, parent=None, graph_view=None):
        """
        marker_type: 'PG', 'TWC', 'STATION'
        marker_data: (timestamp, label) 또는 {'start': (t, lbl), 'end': (t, lbl)} for TWC
        """
        super().__init__(parent)
        self.marker_type = marker_type
        self.marker_data = marker_data
        self.graph_view = graph_view
        self.setWindowTitle(f"{marker_type} 마커 속성")
        self.resize(380, 320)

        # 배경색 설정 (다크모드)
        self.setStyleSheet("""
            QDialog {
                background-color: #2D2D30;
                color: white;
            }
            QLabel {
                color: white;
            }
            QGroupBox {
                color: white;
                font-weight: bold;
                border: 1px solid #3E3E42;
                border-radius: 4px;
                margin-top: 10px;
            }
            QGroupBox::title {
                subcontrol-origin: margin;
                left: 10px;
                padding: 0 3px;
                color: white;
            }
            QCheckBox {
                color: white;
            }
        """)

        layout = QVBoxLayout(self)
        layout.setContentsMargins(15, 15, 15, 15)
        layout.setSpacing(10)

        # 마커 정보 표시
        if isinstance(marker_data, dict):  # TWC 쌍 (start/end)
            info_text = f"📍 {marker_type}: {marker_data['start'][1]} ~ {marker_data['end'][1]}"
            timestamp = marker_data['start'][0]
        else:
            timestamp, label = marker_data
            info_text = f"📍 {marker_type}: {label}"

        info_label = QLabel(info_text)
        info_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #4CAF50;")
        layout.addWidget(info_label)

        # 시간 표시
        dt = QDateTime.fromMSecsSinceEpoch(int(timestamp))
        time_str = dt.toString("HH:mm:ss")
        time_label = QLabel(f"⏱️ 시간: {time_str}")
        time_label.setStyleSheet("color: #2196F3;")
        layout.addWidget(time_label)

        # TWC 영역 표시 옵션 (TWC 마커일 경우만)
        if marker_type == "TWC" and isinstance(marker_data, dict):
            region_group = QGroupBox("영역 표시 설정")
            region_layout = QFormLayout(region_group)

            self.chk_show_region = QCheckBox("TWC 영역 표시 (Rx ~ End)")
            self.chk_show_region.setChecked(marker_data.get('show_region', False))
            region_layout.addRow(self.chk_show_region)

            # 영역 색상
            self.btn_region_color = QPushButton()
            self.region_color = marker_data.get('region_color', QColor("#00FF00"))
            self.btn_region_color.setStyleSheet(f"""
                QPushButton {{
                    background-color: {self.region_color.name()};
                    border: 1px solid gray;
                    border-radius: 4px;
                    min-width: 60px;
                    min-height: 25px;
                }}
            """)
            self.btn_region_color.clicked.connect(self.choose_region_color)
            region_layout.addRow("영역 색상:", self.btn_region_color)

            # 영역 투명도
            self.sb_region_opacity = QSpinBox()
            self.sb_region_opacity.setRange(10, 150)
            self.sb_region_opacity.setValue(marker_data.get('region_opacity', 50))
            self.sb_region_opacity.setStyleSheet("color: white; background-color: #3C3C3C;")
            region_layout.addRow("투명도:", self.sb_region_opacity)

            layout.addWidget(region_group)

        # 버튼들
        buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
        buttons.accepted.connect(self.accept)
        buttons.rejected.connect(self.reject)
        layout.addWidget(buttons)

    def choose_region_color(self):
        color = QColorDialog.getColor(self.region_color, self, "영역 색상 선택")
        if color.isValid():
            self.region_color = color
            self.btn_region_color.setStyleSheet(f"""
                QPushButton {{
                    background-color: {color.name()};
                    border: 1px solid gray;
                    border-radius: 4px;
                    min-width: 60px;
                    min-height: 25px;
                }}
            """)

    def accept(self):
        # TWC 영역 설정 저장
        if self.marker_type == "TWC" and isinstance(self.marker_data, dict):
            self.marker_data['show_region'] = self.chk_show_region.isChecked()
            self.marker_data['region_color'] = self.region_color
            self.marker_data['region_opacity'] = self.sb_region_opacity.value()
        super().accept()


# --- [3.6. 텍스트 속성 변경 팝업] ---
class TextPropertiesDialog(QDialog):
    def __init__(self, text_obj, parent=None):
        super().__init__(parent)
        self.text_obj = text_obj
        self._deleted = False  # 삭제 여부
        self.setWindowTitle("텍스트 속성 설정")
        self.resize(400, 400)

        # 배경색 설정 (다크모드)
        self.setStyleSheet("""
            QDialog {
                background-color: #2D2D30;
                color: white;
            }
            QLabel {
                color: white;
            }
            QGroupBox {
                color: white;
                font-weight: bold;
                border: 1px solid #3E3E42;
                border-radius: 4px;
                margin-top: 10px;
            }
            QGroupBox::title {
                subcontrol-origin: margin;
                left: 10px;
                padding: 0 3px;
                color: white;
            }
            QComboBox {
                color: white;
                background-color: #3C3C3C;
                border: 1px solid #555555;
            }
            QComboBox QAbstractItemView {
                background-color: #2D2D30;
                color: white;
                selection-background-color: #0078D7;
            }
            QSpinBox {
                color: white;
                background-color: #3C3C3C;
                border: 1px solid #555555;
            }
            QCheckBox {
                color: white;
            }
            QTextEdit {
                color: white;
                background-color: #3C3C3C;
                border: 1px solid #555555;
            }
        """)

        layout = QVBoxLayout(self)
        layout.setContentsMargins(15, 15, 15, 15)
        layout.setSpacing(10)

        # 텍스트 편집 그룹
        text_group = QGroupBox("텍스트 편집")
        text_layout = QVBoxLayout(text_group)

        self.text_edit = QTextEdit()
        self.text_edit.setPlainText(text_obj['text'])
        self.text_edit.setMaximumHeight(60)
        text_layout.addWidget(self.text_edit)

        layout.addWidget(text_group)

        # 폰트 속성 그룹
        font_group = QGroupBox("폰트 속성")
        font_layout = QFormLayout(font_group)

        # 폰트 패밀리
        self.cb_font_family = QComboBox()
        self.cb_font_family.addItems(["Malgun Gothic", "Arial", "Times New Roman", "Courier New", "Verdana"])
        current_font = text_obj['font_family']
        if current_font in ["Malgun Gothic", "Arial", "Times New Roman", "Courier New", "Verdana"]:
            self.cb_font_family.setCurrentText(current_font)
        font_layout.addRow("폰트:", self.cb_font_family)

        # 폰트 크기
        self.sb_font_size = QSpinBox()
        self.sb_font_size.setRange(8, 72)
        self.sb_font_size.setValue(text_obj['font_size'])
        font_layout.addRow("크기:", self.sb_font_size)

        # 텍스트 색상
        self.btn_text_color = QPushButton()
        self.text_color = text_obj['text_color']
        self.btn_text_color.setStyleSheet(f"""
            QPushButton {{
                background-color: {self.text_color.name()};
                border: 1px solid gray;
                border-radius: 4px;
                min-width: 60px;
                min-height: 25px;
            }}
        """)
        self.btn_text_color.clicked.connect(self.choose_text_color)
        font_layout.addRow("색상:", self.btn_text_color)

        # 굵게, 기울임
        self.chk_bold = QCheckBox("굵게")
        self.chk_bold.setChecked(text_obj['font_bold'])
        font_layout.addRow("", self.chk_bold)

        self.chk_italic = QCheckBox("기울임")
        self.chk_italic.setChecked(text_obj['font_italic'])
        font_layout.addRow("", self.chk_italic)

        layout.addWidget(font_group)

        # 효과 그룹
        effect_group = QGroupBox("효과")
        effect_layout = QFormLayout(effect_group)

        self.chk_shadow = QCheckBox("그림자 효과")
        self.chk_shadow.setChecked(text_obj['text_shadow'])
        effect_layout.addRow("", self.chk_shadow)

        self.cb_shadow_type = QComboBox()
        self.cb_shadow_type.addItems(["흰색 배경 검은 글자", "검은 배경 흰 글자"])
        if text_obj['shadow_type'] == "white_black":
            self.cb_shadow_type.setCurrentIndex(0)
        else:
            self.cb_shadow_type.setCurrentIndex(1)
        effect_layout.addRow("그림자 타입:", self.cb_shadow_type)

        layout.addWidget(effect_group)

        # 삭제 버튼
        btn_delete = QPushButton("🗑️ 삭제")
        btn_delete.setStyleSheet("""
            QPushButton {
                background-color: #E53935;
                color: white;
                border: none;
                border-radius: 4px;
                padding: 8px;
                font-weight: bold;
            }
            QPushButton:hover {
                background-color: #F44336;
            }
            QPushButton:pressed {
                background-color: #C62828;
            }
        """)
        btn_delete.clicked.connect(self.delete_text)
        layout.addWidget(btn_delete)

        # 버튼들
        buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
        buttons.accepted.connect(self.accept)
        buttons.rejected.connect(self.reject)
        layout.addWidget(buttons)

    def delete_text(self):
        """텍스트 삭제"""
        self._deleted = True
        self.reject()

    def choose_text_color(self):
        color = QColorDialog.getColor(self.text_color, self, "텍스트 색상 선택")
        if color.isValid():
            self.text_color = color
            self.btn_text_color.setStyleSheet(f"""
                QPushButton {{
                    background-color: {color.name()};
                    border: 1px solid gray;
                    border-radius: 4px;
                    min-width: 60px;
                    min-height: 25px;
                }}
            """)

    def accept(self):
        # 설정 적용
        self.text_obj['text'] = self.text_edit.toPlainText()
        self.text_obj['font_family'] = self.cb_font_family.currentText()
        self.text_obj['font_size'] = self.sb_font_size.value()
        self.text_obj['text_color'] = self.text_color
        self.text_obj['font_bold'] = self.chk_bold.isChecked()
        self.text_obj['font_italic'] = self.chk_italic.isChecked()
        self.text_obj['text_shadow'] = self.chk_shadow.isChecked()

        if self.cb_shadow_type.currentIndex() == 0:
            self.text_obj['shadow_type'] = "white_black"
        else:
            self.text_obj['shadow_type'] = "black_white"

        super().accept()


# --- [4. 인터랙티브 차트 뷰] ---
class InteractiveChartView(QChartView):
    cursorMoved = Signal(float, object)

    def __init__(self, chart, parent=None):
        super().__init__(chart)
        self.parent_view = parent  # GraphView 참조
        self.setRenderHint(QPainter.Antialiasing)
        self.setMouseTracking(True)
        self.setDragMode(QChartView.NoDrag)
        self.setFocusPolicy(Qt.StrongFocus)  # 키보드 포커스 받기

        self.is_panning = False
        self.last_mouse_pos = None
        self.cursor_pos_x = None
        self.cursor_pen = QPen(QColor("red"), 1, Qt.SolidLine)
        self.selected_object = None  # 선택된 드로잉 객체
        
        # 드래그 관련 상태
        self.is_dragging_object = False
        self.drag_start_pos = None
        self.drag_start_chart_pos = None

        # 구간 선택(드래그) 상태
        self.is_selecting_range = False
        self.range_start_pos = None
        self.range_rect = None
        self._range_min_ms = None
        self._range_max_ms = None
        self.range_brush = QBrush(QColor(0, 120, 215, 50))
        self.range_pen = QPen(QColor(0, 120, 215, 180), 1, Qt.SolidLine)

        self.event_markers = {"PG": [], "TWC": [], "STATION": []}
        self.twc_regions = []  # TWC 영역 표시용 데이터
        self.pg_regions = []   # PG 영역 표시용 데이터

    def wheelEvent(self, event):
        # 줌 기능 (X축 중심)
        chart = self.chart()
        axis_x = chart.axes(Qt.Horizontal)[0]
        
        zoom_factor = 1.2 if event.angleDelta().y() > 0 else 0.8
        
        min_val = axis_x.min().toMSecsSinceEpoch()
        max_val = axis_x.max().toMSecsSinceEpoch()
        current_range = max_val - min_val
        
        # 줌 한계 설정 (최소 1초 ~ 최대 24시간)
        mouse_ratio = (event.position().x() - chart.plotArea().left()) / chart.plotArea().width()
        new_range = current_range * (1 / zoom_factor)
        
        if new_range < 1000 or new_range > 3600 * 1000 * 24: return 
        
        center = min_val + (current_range * mouse_ratio)
        new_min = center - (new_range * mouse_ratio)
        new_max = new_min + new_range
        
        axis_x.setRange(QDateTime.fromMSecsSinceEpoch(int(new_min)), 
                        QDateTime.fromMSecsSinceEpoch(int(new_max)))
        
        # SpinBox 값 업데이트
        if self.parent_view:
            self.parent_view.update_x_scale_spinbox()
        
        event.accept()

    def mousePressEvent(self, event):
        if event.button() == Qt.MiddleButton:
            self.is_panning = True
            self.last_mouse_pos = event.pos()
            self.setCursor(Qt.ClosedHandCursor)
            event.accept()
        elif event.button() == Qt.LeftButton and self.parent_view.drawing_mode:
            # 그리기 모드일 때 시작점 설정
            if self.parent_view.drawing_mode == "text":
                # 텍스트 모드: 클릭한 위치에 텍스트 추가
                pos = event.pos()
                plot_area = self.chart().plotArea()
                if plot_area.contains(pos.x(), pos.y()):
                    chart_pos = self.chart().mapToValue(pos)
                    self.parent_view.add_text_at_position(chart_pos.x(), chart_pos.y())
                    self.parent_view.drawing_mode = None  # 그리기 모드 해제
                    self.setCursor(Qt.ArrowCursor)
            elif self.parent_view.drawing_mode in ["rect", "circle"]:
                # 도형 모드: 드래그 시작
                self.parent_view.drawing_start_pos = event.pos()
                self.parent_view.temp_shape = None
            event.accept()
        elif event.button() == Qt.LeftButton:
            # 드로잉 객체 클릭 체크
            clicked_obj = self.get_object_at_position(event.pos())
            if clicked_obj:
                self.selected_object = clicked_obj
                # 드래그 시작 준비
                self.is_dragging_object = True
                self.drag_start_pos = event.pos()
                self.drag_start_chart_pos = self.chart().mapToValue(event.pos())
                self.setCursor(Qt.SizeAllCursor)
                self.setFocus()  # 키보드 포커스 가져오기
                self.update()  # 선택 표시 업데이트
                event.accept()
                return
            else:
                # 빈 곳 클릭 시 선택 해제
                if self.selected_object:
                    self.selected_object = None
                    self.update()
                # 구간 선택 시작
                self.is_selecting_range = True
                self.range_start_pos = event.pos()
                self.range_rect = QRectF(self.range_start_pos, self.range_start_pos)
                self._range_min_ms = None
                self._range_max_ms = None
                self.update()

        super().mousePressEvent(event)

    def get_object_at_position(self, pos):
        """특정 위치의 드로잉 객체 반환"""
        if not hasattr(self, 'parent_view') or not self.parent_view:
            return None

        chart = self.chart()
        plot_area = chart.plotArea()
        if not plot_area.contains(pos.x(), pos.y()):
            return None

        chart_pos = chart.mapToValue(pos)

        # 객체들과의 거리 계산 (역순으로 - 나중에 추가된 객체가 위에 있음)
        for obj in reversed(self.parent_view.drawing_objects):
            if obj['type'] in ['rect', 'circle', 'text']:
                # 사각형 기반 객체 (도형, 텍스트 모두 동일하게 처리)
                t = chart_pos.x()
                y = chart_pos.y()
                if (obj['timestamp_left'] <= t <= obj['timestamp_right'] and
                    obj['y_bottom'] <= y <= obj['y_top']):
                    return obj
            elif obj['type'] == 'timeline':
                # 타임라인과의 거리 계산
                px = chart.mapToPosition(QPointF(obj['timestamp'], 0)).x()
                if abs(pos.x() - px) < 10:  # 선 근처 10픽셀
                    return obj

        return None

    def get_marker_at_position(self, pos):
        """특정 위치의 마커 반환 (타입, 데이터)"""
        chart = self.chart()
        plot_area = chart.plotArea()
        
        # 상단 마커 영역 체크 (plot_area 상단 50px)
        if not (plot_area.left() <= pos.x() <= plot_area.right() and
                pos.y() >= plot_area.top() - 10 and pos.y() <= plot_area.top() + 50):
            return None

        # 마커 순회하며 위치 확인
        for kind, points in self.event_markers.items():
            for i, (timestamp, label) in enumerate(points):
                px = chart.mapToPosition(QPointF(timestamp, 0)).x()
                if abs(pos.x() - px) < 15:  # 마커 근처 15픽셀
                    if kind == "TWC":
                        # TWC는 Rx~End 쌍으로 처리
                        return self._get_twc_pair(i, timestamp, label)
                    return (kind, (timestamp, label))
        
        return None

    def _get_twc_pair(self, index, timestamp, label):
        """TWC 마커의 Rx~End 쌍 찾기"""
        twc_markers = self.event_markers.get("TWC", [])
        
        if "Rx" in label:
            # Rx 마커면 다음 End 마커 찾기
            for j in range(index + 1, len(twc_markers)):
                end_ts, end_label = twc_markers[j]
                if "End" in end_label:
                    return ("TWC", {
                        'start': (timestamp, label),
                        'end': (end_ts, end_label),
                        'show_region': False,
                        'region_color': QColor("#00FF00"),
                        'region_opacity': 50
                    })
        elif "End" in label:
            # End 마커면 이전 Rx 마커 찾기
            for j in range(index - 1, -1, -1):
                rx_ts, rx_label = twc_markers[j]
                if "Rx" in rx_label:
                    return ("TWC", {
                        'start': (rx_ts, rx_label),
                        'end': (timestamp, label),
                        'show_region': False,
                        'region_color': QColor("#00FF00"),
                        'region_opacity': 50
                    })
        
        # 단독 마커
        return ("TWC", (timestamp, label))

    def mouseDoubleClickEvent(self, event):
        """더블클릭으로 객체 속성 편집 - 우선순위: 마커 < 데이터라인 < 드로잉객체"""
        
        # 1. 드로잉 객체 더블클릭 체크 (최우선)
        # 매번 현재 위치에서 객체를 새로 검색 (삭제된 객체 잔재 방지)
        obj = self.get_object_at_position(event.pos())
        
        if obj:
            if obj['type'] in ['rect', 'circle']:
                dialog = ShapePropertiesDialog(obj)
                if dialog.exec():
                    self.update()
                elif dialog._deleted:
                    # 도형 삭제
                    if obj in self.parent_view.drawing_objects:
                        self.parent_view.drawing_objects.remove(obj)
                        self.selected_object = None  # 선택 초기화
                        self.update()
                return
            elif obj['type'] == 'text':
                dialog = TextPropertiesDialog(obj)
                if dialog.exec():
                    self.update()
                elif dialog._deleted:
                    # 텍스트 삭제
                    if obj in self.parent_view.drawing_objects:
                        self.parent_view.drawing_objects.remove(obj)
                        self.selected_object = None
                        self.update()
                return
            elif obj['type'] == 'timeline':
                dialog = TimelinePropertiesDialog(obj, None, self.parent_view)
                if dialog.exec():
                    self.update()
                elif dialog._deleted:
                    # 타임라인 삭제
                    if obj in self.parent_view.drawing_objects:
                        self.parent_view.drawing_objects.remove(obj)
                        self.selected_object = None
                        self.update()
                return

        # 2. 마커 더블클릭 체크 (드로잉 객체가 없을 때만)
        marker_info = self.get_marker_at_position(event.pos())
        if marker_info:
            marker_type, marker_data = marker_info
            dialog = MarkerPropertiesDialog(marker_type, marker_data, None, self.parent_view)
            if dialog.exec():
                self.update()
            return
        
        # 3. 기본 동작 (데이터 라인 등)
        super().mouseDoubleClickEvent(event)

    def mouseReleaseEvent(self, event):
        if event.button() == Qt.MiddleButton:
            self.is_panning = False
            self.setCursor(Qt.ArrowCursor)
            event.accept()
        elif event.button() == Qt.LeftButton and self.is_dragging_object:
            # 드래그 종료
            self.is_dragging_object = False
            self.drag_start_pos = None
            self.drag_start_chart_pos = None
            self.setCursor(Qt.ArrowCursor)
            event.accept()
        elif event.button() == Qt.LeftButton and self.is_selecting_range:
            # 구간 선택 종료
            self.is_selecting_range = False
            if self.range_rect and self.range_start_pos:
                drag_distance = (event.pos() - self.range_start_pos).manhattanLength()
                if drag_distance >= 10 and self._range_min_ms is not None and self._range_max_ms is not None:
                    # 컨텍스트 메뉴 표시
                    if hasattr(self, "parent_view") and self.parent_view:
                        self.parent_view.show_range_context_menu(event.globalPos(), self._range_min_ms, self._range_max_ms)
            self.range_rect = None
            self.range_start_pos = None
            self.update()
        elif event.button() == Qt.LeftButton and self.parent_view.drawing_start_pos and self.parent_view.drawing_mode in ["rect", "circle"]:
            # 드래그 종료: 실제 도형 생성 (충분한 드래그 거리 확인)
            start_pos = self.parent_view.drawing_start_pos
            end_pos = event.pos()

            # 드래그 거리 확인 (최소 10픽셀 이상)
            drag_distance = (end_pos - start_pos).manhattanLength()
            if drag_distance >= 10:
                # 임시 도형 제거
                if self.parent_view.temp_shape:
                    self.scene().removeItem(self.parent_view.temp_shape)
                    self.parent_view.temp_shape = None

                # 실제 도형 생성
                self.parent_view.create_final_shape(start_pos, end_pos)

            # 그리기 모드 해제
            self.parent_view.drawing_mode = None
            self.parent_view.drawing_start_pos = None
            if self.parent_view.temp_shape:
                self.scene().removeItem(self.parent_view.temp_shape)
                self.parent_view.temp_shape = None
            self.setCursor(Qt.ArrowCursor)
            event.accept()
        else:
            super().mouseReleaseEvent(event)

    def mouseMoveEvent(self, event):
        chart = self.chart()
        # 패닝 (Middle Button)
        if self.is_panning and self.last_mouse_pos:
            delta = event.pos() - self.last_mouse_pos
            self.last_mouse_pos = event.pos()
            chart.scroll(-delta.x(), 0)
            return
        
        # 드로잉 객체 드래그 이동
        if self.is_dragging_object and self.selected_object and self.drag_start_chart_pos:
            current_chart_pos = chart.mapToValue(event.pos())
            delta_x = current_chart_pos.x() - self.drag_start_chart_pos.x()
            delta_y = current_chart_pos.y() - self.drag_start_chart_pos.y()
            
            obj = self.selected_object
            if obj['type'] in ['rect', 'circle', 'text']:
                # 모든 사각형 기반 객체는 동일하게 처리
                obj['timestamp_left'] += delta_x
                obj['timestamp_right'] += delta_x
                obj['y_top'] += delta_y
                obj['y_bottom'] += delta_y
            elif obj['type'] == 'timeline':
                obj['timestamp'] += delta_x
                # 라벨 업데이트
                obj['label'] = QDateTime.fromMSecsSinceEpoch(int(obj['timestamp'])).toString("HH:mm:ss")
            
            self.drag_start_chart_pos = current_chart_pos
            self.update()
            return

        # 구간 선택 드래그
        if self.is_selecting_range and self.range_start_pos:
            self.range_rect = QRectF(self.range_start_pos, event.pos()).normalized()
            # 시간 범위 계산 (plot 영역 안에서만)
            chart = self.chart()
            plot_area = chart.plotArea()
            rect = self.range_rect
            if rect.intersects(plot_area):
                left = max(rect.left(), plot_area.left())
                right = min(rect.right(), plot_area.right())
                if right > left:
                    min_ms = chart.mapToValue(QPointF(left, plot_area.top())).x()
                    max_ms = chart.mapToValue(QPointF(right, plot_area.top())).x()
                    self._range_min_ms = min(min_ms, max_ms)
                    self._range_max_ms = max(min_ms, max_ms)
            self.update()
            return

        # 그리기 모드 드래그 중
        if self.parent_view.drawing_start_pos and self.parent_view.drawing_mode in ["rect", "circle"]:
            # 임시 도형 업데이트
            start_pos = self.parent_view.drawing_start_pos
            current_pos = event.pos()

            # 기존 임시 도형 제거
            if self.parent_view.temp_shape:
                self.scene().removeItem(self.parent_view.temp_shape)

            # 새로운 임시 도형 생성
            self.parent_view.temp_shape = self.parent_view.create_temp_shape(start_pos, current_pos)
            if self.parent_view.temp_shape:
                self.scene().addItem(self.parent_view.temp_shape)
            return

        # 커서 이동 (Red Line)
        pos = event.pos()
        plot_area = chart.plotArea()
        if plot_area.contains(pos.x(), pos.y()):
            self.cursor_pos_x = pos.x()
            val_x_dt = chart.mapToValue(pos).x()
            self.cursorMoved.emit(val_x_dt, None)
            self.scene().update()
        super().mouseMoveEvent(event)

    def keyPressEvent(self, event):
        """키보드 단축키 처리"""
        if not self.selected_object:
            super().keyPressEvent(event)
            return
        
        obj = self.selected_object
        chart = self.chart()
        
        # 이동 단위 계산 (화면 기준 5픽셀)
        axis_x = chart.axes(Qt.Horizontal)[0]
        axis_y = chart.axes(Qt.Vertical)[0]
        x_range = axis_x.max().toMSecsSinceEpoch() - axis_x.min().toMSecsSinceEpoch()
        y_range = axis_y.max() - axis_y.min()
        plot_area = chart.plotArea()
        
        move_x = (5 / plot_area.width()) * x_range   # 5픽셀에 해당하는 X 이동량
        move_y = (5 / plot_area.height()) * y_range  # 5픽셀에 해당하는 Y 이동량
        
        key = event.key()
        
        # 방향키로 미세 이동
        if key == Qt.Key_Left:
            if obj['type'] in ['rect', 'circle', 'text']:
                obj['timestamp_left'] -= move_x
                obj['timestamp_right'] -= move_x
            elif obj['type'] == 'timeline':
                obj['timestamp'] -= move_x
                obj['label'] = QDateTime.fromMSecsSinceEpoch(int(obj['timestamp'])).toString("HH:mm:ss")
            self.update()
            event.accept()
            
        elif key == Qt.Key_Right:
            if obj['type'] in ['rect', 'circle', 'text']:
                obj['timestamp_left'] += move_x
                obj['timestamp_right'] += move_x
            elif obj['type'] == 'timeline':
                obj['timestamp'] += move_x
                obj['label'] = QDateTime.fromMSecsSinceEpoch(int(obj['timestamp'])).toString("HH:mm:ss")
            self.update()
            event.accept()
            
        elif key == Qt.Key_Up:
            if obj['type'] in ['rect', 'circle', 'text']:
                obj['y_top'] += move_y
                obj['y_bottom'] += move_y
            self.update()
            event.accept()
            
        elif key == Qt.Key_Down:
            if obj['type'] in ['rect', 'circle', 'text']:
                obj['y_top'] -= move_y
                obj['y_bottom'] -= move_y
            self.update()
            event.accept()
        
        # Delete 키로 삭제
        elif key == Qt.Key_Delete:
            if obj in self.parent_view.drawing_objects:
                self.parent_view.drawing_objects.remove(obj)
                self.selected_object = None
                self.update()
            event.accept()
        
        # Enter 키로 속성 팝업 열기
        elif key == Qt.Key_Return or key == Qt.Key_Enter:
            if obj['type'] in ['rect', 'circle']:
                dialog = ShapePropertiesDialog(obj)
                if dialog.exec():
                    self.update()
                elif dialog._deleted:
                    if obj in self.parent_view.drawing_objects:
                        self.parent_view.drawing_objects.remove(obj)
                        self.selected_object = None
                        self.update()
            elif obj['type'] == 'text':
                dialog = TextPropertiesDialog(obj)
                if dialog.exec():
                    self.update()
                elif dialog._deleted:
                    if obj in self.parent_view.drawing_objects:
                        self.parent_view.drawing_objects.remove(obj)
                        self.selected_object = None
                        self.update()
            elif obj['type'] == 'timeline':
                dialog = TimelinePropertiesDialog(obj, None, self.parent_view)
                if dialog.exec():
                    self.update()
                elif dialog._deleted:
                    if obj in self.parent_view.drawing_objects:
                        self.parent_view.drawing_objects.remove(obj)
                        self.selected_object = None
                        self.update()
            event.accept()
        
        # Escape 키로 선택 해제
        elif key == Qt.Key_Escape:
            self.selected_object = None
            self.update()
            event.accept()
        
        else:
            super().keyPressEvent(event)

    def drawForeground(self, painter, rect):
        chart = self.chart()
        plot_area = chart.plotArea()
        
        # 0. PG 영역 표시 (배경으로 먼저 그리기)
        for region in self.pg_regions:
            if region.get('show_region', False):
                start_ts = region['start'][0]
                end_ts = region['end'][0]
                
                start_px = chart.mapToPosition(QPointF(start_ts, 0)).x()
                end_px = chart.mapToPosition(QPointF(end_ts, 0)).x()
                
                if end_px > plot_area.left() and start_px < plot_area.right():
                    # 영역이 화면에 보이면 그리기
                    left = max(start_px, plot_area.left())
                    right = min(end_px, plot_area.right())
                    
                    region_color = QColor(region.get('region_color', QColor("#FF6600")))
                    region_color.setAlpha(region.get('region_opacity', 40))
                    
                    painter.fillRect(
                        QRectF(left, plot_area.top(), right - left, plot_area.height()),
                        QBrush(region_color)
                    )
        
        # 0.5. TWC 영역 표시
        for region in self.twc_regions:
            if region.get('show_region', False):
                start_ts = region['start'][0]
                end_ts = region['end'][0]
                
                start_px = chart.mapToPosition(QPointF(start_ts, 0)).x()
                end_px = chart.mapToPosition(QPointF(end_ts, 0)).x()
                
                if end_px > plot_area.left() and start_px < plot_area.right():
                    # 영역이 화면에 보이면 그리기
                    left = max(start_px, plot_area.left())
                    right = min(end_px, plot_area.right())
                    
                    region_color = QColor(region.get('region_color', QColor("#00FF00")))
                    region_color.setAlpha(region.get('region_opacity', 50))
                    
                    painter.fillRect(
                        QRectF(left, plot_area.top(), right - left, plot_area.height()),
                        QBrush(region_color)
                    )
        
        # 1. 커서 라인 (Red Vertical Line)
        if self.cursor_pos_x and plot_area.left() <= self.cursor_pos_x <= plot_area.right():
            painter.setPen(self.cursor_pen)
            painter.drawLine(int(self.cursor_pos_x), int(plot_area.top()), 
                             int(self.cursor_pos_x), int(plot_area.bottom()))

        # 1.5. 구간 선택 표시
        if self.range_rect:
            painter.setPen(self.range_pen)
            painter.setBrush(self.range_brush)
            painter.drawRect(self.range_rect)

        # 2. 이벤트 마커 (PG, TWC, Station)
        font = QFont("Malgun Gothic", 9, QFont.Bold)
        painter.setFont(font)

        # 스타일 정의
        styles = {
            "PG":    {"color": QColor("#FF6600"), "offset": 10, "line_style": Qt.DashLine},  # 오렌지
            "TWC":   {"color": QColor(0, 180, 0), "offset": 25, "line_style": Qt.DotLine},   # 밝은 녹색
            "STATION": {"color": QColor("#FFEB3B"), "offset": 40, "line_style": Qt.SolidLine}  # 노란색
        }

        for kind, points in self.event_markers.items():
            style = styles.get(kind, styles["PG"])
            
            for timestamp, label in points:
                # 좌표 변환 (Time -> Pixel)
                px = chart.mapToPosition(QPointF(timestamp, 0)).x()
                
                # 화면 영역 내에 있을 때만 그리기
                if plot_area.left() <= px <= plot_area.right():
                    # 세로선
                    painter.setPen(QPen(style["color"], 1, style["line_style"]))
                    painter.drawLine(int(px), int(plot_area.top()), 
                                     int(px), int(plot_area.top() + style["offset"] + 10))
                    
                    # 라벨 (역삼각형 + 텍스트)
                    painter.setBrush(style["color"])
                    painter.setPen(Qt.NoPen)
                    
                    # 역삼각형 좌표
                    tip_y = plot_area.top() + 5
                    arrow = [
                        QPointF(px, tip_y), 
                        QPointF(px - 4, tip_y - 6), 
                        QPointF(px + 4, tip_y - 6)
                    ]
                    painter.drawPolygon(QPolygonF(arrow))
                    
                    # 텍스트
                    painter.setPen(style["color"])
                    painter.drawText(int(px + 6), int(plot_area.top() + style["offset"]), label)

        # 3. 드로잉 객체들 (도형, 텍스트, 타임라인)
        if hasattr(self, 'parent_view') and self.parent_view:
            for obj in self.parent_view.drawing_objects:
                if obj['type'] == 'rect':
                    # 사각형 렌더링 - 4개 꼭짓점 좌표로 렌더링
                    left_px = chart.mapToPosition(QPointF(obj['timestamp_left'], 0)).x()
                    right_px = chart.mapToPosition(QPointF(obj['timestamp_right'], 0)).x()
                    top_py = chart.mapToPosition(QPointF(0, obj['y_top'])).y()
                    bottom_py = chart.mapToPosition(QPointF(0, obj['y_bottom'])).y()

                    # 사각형 영역 계산
                    rect = QRectF(left_px, top_py, right_px - left_px, bottom_py - top_py)

                    # 채우기
                    fill_color = QColor(obj['fill_color'])
                    fill_color.setAlpha(obj['fill_opacity'])
                    painter.setBrush(QBrush(fill_color))
                    painter.setPen(QPen(obj['border_color'], obj['border_width']))
                    painter.drawRect(rect)

                elif obj['type'] == 'circle':
                    # 원 렌더링 - 4개 꼭짓점 좌표로 렌더링
                    left_px = chart.mapToPosition(QPointF(obj['timestamp_left'], 0)).x()
                    right_px = chart.mapToPosition(QPointF(obj['timestamp_right'], 0)).x()
                    top_py = chart.mapToPosition(QPointF(0, obj['y_top'])).y()
                    bottom_py = chart.mapToPosition(QPointF(0, obj['y_bottom'])).y()

                    # 타원 영역 계산
                    rect = QRectF(left_px, top_py, right_px - left_px, bottom_py - top_py)

                    # 채우기
                    fill_color = QColor(obj['fill_color'])
                    fill_color.setAlpha(obj['fill_opacity'])
                    painter.setBrush(QBrush(fill_color))
                    painter.setPen(QPen(obj['border_color'], obj['border_width']))
                    painter.drawEllipse(rect)

                elif obj['type'] == 'timeline':
                    # 타임라인 렌더링
                    px = chart.mapToPosition(QPointF(obj['timestamp'], 0)).x()
                    if plot_area.left() <= px <= plot_area.right():
                        # 세로선
                        painter.setPen(QPen(obj['color'], obj['width'], obj['style']))
                        painter.drawLine(int(px), int(plot_area.top()),
                                       int(px), int(plot_area.bottom()))

                        # 시간 라벨
                        font = QFont("Malgun Gothic", 8, QFont.Bold)
                        painter.setFont(font)
                        painter.setPen(obj['color'])
                        label_rect = painter.fontMetrics().boundingRect(obj['label'])
                        label_x = px - label_rect.width() / 2
                        label_y = plot_area.top() - label_rect.height() - 2
                        painter.drawText(int(label_x), int(label_y + label_rect.height()), obj['label'])

                elif obj['type'] == 'text':
                    # 텍스트 렌더링 (사각형 기반 좌표 사용)
                    left_px = chart.mapToPosition(QPointF(obj['timestamp_left'], 0)).x()
                    right_px = chart.mapToPosition(QPointF(obj['timestamp_right'], 0)).x()
                    top_py = chart.mapToPosition(QPointF(0, obj['y_top'])).y()
                    bottom_py = chart.mapToPosition(QPointF(0, obj['y_bottom'])).y()
                    
                    # 텍스트 영역 사각형
                    text_rect = QRectF(left_px, top_py, right_px - left_px, bottom_py - top_py)

                    if plot_area.intersects(text_rect):
                        # 폰트 설정
                        font = QFont(obj['font_family'], obj['font_size'])
                        font.setBold(obj['font_bold'])
                        font.setItalic(obj['font_italic'])
                        painter.setFont(font)
                        painter.setPen(obj['text_color'])
                        
                        # 텍스트 위치 (사각형 중앙 기준)
                        text_x = left_px
                        text_y = (top_py + bottom_py) / 2 + obj['font_size'] / 3

                        # 그림자 효과
                        if obj['text_shadow']:
                            shadow_offset = max(1, font.pointSize() // 8)
                            shadow_color = QColor("black") if obj['text_color'].lightness() > 128 else QColor("white")
                            painter.setPen(shadow_color)

                            # 8방향 그림자
                            for dx, dy in [(-shadow_offset, -shadow_offset), (0, -shadow_offset), (shadow_offset, -shadow_offset),
                                         (-shadow_offset, 0), (shadow_offset, 0),
                                         (-shadow_offset, shadow_offset), (0, shadow_offset), (shadow_offset, shadow_offset)]:
                                painter.drawText(int(text_x + dx), int(text_y + dy), obj['text'])

                        # 본문 텍스트
                        painter.setPen(obj['text_color'])
                        painter.drawText(int(text_x), int(text_y), obj['text'])
        
        # 4. 선택된 객체 표시 (점선 사각형)
        if self.selected_object:
            obj = self.selected_object
            selection_pen = QPen(QColor("#00BFFF"), 2, Qt.DashLine)
            painter.setPen(selection_pen)
            painter.setBrush(Qt.NoBrush)
            
            if obj['type'] in ['rect', 'circle', 'text']:
                # 모든 사각형 기반 객체 동일하게 처리
                left_px = chart.mapToPosition(QPointF(obj['timestamp_left'], 0)).x()
                right_px = chart.mapToPosition(QPointF(obj['timestamp_right'], 0)).x()
                top_py = chart.mapToPosition(QPointF(0, obj['y_top'])).y()
                bottom_py = chart.mapToPosition(QPointF(0, obj['y_bottom'])).y()
                
                # 선택 표시 사각형 (약간 확장)
                margin = 4
                sel_rect = QRectF(left_px - margin, top_py - margin, 
                                  right_px - left_px + margin*2, bottom_py - top_py + margin*2)
                painter.drawRect(sel_rect)
                
            elif obj['type'] == 'text_old_unused':
                # 이전 방식 (사용하지 않음)
                px = chart.mapToPosition(QPointF(obj.get('timestamp', 0), obj.get('y_value', 0))).x()
                py = chart.mapToPosition(QPointF(0, obj.get('y_value', 0))).y()
                
                # 텍스트 바운딩 박스 계산
                font = QFont(obj['font_family'], obj['font_size'])
                font.setBold(obj['font_bold'])
                fm = QFontMetrics(font)
                text_rect = fm.boundingRect(obj['text'])
                
                margin = 4
                sel_rect = QRectF(px - margin, py - text_rect.height() - margin,
                                  text_rect.width() + margin*2, text_rect.height() + margin*2)
                painter.drawRect(sel_rect)
                
            elif obj['type'] == 'timeline':
                px = chart.mapToPosition(QPointF(obj['timestamp'], 0)).x()
                
                # 타임라인 상하단에 작은 사각형 표시
                handle_size = 8
                painter.setBrush(QBrush(QColor("#00BFFF")))
                painter.drawRect(int(px - handle_size/2), int(plot_area.top() - handle_size/2), 
                               handle_size, handle_size)
                painter.drawRect(int(px - handle_size/2), int(plot_area.bottom() - handle_size/2), 
                               handle_size, handle_size)


# --- [5. 그래프 뷰 메인 위젯] ---
class GraphView(QWidget):
    range_ai_requested = Signal(int, int, int, int)
    def __init__(self, panel_id):
        super().__init__()
        self.panel_id = panel_id
        self.data_list = []
        self.start_timestamp = 0 

        # 날짜/열번 선택용 상태
        self._available_qdates = set()          # Set[QDate]
        self._date_to_first_idx = {}            # Dict[QDate, int]
        self._trainno_to_first_idx = {}         # Dict[str, int]
        self._last_valid_qdate = None           # Optional[QDate]
        self._suppress_x_range_sync = False

        # 성능 디버그 (환경변수로 제어: MMI_GRAPH_PERF=1)
        self.perf_debug = os.environ.get("MMI_GRAPH_PERF", "0") == "1"
        
        self.layout = QHBoxLayout(self)
        self.layout.setContentsMargins(0, 0, 0, 0)
        self.layout.setSpacing(0)
        self.setStyleSheet("background-color: #1E1E1E;")  # 다크모드 배경 

        # === 차트 구성 ===
        self.chart = QChart()
        self.chart.legend().hide()
        self.chart.setMargins(QMargins(5, 5, 5, 5))
        self.chart.setBackgroundBrush(QColor("#2D2D2D"))  # 다크모드 차트 배경      

        self.axis_x = QDateTimeAxis()
        self.axis_x.setFormat("hh:mm:ss")
        self.axis_x.setTickCount(9)
        self.axis_x.setGridLineVisible(True)
        self.axis_x.setGridLineColor(QColor("#555555"))  # 다크모드 그리드
        self.axis_x.setLabelsColor(QColor("#CCCCCC"))   # 다크모드 라벨 색상
        self.chart.addAxis(self.axis_x, Qt.AlignBottom)

        # 왼쪽 Y축 (속도, PWM, TASC, ATC)
        self.axis_y_left = QValueAxis()
        self.axis_y_left.setRange(0, 110) # 일반 신호 범위 0~110
        self.axis_y_left.setGridLineVisible(True)
        self.axis_y_left.setGridLineColor(QColor("#555555"))  # 다크모드 그리드
        self.axis_y_left.setLabelFormat("%d")
        self.axis_y_left.setTitleText("속도/PWM/ATC/TASC")
        self.axis_y_left.setLabelsColor(QColor("#CCCCCC"))   # 다크모드 라벨 색상
        self.axis_y_left.setTitleBrush(QColor("#CCCCCC"))    # 타이틀 색상
        self.chart.addAxis(self.axis_y_left, Qt.AlignLeft)

        # 오른쪽 Y축 (DTG 전용)
        self.axis_y_right = QValueAxis()
        self.axis_y_right.setRange(0, 2000) # DTG 범위 0~2000m
        self.axis_y_right.setGridLineVisible(False)  # 그리드 라인 숨김
        self.axis_y_right.setLabelFormat("%d")
        self.axis_y_right.setTitleText("DTG (m)")
        self.axis_y_right.setLabelsColor(QColor("#FFAB00"))  # DTG 색상과 맞춤
        self.axis_y_right.setTitleBrush(QColor("#FFAB00"))    # 타이틀 색상
        self.chart.addAxis(self.axis_y_right, Qt.AlignRight)

        # 기본 axis_y는 왼쪽 축으로 설정 (호환성 유지)
        self.axis_y = self.axis_y_left

        self.chart_view = InteractiveChartView(self.chart, self)
        self.chart_view.cursorMoved.connect(self.on_cursor_moved)

        # 차트 축 변경 시 객체 위치 업데이트
        self.axis_x.rangeChanged.connect(self.update_drawing_objects_position)
        self.axis_x.rangeChanged.connect(self._on_axis_x_range_changed)
        self.axis_y_left.rangeChanged.connect(self.update_drawing_objects_position)
        self.axis_y_right.rangeChanged.connect(self.update_drawing_objects_position)
        self.layout.addWidget(self.chart_view, stretch=85)

        # 우클릭 메뉴 연결
        self.chart_view.setContextMenuPolicy(Qt.CustomContextMenu)
        self.chart_view.customContextMenuRequested.connect(self.show_context_menu)

        # === 우측 제어 패널 ===
        self.right_panel = QFrame()
        self.right_panel.setStyleSheet("background-color: #252526; border-left: 1px solid #3E3E42;")
        self.right_panel.setMaximumWidth(260)
        self.panel_layout = QVBoxLayout(self.right_panel)
        self.panel_layout.setContentsMargins(10, 10, 10, 10)
        self.panel_layout.setSpacing(10)

        # 1. 파일 정보 박스
        self.info_box = QComboBox()
        self.info_box.addItem("[No File]")
        self.info_box.setStyleSheet("""
            QComboBox { border: 1px solid #0078D7; border-radius: 4px; padding: 5px; color: white; background-color: #0078D7; font-weight: bold;}
            QComboBox::drop-down { border: 0px; }
            QComboBox QAbstractItemView { background-color: #2D2D30; color: white; selection-background-color: #0078D7; }
        """)
        self.panel_layout.addWidget(self.info_box)

        # 1-1. 시간 표시 (HH:mm:ss만)
        self.lbl_time_top = QLabel("⏱️ --:--:--")
        self.lbl_time_top.setStyleSheet("font-weight: bold; color: #FFFFFF; font-size: 14px;")
        self.panel_layout.addWidget(self.lbl_time_top)

        # 1-2. 날짜 선택 (데이터 있는 날짜만 강조/선택 가능)
        date_title = QLabel("📅 날짜")
        date_title.setStyleSheet("font-weight: bold; margin-top: 6px; color: #CCCCCC;")
        self.panel_layout.addWidget(date_title)

        self.date_picker = QDateEdit()
        self.date_picker.setCalendarPopup(True)
        self.date_picker.setDisplayFormat("yyyy-MM-dd")
        self.date_picker.setEnabled(False)
        self.date_picker.setStyleSheet("""
            QDateEdit { padding: 5px; color: white; background-color: #3C3C3C; border: 1px solid #555555; border-radius: 3px; }
            QDateEdit::drop-down { border: 0px; }
        """)
        self.date_picker.dateChanged.connect(self.on_date_changed)
        self.panel_layout.addWidget(self.date_picker)

        # 1-3. 열번(열차 번호) 선택
        train_title = QLabel("🚆 열번")
        train_title.setStyleSheet("font-weight: bold; margin-top: 6px; color: #CCCCCC;")
        self.panel_layout.addWidget(train_title)

        self.cb_trainno = QComboBox()
        self.cb_trainno.setEnabled(False)
        self.cb_trainno.setStyleSheet("""
            QComboBox { padding: 5px; color: white; background-color: #3C3C3C; border: 1px solid #555555; border-radius: 3px; }
            QComboBox::drop-down { border: 0px; }
            QComboBox QAbstractItemView { background-color: #2D2D30; color: white; selection-background-color: #0078D7; }
        """)
        self.cb_trainno.currentIndexChanged.connect(self.on_trainno_selected)
        self.panel_layout.addWidget(self.cb_trainno)

        # 2. 범례 및 신호 토글 (Legend)
        grp = QGroupBox("Signal Selection")
        grp.setStyleSheet("""
            QGroupBox { border: 1px solid #3E3E42; border-radius: 4px; margin-top: 10px; font-weight: bold; color: #CCCCCC; }
            QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 3px; color: #CCCCCC; }
        """)
        self.legend_layout = QVBoxLayout(grp)
        self.legend_layout.setSpacing(5)
        self.panel_layout.addWidget(grp)
        
        self.signals_map = {}
        self.init_signals() 

        # 3. 역 이동 (Station Jump)
        stn_label = QLabel("🚩 역 이동 (Station Jump)")
        stn_label.setStyleSheet("font-weight: bold; margin-top: 10px; color: #CCCCCC;")
        self.panel_layout.addWidget(stn_label)

        self.cb_stations = QComboBox()
        self.cb_stations.setStyleSheet("""
            QComboBox { padding: 5px; color: white; background-color: #3C3C3C; border: 1px solid #555555; border-radius: 3px; }
            QComboBox::drop-down { border: 0px; }
            QComboBox QAbstractItemView { background-color: #2D2D30; color: white; selection-background-color: #0078D7; }
        """)
        self.cb_stations.currentIndexChanged.connect(self.on_station_selected)
        self.panel_layout.addWidget(self.cb_stations)

        # 4. 그리기 툴박스
        toolbox_group = QGroupBox("그리기 도구")
        toolbox_group.setStyleSheet("""
            QGroupBox { border: 1px solid #3E3E42; border-radius: 4px; margin-top: 10px; font-weight: bold; color: #CCCCCC; }
            QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 3px; color: #CCCCCC; }
        """)
        toolbox_layout = QVBoxLayout(toolbox_group)
        toolbox_layout.setSpacing(5)

        # 그리기 도구 버튼들
        tool_buttons = [
            ("📝 텍스트", self.set_text_drawing_mode),
            ("🔲 사각형", self.set_rect_drawing_mode),
            ("🔴 원", self.set_circle_drawing_mode),
            ("📏 세로선", lambda: self.add_drawing_tool("line")),
        ]

        for text, callback in tool_buttons:
            btn = QPushButton(text)
            btn.setStyleSheet("""
                QPushButton {
                    background-color: #3C3C3C;
                    color: white;
                    border: 1px solid #555555;
                    border-radius: 4px;
                    padding: 8px;
                    text-align: left;
                }
                QPushButton:hover {
                    background-color: #4C4C4C;
                    border: 1px solid #666666;
                }
                QPushButton:pressed {
                    background-color: #2C2C2C;
                }
            """)
            btn.clicked.connect(callback)
            toolbox_layout.addWidget(btn)

        self.panel_layout.addWidget(toolbox_group)

        self.panel_layout.addStretch(1)
        
        # 4. X축 스케일 조정 SpinBox
        scale_group = QGroupBox("X축 범위 (초)")
        scale_group.setStyleSheet("""
            QGroupBox { border: 1px solid #3E3E42; border-radius: 4px; margin-top: 10px; font-weight: bold; color: #CCCCCC; }
            QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 3px; color: #CCCCCC; }
        """)
        scale_layout = QHBoxLayout(scale_group)
        scale_layout.setContentsMargins(8, 15, 8, 8)
        
        self.spin_x_scale = QSpinBox()
        self.spin_x_scale.setRange(10, 7200)  # 10초 ~ 2시간
        self.spin_x_scale.setValue(600)  # 기본 600초 (10분)
        self.spin_x_scale.setSingleStep(10)
        self.spin_x_scale.setSuffix(" 초")
        self.spin_x_scale.setStyleSheet("""
            QSpinBox {
                background-color: #3C3C3C;
                color: white;
                border: 1px solid #555555;
                border-radius: 4px;
                padding: 5px;
                font-size: 12px;
            }
            QSpinBox::up-button, QSpinBox::down-button {
                background-color: #4C4C4C;
                border: none;
                width: 20px;
            }
            QSpinBox::up-button:hover, QSpinBox::down-button:hover {
                background-color: #5C5C5C;
            }
        """)
        self.spin_x_scale.valueChanged.connect(self.on_x_scale_changed)
        scale_layout.addWidget(self.spin_x_scale)
        
        self.panel_layout.addWidget(scale_group)

        self.layout.addWidget(self.right_panel, stretch=15)
        self.clicked_series = None
        self.last_click_time = 0

        self.station_timestamp_map = {}

        # 다른 패널에서 온 X축 스케일 동기화 수신
        try:
            sync_manager.x_range_changed.connect(self._apply_synced_x_range)
        except Exception:
            pass

        # 그리기 모드 상태
        self.drawing_mode = None  # None, "rect", "circle", "text"
        self.drawing_start_pos = None
        self.temp_shape = None

        # 차트에 통합된 드로잉 객체들 (차트와 한몸)
        self.drawing_objects = []

    def init_signals(self):
        # 신호 정의: (key, DisplayName, Color)
        config = [
            ("speed", "SPEED (속도)", "#2979FF"),
            ("pwm", "PWM", "#9370DB"),
            ("tasc", "TASC (목표)", "#00C853"),
            ("atc", "ATC Code", "#FF3D00"),
            ("dtg", "DTG (잔여거리)", "#FFAB00") 
        ]
        
        for key, name, color in config:
            series = QLineSeries()
            series.setName(name)
            pen = QPen(QColor(color))
            pen.setWidth(2)
            series.setPen(pen)

            # 시리즈 클릭 이벤트 연결
            series.clicked.connect(lambda p, s=series: self.on_series_clicked(s))

            self.chart.addSeries(series)
            series.attachAxis(self.axis_x)

            # DTG는 오른쪽 Y축, 나머지는 왼쪽 Y축 사용
            if key == "dtg":
                series.attachAxis(self.axis_y_right)
            else:
                series.attachAxis(self.axis_y_left)
            
            # 우측 패널에 체크박스 및 라벨 추가
            row = QHBoxLayout()
            chk = QCheckBox()
            chk.setChecked(True) # 기본값: 모두 켜기
            series.setVisible(True)
            
            chk.setFixedSize(20, 20)
            chk.setStyleSheet(f"""
                QCheckBox::indicator {{ width: 16px; height: 16px; background-color: {color}; border: 1px solid gray; border-radius: 3px; }}
                QCheckBox::indicator:checked {{ background-color: {color}; border: 2px solid white; }}
                QCheckBox::indicator:unchecked {{ background-color: #3C3C3C; border: 1px solid {color}; }}
            """)
            
            lbl = QLabel(f"{name} (-)")
            lbl.setStyleSheet("font-family: 'Malgun Gothic'; font-size: 11px; color: #CCCCCC;")
            
            row.addWidget(chk)
            row.addWidget(lbl)
            row.addStretch(1)
            self.legend_layout.addLayout(row)
            
            # functools.partial을 사용하여 클로저 문제 방지
            chk.stateChanged.connect(partial(self.toggle_series, key))
            
            self.signals_map[key] = {
                "series": series, "label": lbl, "chk": chk, "base": name, "data": []
            }

    def show_range_context_menu(self, global_pos, min_ms, max_ms):
        """드래그 선택 구간 컨텍스트 메뉴"""
        if min_ms is None or max_ms is None:
            return
        menu = QMenu(self)
        menu.setStyleSheet("""
            QMenu {
                background-color: #2D2D30;
                color: white;
                border: 1px solid #3E3E42;
                padding: 5px;
            }
            QMenu::item:selected {
                background-color: #0078D7;
                color: white;
            }
        """)
        action_ai = QAction("이 구간에 대해 AI에게 질문하기", self)
        action_ai.triggered.connect(lambda: self._request_ai_for_range(min_ms, max_ms))
        menu.addAction(action_ai)
        menu.exec(global_pos)

    def _request_ai_for_range(self, min_ms, max_ms):
        """AI 요청 시그널 발생"""
        if not self.data_list or self.start_timestamp == 0:
            return
        start_ms = min(min_ms, max_ms)
        end_ms = max(min_ms, max_ms)
        start_idx = int((start_ms - self.start_timestamp) / 1000)
        end_idx = int((end_ms - self.start_timestamp) / 1000)
        start_idx = max(0, min(len(self.data_list) - 1, start_idx))
        end_idx = max(0, min(len(self.data_list) - 1, end_idx))
        if start_idx > end_idx:
            start_idx, end_idx = end_idx, start_idx
        self.range_ai_requested.emit(int(start_ms), int(end_ms), int(start_idx), int(end_idx))

    def toggle_series(self, key, state):
        if key not in self.signals_map:
            return
        series = self.signals_map[key]['series']
        is_visible = (state == Qt.Checked or state == 2)  # Qt.Checked = 2
        series.setVisible(is_visible)
        # 차트 갱신
        self.chart_view.viewport().update()
        self.chart.update()

    def on_series_clicked(self, series):
        """더블클릭(빠른 클릭) 시 속성창 띄우기"""
        import time
        now = time.time()
        # 0.3초 내에 같은 시리즈 다시 클릭 시
        if series == self.clicked_series and (now - self.last_click_time) < 0.3:
            dlg = SeriesPropDialog(series, self)
            if dlg.exec_():
                dlg.apply_settings()
                # 체크박스 색상도 동기화
                key = [k for k, v in self.signals_map.items() if v['series'] == series][0]
                new_col = series.pen().color().name()
                self.signals_map[key]['chk'].setStyleSheet(
                     f"QCheckBox::indicator {{ width: 16px; height: 16px; background-color: {new_col}; border: 1px solid gray; }}"
                     f"QCheckBox::indicator:checked {{ background-color: {new_col}; border: 2px solid black; }}")
        self.clicked_series = series
        self.last_click_time = now

    def show_context_menu(self, pos):
        """우클릭 컨텍스트 메뉴"""
        # 컨텍스트 메뉴 위치 저장
        self.context_menu_pos = pos

        menu = QMenu(self)
        menu.setStyleSheet("""
            QMenu {
                background-color: #2D2D30;
                color: white;
                border: 1px solid #3E3E42;
                padding: 5px;
            }
            QMenu::item {
                background-color: transparent;
                color: white;
                padding: 5px 20px;
                margin: 2px 0px;
            }
            QMenu::item:selected {
                background-color: #0078D7;
                color: white;
            }
            QMenu::separator {
                height: 1px;
                background-color: #3E3E42;
                margin: 5px 0px;
            }
        """)

        # 타임라인 추가
        action_timeline = QAction("⏱️ 타임라인 추가 (세로선)", self)
        action_timeline.setShortcut("Ctrl+T")
        action_timeline.triggered.connect(lambda: self.add_timeline_at_menu_pos())
        menu.addAction(action_timeline)

        menu.addSeparator()

        # 화면 초기화
        action_reset = QAction("🔄 화면 초기화", self)
        action_reset.setShortcut("Ctrl+R")
        action_reset.triggered.connect(self.reset_view)
        menu.addAction(action_reset)

        menu.exec(self.chart_view.mapToGlobal(pos))

    def set_drag_mode(self):
        """드래그 모드 활성화 (손모양 커서)"""
        self.chart_view.setDragMode(QChartView.ScrollHandDrag)
        self.chart_view.setCursor(Qt.OpenHandCursor)

    def set_view_mode(self):
        """뷰잉 모드 활성화 (눈모양 커서)"""
        self.chart_view.setDragMode(QChartView.NoDrag)
        self.chart_view.setCursor(Qt.CrossCursor)
        self.drawing_mode = None  # 그리기 모드 해제

    def set_rect_drawing_mode(self):
        """사각형 그리기 모드 활성화"""
        self.drawing_mode = "rect"
        self.chart_view.setDragMode(QChartView.NoDrag)
        self.chart_view.setCursor(Qt.CrossCursor)

    def set_circle_drawing_mode(self):
        """원 그리기 모드 활성화"""
        self.drawing_mode = "circle"
        self.chart_view.setDragMode(QChartView.NoDrag)
        self.chart_view.setCursor(Qt.CrossCursor)

    def set_text_drawing_mode(self):
        """텍스트 그리기 모드 활성화"""
        self.drawing_mode = "text"
        self.chart_view.setDragMode(QChartView.NoDrag)
        self.chart_view.setCursor(Qt.IBeamCursor)

    def reset_view(self):
        self.chart.zoomReset()
        if self.data_list:
            start_dt = QDateTime.fromMSecsSinceEpoch(self.start_timestamp)
            self.axis_x.setRange(start_dt, start_dt.addSecs(600))
            self.spin_x_scale.blockSignals(True)
            self.spin_x_scale.setValue(600)
            self.spin_x_scale.blockSignals(False)

    def on_x_scale_changed(self, value):
        """X축 스케일 SpinBox 값 변경 시"""
        # 현재 X축 중앙 시간 계산
        curr_min = self.axis_x.min()
        curr_max = self.axis_x.max()
        center_ms = (curr_min.toMSecsSinceEpoch() + curr_max.toMSecsSinceEpoch()) / 2
        center_dt = QDateTime.fromMSecsSinceEpoch(int(center_ms))
        
        # 새 범위 설정 (중앙 기준)
        half_range = value // 2
        new_min = center_dt.addSecs(-half_range)
        new_max = center_dt.addSecs(value - half_range)
        self.axis_x.setRange(new_min, new_max)

    def _on_axis_x_range_changed(self, min_dt: QDateTime, max_dt: QDateTime):
        """현재 패널에서 X축 범위가 바뀌면 다른 패널로 전파"""
        if self._suppress_x_range_sync:
            return
        try:
            min_ms = min_dt.toMSecsSinceEpoch()
            max_ms = max_dt.toMSecsSinceEpoch()
            sync_manager.request_x_range_sync(int(min_ms), int(max_ms), self.panel_id)
        except Exception:
            pass

    def _apply_synced_x_range(self, min_ms: int, max_ms: int, source_id):
        """다른 패널에서 보낸 X축 범위를 적용"""
        # 자기 자신이 보낸 이벤트면 무시
        if str(source_id) == str(self.panel_id):
            return
        if not hasattr(self, "axis_x"):
            return
        try:
            self._suppress_x_range_sync = True
            self.axis_x.setRange(
                QDateTime.fromMSecsSinceEpoch(int(min_ms)),
                QDateTime.fromMSecsSinceEpoch(int(max_ms)),
            )
            # SpinBox도 갱신
            self.update_x_scale_spinbox()
        finally:
            self._suppress_x_range_sync = False

    def update_x_scale_spinbox(self):
        """현재 X축 범위를 SpinBox에 반영"""
        if not hasattr(self, 'spin_x_scale'):
            return
        curr_min = self.axis_x.min()
        curr_max = self.axis_x.max()
        current_range_secs = curr_min.secsTo(curr_max)
        self.spin_x_scale.blockSignals(True)
        self.spin_x_scale.setValue(max(10, min(7200, current_range_secs)))
        self.spin_x_scale.blockSignals(False)

    def add_drawing_tool(self, tool_type):
        """도형 추가 로직"""
        # 현재 보고 있는 뷰의 중앙 좌표 계산
        plot_area = self.chart.plotArea()
        center_x = plot_area.center().x()
        center_y = plot_area.center().y()
        
        scene = self.chart_view.scene()
        
        if tool_type == "line":
            # 차트 높이만큼 세로선 생성
            item = CustomLineItem(center_x, plot_area.top(), plot_area.height())
        elif tool_type == "rect":
            item = CustomRectItem(center_x - 30, center_y - 20, 60, 40)
        elif tool_type == "circle":
            item = CustomEllipseItem(center_x - 30, center_y - 30, 60, 60)
        else:
            return

        scene.addItem(item)

    def add_timeline_at_menu_pos(self):
        """컨텍스트 메뉴 위치에 타임라인(세로선) 추가"""
        if not hasattr(self, 'context_menu_pos'):
            return

        # 차트 좌표로 변환
        chart = self.chart
        chart_pos = chart.mapToValue(self.context_menu_pos)

        # 타임라인 객체 생성 (차트에 통합)
        timeline_obj = {
            'type': 'timeline',
            'timestamp': chart_pos.x(),
            'color': QColor("#2196F3"),
            'width': 2,
            'style': Qt.SolidLine,
            'label': QDateTime.fromMSecsSinceEpoch(int(chart_pos.x())).toString("HH:mm:ss")
        }

        self.drawing_objects.append(timeline_obj)
        self.chart_view.update()

    def create_temp_shape(self, start_pos, current_pos):
        """임시 도형 생성 (드래그 중 표시용) - 픽셀 좌표 사용"""
        if not self.drawing_mode or self.drawing_mode not in ["rect", "circle"]:
            return None

        # 픽셀 좌표 직접 사용 (차트 좌표 변환 없이)
        x1, y1 = start_pos.x(), start_pos.y()
        x2, y2 = current_pos.x(), current_pos.y()

        # 좌상단과 우하단 좌표 계산
        left = min(x1, x2)
        top = min(y1, y2)
        width = abs(x2 - x1)
        height = abs(y2 - y1)

        if self.drawing_mode == "rect":
            temp_item = QGraphicsRectItem(left, top, width, height)
            temp_item.setPen(QPen(QColor(255, 0, 0, 150), 2, Qt.DashLine))
            temp_item.setBrush(QBrush(QColor(255, 0, 0, 50)))
        elif self.drawing_mode == "circle":
            temp_item = QGraphicsEllipseItem(left, top, width, height)
            temp_item.setPen(QPen(QColor(255, 0, 0, 150), 2, Qt.DashLine))
            temp_item.setBrush(QBrush(QColor(255, 0, 0, 50)))

        return temp_item

    def create_final_shape(self, start_pos, end_pos):
        """최종 도형 생성"""
        if not self.drawing_mode or self.drawing_mode not in ["rect", "circle"]:
            return

        # 차트 좌표로 변환
        chart = self.chart
        start_chart_pos = chart.mapToValue(start_pos)
        end_chart_pos = chart.mapToValue(end_pos)

        x1, y1 = start_chart_pos.x(), start_chart_pos.y()
        x2, y2 = end_chart_pos.x(), end_chart_pos.y()

        # 최소 크기 확인 (픽셀 거리로 확인)
        pixel_width = abs(end_pos.x() - start_pos.x())
        pixel_height = abs(end_pos.y() - start_pos.y())
        if pixel_width < 10 or pixel_height < 10:
            return  # 너무 작으면 생성하지 않음

        # 도형 객체 생성 (차트에 통합) - 4개 꼭짓점의 시간/값 좌표 저장
        if self.drawing_mode == "rect":
            shape_obj = {
                'type': 'rect',
                'timestamp_left': min(x1, x2),   # 좌측 시간
                'timestamp_right': max(x1, x2),  # 우측 시간
                'y_top': max(y1, y2),            # 상단 Y값 (Y축 반전)
                'y_bottom': min(y1, y2),         # 하단 Y값
                'border_color': QColor(255, 0, 0, 255),
                'border_width': 2,
                'fill_color': QColor(255, 0, 0, 100),
                'fill_opacity': 100
            }
        elif self.drawing_mode == "circle":
            shape_obj = {
                'type': 'circle',
                'timestamp_left': min(x1, x2),   # 좌측 시간
                'timestamp_right': max(x1, x2),  # 우측 시간
                'y_top': max(y1, y2),            # 상단 Y값 (Y축 반전)
                'y_bottom': min(y1, y2),         # 하단 Y값
                'border_color': QColor(255, 0, 0, 255),
                'border_width': 2,
                'fill_color': QColor(255, 0, 0, 100),
                'fill_opacity': 100
            }

        self.drawing_objects.append(shape_obj)
        self.chart_view.update()

    def add_text_at_position(self, x, y):
        """지정된 위치에 텍스트 추가 (팝업으로 속성 입력)"""
        # 텍스트 객체를 사각형 기반으로 생성 (4꼭짓점 좌표)
        # 기본 크기 설정 (차트 좌표 기준)
        axis_x = self.axis_x
        axis_y = self.axis_y_left
        
        # 기본 너비/높이 계산 (대략적인 픽셀 크기를 차트 좌표로 변환)
        x_range = axis_x.max().toMSecsSinceEpoch() - axis_x.min().toMSecsSinceEpoch()
        y_range = axis_y.max() - axis_y.min()
        plot_area = self.chart.plotArea()
        
        # 100x30 픽셀 크기에 해당하는 차트 좌표
        default_width = (100 / plot_area.width()) * x_range
        default_height = (30 / plot_area.height()) * y_range
        
        text_obj = {
            'type': 'text',
            # 사각형 기반 좌표 (도형과 동일)
            'timestamp_left': x,
            'timestamp_right': x + default_width,
            'y_top': y + default_height / 2,
            'y_bottom': y - default_height / 2,
            # 텍스트 속성
            'text': "",
            'font_family': "Malgun Gothic",
            'font_size': 12,
            'text_color': QColor("white"),
            'font_bold': False,
            'font_italic': False,
            'text_shadow': False,
            'shadow_type': "black_white"
        }
        
        # 팝업으로 텍스트 속성 입력받기
        dialog = TextPropertiesDialog(text_obj, self)
        if dialog.exec():
            # 확인 버튼 누르면 객체 추가
            if text_obj['text'].strip():  # 텍스트가 있으면
                self.drawing_objects.append(text_obj)
                self.chart_view.update()

    def update_drawing_objects_position(self):
        """차트 줌/패닝 시 객체들의 위치 재계산"""
        for item in self.chart_view.scene().items():
            if hasattr(item, 'timestamp') and item.timestamp is not None and hasattr(item, 'y_value') and item.y_value is not None:
                # 시간과 Y값을 픽셀 좌표로 변환
                pixel_pos = self.chart.mapToPosition(QPointF(item.timestamp, item.y_value))
                # Y축만 현재 위치 유지, X축은 시간에 고정
                current_pos = item.pos()
                item.setPos(pixel_pos.x(), current_pos.y())

    def set_index(self, index):
        """외부에서 인덱스를 받아 커서만 이동 (싱크 재요청 금지)"""
        if not self.data_list or self.start_timestamp == 0:
            return
        
        timestamp_ms = self.start_timestamp + (index * 1000) 
        
        # 1. 커서 위치 시각적 업데이트 (Map Value -> Position)
        self.chart_view.cursor_pos_x = self.chart.mapToPosition(QPointF(timestamp_ms, 0)).x()
        self.chart_view.scene().update()
        
        # 2. 텍스트 라벨 업데이트
        if 0 <= index < len(self.data_list):
            for key, info in self.signals_map.items():
                if index < len(info['data']):
                    val = info['data'][index]
                    # 소수점 표시 구분
                    if key in ['speed', 'dtg']:
                        info['label'].setText(f"{info['base']} ({val:.1f})")
                    else:
                        info['label'].setText(f"{info['base']} ({int(val)})")
        
        # 3. 뷰포트 자동 이동 (현재 커서가 화면 밖이면)
        dt = QDateTime.fromMSecsSinceEpoch(int(timestamp_ms))
        curr_min = self.axis_x.min()
        curr_max = self.axis_x.max()
        if dt < curr_min or dt > curr_max:
             self.axis_x.setRange(dt.addSecs(-30), dt.addSecs(30))

    def _refresh_date_picker(self, old_dates=None):
        """날짜 picker에 '데이터가 있는 날짜'만 강조하고, 그 날짜만 선택되도록 제어"""
        if old_dates is None:
            old_dates = set()

        # 날짜 목록이 없으면 비활성화
        if not self._available_qdates:
            self.date_picker.blockSignals(True)
            self.date_picker.setEnabled(False)
            self.date_picker.blockSignals(False)
            self._last_valid_qdate = None
            return

        dates_sorted = sorted(self._available_qdates)
        min_d, max_d = dates_sorted[0], dates_sorted[-1]

        self.date_picker.blockSignals(True)
        self.date_picker.setEnabled(True)
        self.date_picker.setDateRange(min_d, max_d)
        # 현재 선택이 유효하지 않으면 첫 날짜로 세팅
        if self._last_valid_qdate not in self._available_qdates:
            self._last_valid_qdate = min_d
        self.date_picker.setDate(self._last_valid_qdate)
        self.date_picker.blockSignals(False)

        # 캘린더에서 데이터 날짜만 색상 강조
        cal = self.date_picker.calendarWidget()
        if cal is None:
            return

        # 이전 하이라이트 제거
        clear_fmt = QTextCharFormat()
        for d in old_dates:
            try:
                cal.setDateTextFormat(d, clear_fmt)
            except Exception:
                pass

        highlight_fmt = QTextCharFormat()
        highlight_fmt.setBackground(QBrush(QColor("#0078D7")))
        highlight_fmt.setForeground(QBrush(QColor("#FFFFFF")))
        highlight_fmt.setFontWeight(QFont.Bold)
        for d in self._available_qdates:
            try:
                cal.setDateTextFormat(d, highlight_fmt)
            except Exception:
                pass

    def _refresh_trainno_combo(self):
        """열번 드롭다운을 데이터 기반으로 갱신"""
        self.cb_trainno.blockSignals(True)
        self.cb_trainno.clear()

        if not self._trainno_to_first_idx:
            self.cb_trainno.addItem("[열번 없음]")
            self.cb_trainno.setEnabled(False)
            self.cb_trainno.blockSignals(False)
            return

        # 표시 순서: 값 기준 정렬 (기본은 문자열 정렬, '0000'은 제외됨)
        for trainno in sorted(self._trainno_to_first_idx.keys()):
            self.cb_trainno.addItem(trainno, trainno)
        self.cb_trainno.setEnabled(True)
        self.cb_trainno.setCurrentIndex(0)
        self.cb_trainno.blockSignals(False)

    def on_date_changed(self, qdate: QDate):
        """사용자가 날짜를 바꾸면 해당 날짜(첫 등장 시점)로 이동"""
        if not self._available_qdates:
            return

        if qdate not in self._available_qdates:
            # 허용되지 않은 날짜 선택 방지: 마지막 유효 날짜로 되돌림
            if self._last_valid_qdate is not None:
                self.date_picker.blockSignals(True)
                self.date_picker.setDate(self._last_valid_qdate)
                self.date_picker.blockSignals(False)
            return

        self._last_valid_qdate = qdate
        idx = self._date_to_first_idx.get(qdate, None)
        if idx is not None:
            self._jump_to_index(idx)

    def on_trainno_selected(self, combo_index: int):
        """열번 변경 시 해당 열번의 첫 등장 위치(시간대)로 이동"""
        if combo_index < 0:
            return
        trainno = self.cb_trainno.currentData()
        if not trainno:
            trainno = self.cb_trainno.currentText()
        idx = self._trainno_to_first_idx.get(str(trainno), None)
        if idx is not None:
            self._jump_to_index(idx)

    def _jump_to_index(self, index: int):
        """인덱스를 시간(ms)로 변환해 현재 스케일 유지하며 이동"""
        if not self.data_list or self.start_timestamp == 0:
            return
        t_ms = self.start_timestamp + (int(index) * 1000)
        self._jump_to_ms(t_ms)

    def _jump_to_ms(self, t_ms: int):
        """지정한 시간(ms)을 중심으로 현재 X축 스케일 유지하며 이동 + 커서/패널 갱신"""
        dt = QDateTime.fromMSecsSinceEpoch(int(t_ms))

        curr_min = self.axis_x.min()
        curr_max = self.axis_x.max()
        current_range_secs = curr_min.secsTo(curr_max)
        if current_range_secs <= 0 and hasattr(self, "spin_x_scale"):
            current_range_secs = int(self.spin_x_scale.value())
        if current_range_secs <= 0:
            current_range_secs = 600

        half_range = current_range_secs // 2
        new_min = dt.addSecs(-half_range)
        new_max = dt.addSecs(current_range_secs - half_range)
        self.axis_x.setRange(new_min, new_max)

        self.chart_view.cursor_pos_x = self.chart.mapToPosition(QPointF(t_ms, 0)).x()
        self.chart_view.scene().update()
        self.on_cursor_moved(t_ms, None)

    def on_station_selected(self, index):
        """콤보박스에서 역 선택 시 이동 (현재 확대/축소 스케일 유지)"""
        if index in self.station_timestamp_map:
            t_ms = self.station_timestamp_map[index]
            dt = QDateTime.fromMSecsSinceEpoch(int(t_ms))
            
            # 현재 X축 범위(스케일) 유지
            curr_min = self.axis_x.min()
            curr_max = self.axis_x.max()
            current_range_secs = curr_min.secsTo(curr_max)  # 현재 표시 범위(초)
            
            # 선택한 역을 중심으로 현재 스케일 유지하며 이동
            half_range = current_range_secs // 2
            new_min = dt.addSecs(-half_range)
            new_max = dt.addSecs(current_range_secs - half_range)
            self.axis_x.setRange(new_min, new_max)
            
            # 커서 이동 및 싱크 요청
            self.chart_view.cursor_pos_x = self.chart.mapToPosition(QPointF(t_ms, 0)).x()
            self.chart_view.scene().update()
            self.on_cursor_moved(t_ms, None)

    def filter_noise(self, raw_data, min_valid=0, threshold=3, speed_data=None, all_zero_mask=None, preserve_valid_zero=False):
        """
        범용 노이즈 필터링 (튀는 값 제거)
        
        Args:
            raw_data: 원본 데이터 리스트
            min_valid: 유효 최소값 (이 값 미만은 노이즈로 간주)
            threshold: 연속 노이즈 허용 횟수 (이 횟수 초과하면 실제 0으로 인정)
            speed_data: 속도 데이터 (정차 시 필터링 해제용)
            all_zero_mask: 모든 신호가 0인 구간 마스크 (True=노이즈 구간)
        
        Note:
            - 속도가 0인 구간(정차 시)에서는 필터링을 적용하지 않고 원본 값 사용
            - all_zero_mask가 True인 구간은 통신 에러로 간주하여 이전 값 유지
        """
        filtered = []
        last_valid = raw_data[0] if raw_data else 0
        noise_count = 0

        for i, val in enumerate(raw_data):
            # 1. 모든 신호가 0인 구간 (통신 에러) - 이전 값 유지
            if all_zero_mask and i < len(all_zero_mask) and all_zero_mask[i]:
                filtered.append(last_valid)
                continue
            
            # 2. 정차 상태(속도=0)에서도 동일한 노이즈 필터링 적용
            # 정차 중에도 TASC/ATC가 0과 특정값 사이를 반복할 수 있음
            # 따라서 정차 여부와 관계없이 동일한 Hold Last Value 필터 적용
            
            # 3. 일반 필터링 로직 (정차/주행 모두 동일)
            if val is None:
                filtered.append(last_valid)
                continue
            if preserve_valid_zero and val == 0:
                last_valid = 0
                noise_count = 0
                filtered.append(0)
                continue
            if val >= min_valid:  # 유효 범위
                last_valid = val
                noise_count = 0
                filtered.append(val)
            else:
                noise_count += 1
                if noise_count > threshold:
                    filtered.append(0)  # 실제 0으로 인정
                    last_valid = 0
                else:
                    filtered.append(last_valid)  # 노이즈 구간은 이전 값 유지
        return filtered
    
    def filter_speed_noise(self, raw_speed, all_zero_mask):
        """
        속도 데이터 필터링 (모든 신호가 0인 통신 에러 구간에서는 이전 값 유지)
        
        Args:
            raw_speed: 원본 속도 데이터 리스트
            all_zero_mask: 모든 신호가 0인 구간 마스크 (True=통신 에러 구간)
        
        Returns:
            filtered: 필터링된 속도 데이터
        """
        if not raw_speed:
            return []
        
        filtered = []
        last_valid = raw_speed[0] if raw_speed else 0
        
        for i, val in enumerate(raw_speed):
            # 모든 신호가 0인 구간 (통신 에러) - 이전 값 유지
            if all_zero_mask and i < len(all_zero_mask) and all_zero_mask[i]:
                filtered.append(last_valid)
            else:
                if val is None:
                    filtered.append(last_valid)
                else:
                    filtered.append(val)
                    last_valid = val
        
        return filtered
    
    def detect_all_zero_frames(self, vals_dict):
        """
        모든 주요 신호가 동시에 0이 되는 구간을 감지 (통신 에러로 추정)
        
        Returns:
            all_zero_mask: 각 프레임별 True/False 리스트 (True=모든 신호가 0인 노이즈 구간)
        
        Note:
            - 속도, PWM, TASC, ATC, DTG가 모두 0인 경우: 통신 에러로 추정 → 이전 값 유지
            - 속도가 0이 아닌데 나머지가 모두 0인 경우: 통신 에러로 추정 → 이전 값 유지
        """
        if not vals_dict:
            return []
        
        # 프레임 수
        frame_count = len(list(vals_dict.values())[0])
        all_zero_mask = []
        
        for i in range(frame_count):
            speed = vals_dict.get('speed', [0])[i] if i < len(vals_dict.get('speed', [])) else 0
            pwm = vals_dict.get('pwm', [0])[i] if i < len(vals_dict.get('pwm', [])) else 0
            tasc = vals_dict.get('tasc', [0])[i] if i < len(vals_dict.get('tasc', [])) else 0
            atc = vals_dict.get('atc', [0])[i] if i < len(vals_dict.get('atc', [])) else 0
            dtg = vals_dict.get('dtg', [0])[i] if i < len(vals_dict.get('dtg', [])) else 0
            
            # Case 1: 속도를 포함한 모든 신호가 0이면 통신 에러로 추정
            if speed == 0 and pwm == 0 and tasc == 0 and atc == 0 and dtg == 0:
                all_zero_mask.append(True)
            # Case 2: 속도가 0이 아닌데 다른 모든 신호가 0이면 통신 에러로 추정
            elif speed != 0 and pwm == 0 and tasc == 0 and atc == 0 and dtg == 0:
                all_zero_mask.append(True)
            else:
                all_zero_mask.append(False)
        
        return all_zero_mask

    def _infer_valid_sources(self, data_list, signal_attr_map, zero_ratio_threshold=0.98, min_samples=50):
        """
        소스별로 '거의 항상 0'인 신호를 찾아 유효 소스를 추정한다.
        - 특정 소스가 해당 신호에서 0 비율이 매우 높고, 다른 소스는 그렇지 않으면 해당 소스를 제외.
        """
        stats = {k: {} for k in signal_attr_map.keys()}
        sources_seen = set()
        for d in data_list:
            src = getattr(d, "source", None)
            if src is None:
                continue
            sources_seen.add(src)
            for key, attr in signal_attr_map.items():
                val = getattr(d, attr, None)
                if val is None:
                    continue
                s = stats[key].setdefault(src, {"n": 0, "zero": 0})
                s["n"] += 1
                if val == 0:
                    s["zero"] += 1

        valid_sources_map = {}
        for key, by_src in stats.items():
            if not by_src or len(by_src) <= 1:
                valid_sources_map[key] = None
                continue
            ratios = {}
            for src, s in by_src.items():
                if s["n"] < min_samples:
                    continue
                ratios[src] = (s["zero"] / s["n"]) if s["n"] else 0
            if not ratios:
                valid_sources_map[key] = None
                continue
            low_zero = [src for src, r in ratios.items() if r < zero_ratio_threshold]
            high_zero = [src for src, r in ratios.items() if r >= zero_ratio_threshold]
            if low_zero and high_zero:
                valid_sources_map[key] = set(low_zero)
            else:
                valid_sources_map[key] = None
        return valid_sources_map

    def _apply_source_filter(self, raw_values, valid_mask):
        """유효하지 않은 소스의 값은 직전 유효값으로 대체"""
        filtered = []
        last_valid = None
        for val, is_valid in zip(raw_values, valid_mask):
            if is_valid:
                last_valid = val
                filtered.append(val)
            else:
                if last_valid is None:
                    filtered.append(val if val is not None else 0)
                else:
                    filtered.append(last_valid)
        return filtered

    def _filter_pg_markers(self, raw_markers, threshold=3):
        """
        PG 마커 노이즈 필터링 (시간 정확성 유지)
        
        문제: PG 신호가 1초마다 ON/OFF를 반복 (깜박임)
        해결: 깜박이는 구간을 병합하되, 실제 첫 시작과 마지막 끝 시간을 유지
        
        원리:
        - 짧은 OFF 구간(threshold 이하) 다음에 다시 ON이 오면 → 연속된 신호로 간주
        - 짧은 OFF 구간 다음에 충분히 긴 OFF가 오면 → 마지막 ON 시점이 실제 종료
        
        Args:
            raw_markers: 원본 마커 리스트
            threshold: 깜박임으로 간주할 최대 OFF 연속 횟수
        
        Returns:
            filtered_markers: 깜박임이 병합된 마커 리스트 (실제 시간 유지)
        """
        if not raw_markers:
            return []
        
        n = len(raw_markers)
        filtered = list(raw_markers)  # 복사본 생성
        
        i = 0
        while i < n:
            if raw_markers[i] != "-":
                # PG 신호 시작 발견
                marker_type = raw_markers[i]
                start_idx = i
                last_on_idx = i
                
                # 해당 마커의 끝을 찾음 (깜박임 병합)
                j = i + 1
                off_count = 0
                
                while j < n:
                    if raw_markers[j] == marker_type:
                        # 같은 마커 다시 발견 - 깜박임 중간을 채움
                        for k in range(last_on_idx + 1, j):
                            filtered[k] = marker_type
                        last_on_idx = j
                        off_count = 0
                        j += 1
                    elif raw_markers[j] == "-":
                        off_count += 1
                        if off_count > threshold:
                            # 충분히 긴 OFF - 실제 종료
                            # last_on_idx가 마지막 ON 위치, 그 다음부터는 "-"로 유지
                            break
                        j += 1
                    else:
                        # 다른 마커 발견 - 현재 마커 종료
                        break
                
                # last_on_idx까지만 마커로 채우고, 그 이후는 원본 유지
                i = j
            else:
                i += 1
        
        return filtered

    def set_data(self, data_list):
        """데이터 로드 및 시각화"""
        t0 = time.perf_counter()
        self.data_list = data_list
        if not data_list: return
        
        # 1. 시작 시간 설정 (첫 데이터 기준)
        # 데이터에 'time'이 있으면 해당 값을 기준으로 시작 시간을 설정 (기본: 1초 간격)
        first_time_str = getattr(data_list[0], "time", "") or ""
        first_dt = QDateTime.fromString(first_time_str, "yyyy.MM.dd HH:mm:ss")
        if first_dt.isValid():
            self.start_timestamp = first_dt.toMSecsSinceEpoch()
        else:
            # 파싱 실패 시 현재시간으로 폴백
            self.start_timestamp = QDateTime.currentDateTime().toMSecsSinceEpoch()
            first_dt = QDateTime.fromMSecsSinceEpoch(self.start_timestamp)

        # 상단 시간 라벨/파일정보(짧게 표시: HH:mm:ss만)
        time_str = first_dt.toString("HH:mm:ss")
        full_dt_str = first_dt.toString("yyyy-MM-dd HH:mm:ss")
        self.lbl_time_top.setText(f"⏱️ {time_str}")
        self.info_box.setItemText(0, f"[{len(data_list)}] {time_str}")
        self.info_box.setToolTip(full_dt_str)

        # 날짜/열번 목록 구성은 아래 메인 루프(2. 데이터 순회)에서 같이 수집하여 1회 순회로 처리
        old_dates = self._available_qdates
        self._available_qdates = set()
        self._date_to_first_idx = {}
        self._trainno_to_first_idx = {}
        t1 = time.perf_counter()

        signal_attr_map = {
            "speed": "trainspeed",
            "pwm": "pwm_value",
            "tasc": "tasc_value",
            "atc": "atc_code_val",
            "dtg": "dtg",
        }
        source_rules = self._infer_valid_sources(data_list, signal_attr_map)
        self._signal_valid_sources = source_rules

        vals_raw = {k: [] for k in self.signals_map.keys()}
        source_valid_mask = {k: [] for k in self.signals_map.keys()}
        markers_pg = []
        markers_twc = []
        markers_station = []
        
        self.cb_stations.clear()
        self.station_timestamp_map = {}
        self.chart_view.twc_regions = []  # TWC 영역 초기화
        self.chart_view.pg_regions = []   # PG 영역 초기화
        
        last_pstn = -1
        last_twc = False
        twc_start_ts = None  # TWC 시작 시간 추적
        
        # PG 마커별 색상 정의
        PG_COLORS = {
            "PG1": QColor("#FF6600"),    # 오렌지
            "PG2": QColor("#FF9900"),    # 밝은 오렌지
            "PG3-2": QColor("#FFCC00"),  # 노란색
            "PGX": QColor("#FF3300"),    # 빨간 오렌지
            "ATS": QColor("#9900FF"),    # 보라색
        }
        
        # 2-1. 먼저 marker 데이터를 수집하여 노이즈 필터링 적용
        raw_markers = [getattr(d, 'marker', '-') for d in data_list]
        filtered_markers = self._filter_pg_markers(raw_markers, threshold=3)
        
        # 2. 데이터 순회 및 추출
        pg_start_ts = None
        pg_start_label = None
        last_marker = "-"
        
        for i, d in enumerate(data_list):
            t_ms = self.start_timestamp + (i * 1000)

            # 날짜/열번 인덱싱 (문자열 파싱 대신 t_ms 기반으로 날짜 계산)
            qdate = QDateTime.fromMSecsSinceEpoch(int(t_ms)).date()
            if qdate not in self._date_to_first_idx:
                self._date_to_first_idx[qdate] = i
            self._available_qdates.add(qdate)

            trainno = getattr(d, "trainno", None)
            if trainno and trainno != "0000" and trainno not in self._trainno_to_first_idx:
                self._trainno_to_first_idx[trainno] = i
            
            # 필드 존재 여부 확인 (getattr) + 소스 기반 필터링 (가짜 0 제거)
            src = getattr(d, "source", None)
            for key, attr in signal_attr_map.items():
                val = getattr(d, attr, 0)
                valid_sources = source_rules.get(key)
                is_valid = (valid_sources is None) or (src in valid_sources)
                vals_raw[key].append(val if is_valid else None)
                source_valid_mask[key].append(is_valid)

            curr_pstn = getattr(d, 'pstn', 0)
            
            # (A) 역 정보 (100 이상)
            if curr_pstn >= 100:
                if curr_pstn != last_pstn: 
                    stn_name = STATION_CODE_MAP.get(curr_pstn, f"STN-{curr_pstn}")
                    
                    if self.cb_stations.count() == 0 or \
                       stn_name not in self.cb_stations.itemText(self.cb_stations.count()-1):
                        markers_station.append((t_ms, stn_name))
                        time_str = QDateTime.fromMSecsSinceEpoch(int(t_ms)).toString("HH:mm:ss")
                        self.cb_stations.addItem(f"[{time_str}] {stn_name}")
                        self.station_timestamp_map[self.cb_stations.count()-1] = t_ms
            
            last_pstn = curr_pstn

            # (B) PG 마커 - 노이즈 필터링된 데이터 사용
            curr_marker = filtered_markers[i] if i < len(filtered_markers) else "-"
            
            if curr_marker != last_marker:
                if curr_marker != "-" and last_marker == "-":
                    # PG 시작 (Rx만 표시)
                    markers_pg.append((t_ms, curr_marker))
                    pg_start_ts = t_ms
                    pg_start_label = curr_marker
                elif curr_marker == "-" and last_marker != "-":
                    # PG 종료 - End 마커는 표시하지 않음, 영역 데이터만 추가
                    if pg_start_ts is not None:
                        self.chart_view.pg_regions.append({
                            'start': (pg_start_ts, pg_start_label),
                            'end': (t_ms, pg_start_label),
                            'show_region': True,
                            'region_color': PG_COLORS.get(pg_start_label, QColor("#FF6600")),
                            'region_opacity': 40,
                            'pg_type': pg_start_label
                        })
                        pg_start_ts = None
                        pg_start_label = None
                elif curr_marker != "-" and last_marker != "-" and curr_marker != last_marker:
                    # PG 전환 (이전 영역 저장 + 새로 시작, End 마커 없음)
                    if pg_start_ts is not None:
                        self.chart_view.pg_regions.append({
                            'start': (pg_start_ts, pg_start_label),
                            'end': (t_ms, pg_start_label),
                            'show_region': True,
                            'region_color': PG_COLORS.get(pg_start_label, QColor("#FF6600")),
                            'region_opacity': 40,
                            'pg_type': pg_start_label
                        })
                    markers_pg.append((t_ms, curr_marker))  # Rx만 표시
                    pg_start_ts = t_ms
                    pg_start_label = curr_marker
            last_marker = curr_marker

            # (C) TWC 수신 여부 - End 마커는 표시하지 않음
            curr_twc = getattr(d, 'twct_enable', False)
            if curr_twc and not last_twc:
                markers_twc.append((t_ms, "TWC"))  # Rx만 표시 (End 없음)
                twc_start_ts = t_ms
            elif not curr_twc and last_twc:
                # TWC 영역 데이터만 추가 (End 마커는 표시하지 않음)
                if twc_start_ts is not None:
                    self.chart_view.twc_regions.append({
                        'start': (twc_start_ts, "TWC"),
                        'end': (t_ms, "TWC"),
                        'show_region': True,
                        'region_color': QColor("#00FF00"),
                        'region_opacity': 50
                    })
                    twc_start_ts = None
            last_twc = curr_twc

        # 날짜 picker / 열번 드롭다운 갱신
        self._refresh_date_picker(old_dates=old_dates)
        self._refresh_trainno_combo()
        t2 = time.perf_counter()

        # 3. 데이터 후처리 (노이즈 제거 및 최적화)
        # 3-0. 소스 기반 필터 적용 (유효하지 않은 소스는 직전 유효값 유지)
        vals = {
            k: self._apply_source_filter(vals_raw[k], source_valid_mask[k])
            for k in vals_raw.keys()
        }

        # 3-1. 모든 신호가 동시에 0이 되는 구간 감지 (통신 에러)
        all_zero_mask = self.detect_all_zero_frames(vals)
        # AI/디버그용으로 저장
        self._all_zero_mask = all_zero_mask
        
        # 3-2. 속도 필터링 (모든 신호가 0인 구간에서는 이전 값 유지)
        vals['speed'] = self.filter_speed_noise(vals['speed'], all_zero_mask)
        
        # 3-3. 속도 데이터를 참조하여 정차 시에는 필터링 비활성화
        speed_data = vals['speed']
        pwm_preserve_zero = source_rules.get("pwm") is not None
        dtg_preserve_zero = source_rules.get("dtg") is not None
        atc_preserve_zero = source_rules.get("atc") is not None
        tasc_preserve_zero = source_rules.get("tasc") is not None

        vals['pwm'] = self.filter_noise(vals['pwm'], min_valid=15, threshold=3, speed_data=speed_data, all_zero_mask=all_zero_mask, preserve_valid_zero=pwm_preserve_zero)  # PWM 필터링
        vals['dtg'] = self.filter_noise(vals['dtg'], min_valid=1, threshold=2, speed_data=speed_data, all_zero_mask=all_zero_mask, preserve_valid_zero=dtg_preserve_zero)   # DTG 필터링 (1m 이상만 유효)
        vals['atc'] = self.filter_noise(vals['atc'], min_valid=1, threshold=3, speed_data=speed_data, all_zero_mask=all_zero_mask, preserve_valid_zero=atc_preserve_zero)   # ATC 코드 필터링 (0 깜박임 제거)
        vals['tasc'] = self.filter_noise(vals['tasc'], min_valid=1, threshold=3, speed_data=speed_data, all_zero_mask=all_zero_mask, preserve_valid_zero=tasc_preserve_zero) # TASC 필터링 (0 깜박임 제거)
        
        # 4. 차트에 데이터 적용 (QPointF 변환)
        for k in self.signals_map:
            values = vals[k]
            points = []
            if values:
                # 데이터가 너무 많으면 다운샘플링 고려 가능
                for i, v in enumerate(values):
                    t = self.start_timestamp + (i * 1000)
                    points.append(QPointF(t, v))
                    
            self.signals_map[k]['series'].replace(points)
            self.signals_map[k]['data'] = values
        t3 = time.perf_counter()

        # 5. 마커 업데이트
        self.chart_view.event_markers["PG"] = markers_pg
        self.chart_view.event_markers["TWC"] = markers_twc
        self.chart_view.event_markers["STATION"] = markers_station
        self.chart_view.scene().update()

        # 6. 초기 뷰 설정 (기본 600초 = 10분)
        # 첫 속도 상승 시점 (0 → 1 이상) 찾기
        first_speed_rise_idx = 0
        for i in range(1, len(data_list)):
            prev_speed = getattr(data_list[i-1], 'trainspeed', 0) or 0
            curr_speed = getattr(data_list[i], 'trainspeed', 0) or 0
            if prev_speed == 0 and curr_speed >= 1:
                first_speed_rise_idx = i
                break
        
        # 첫 속도 상승 시점을 중앙에 배치
        scale_secs = 600  # 기본 스케일
        first_speed_rise_ts = self.start_timestamp + (first_speed_rise_idx * 1000)
        center_ts = first_speed_rise_ts
        half_scale = (scale_secs * 1000) // 2
        
        view_start_ts = max(self.start_timestamp, center_ts - half_scale)
        view_end_ts = view_start_ts + (scale_secs * 1000)
        
        self.axis_x.setRange(
            QDateTime.fromMSecsSinceEpoch(int(view_start_ts)),
            QDateTime.fromMSecsSinceEpoch(int(view_end_ts))
        )
        self.spin_x_scale.blockSignals(True)
        self.spin_x_scale.setValue(scale_secs)
        self.spin_x_scale.blockSignals(False)

        # DTG 축 범위 고정 (0~1500)
        self.axis_y_right.setRange(0, 1500)
        t4 = time.perf_counter()

        if self.perf_debug:
            print(
                f"[GraphView PERF] n={len(data_list)} "
                f"setup={t1-t0:.3f}s "
                f"loop+index+ui={t2-t1:.3f}s "
                f"series_replace={t3-t2:.3f}s "
                f"view={t4-t3:.3f}s "
                f"total={t4-t0:.3f}s"
            )

    def on_cursor_moved(self, timestamp_ms, _):
        """커서 이동 시 텍스트 업데이트 및 동기화 요청"""
        if not self.data_list or self.start_timestamp == 0:
            return
        # 상단 시간 라벨 업데이트 (HH:mm:ss만)
        try:
            self.lbl_time_top.setText(
                f"⏱️ {QDateTime.fromMSecsSinceEpoch(int(timestamp_ms)).toString('HH:mm:ss')}"
            )
        except Exception:
            pass
        idx = int((timestamp_ms - self.start_timestamp) / 1000)
        
        if 0 <= idx < len(self.data_list):
            # 우측 패널 값 업데이트
            for key, info in self.signals_map.items():
                if idx < len(info['data']):
                    val = info['data'][idx]
                    if key in ['speed', 'dtg']:
                        info['label'].setText(f"{info['base']} ({val:.1f})")
                    else:
                        info['label'].setText(f"{info['base']} ({int(val)})")
            
            # 동기화 매니저 호출
            sync_manager.request_sync(idx, self.data_list[idx], self.panel_id)

    def get_ai_context(self, cursor_index: int | None = None):
        """
        AI 프롬프트에 넘길 그래프 컨텍스트 생성
        - 현재 표시 구간(axis_x)
        - 현재 표시(선택)된 신호들(+ 커서 시점 값)
        - 사용자 추가 타임라인/도형(drawing_objects)
        - 커서 시점 전후 2개역(Station Jump 기반) 정보 + 해당 시점 스냅샷
        """
        ctx = {}
        try:
            mn = self.axis_x.min().toMSecsSinceEpoch()
            mx = self.axis_x.max().toMSecsSinceEpoch()
            ctx["visible_range_ms"] = {
                "min": int(mn),
                "max": int(mx),
                "seconds": (mx - mn) / 1000.0,
            }
        except Exception:
            pass

        # 선택(표시)된 신호
        visible = []
        try:
            for k, info in self.signals_map.items():
                series = info.get("series")
                if series is not None and series.isVisible():
                    item = {"key": k, "name": info.get("base", k)}
                    # 커서 시점 값 포함
                    if cursor_index is not None:
                        try:
                            data = info.get("data") or []
                            if 0 <= cursor_index < len(data):
                                item["value"] = data[cursor_index]
                        except Exception:
                            pass
                    visible.append(item)
        except Exception:
            pass
        ctx["visible_signals"] = visible

        # 커서 정보
        try:
            if cursor_index is not None and getattr(self, "start_timestamp", 0):
                ts = int(self.start_timestamp + (cursor_index * 1000))
                ctx["cursor"] = {
                    "index": int(cursor_index),
                    "timestamp_ms": ts,
                    "time": QDateTime.fromMSecsSinceEpoch(ts).toString("yyyy-MM-dd HH:mm:ss"),
                }
        except Exception:
            pass

        # 타임라인/텍스트/도형
        objs = []
        try:
            for o in getattr(self, "drawing_objects", []) or []:
                if not isinstance(o, dict):
                    continue
                t = o.get("type")
                if t not in ("timeline", "text", "rect", "circle", "line"):
                    continue
                item = dict(o)
                # QColor 직렬화
                col = item.get("color")
                try:
                    if hasattr(col, "name"):
                        item["color"] = col.name()
                except Exception:
                    pass
                # PenStyle 직렬화
                style = item.get("style")
                try:
                    if hasattr(style, "value"):
                        item["style"] = style.value
                except Exception:
                    pass
                # 레인지 안의 타임라인만 우선 포함
                if t == "timeline":
                    ts = item.get("timestamp")
                    if ts is None:
                        continue
                objs.append(item)
        except Exception:
            pass
        ctx["drawing_objects"] = objs

        # 타임라인(세로선) 전후 2개(커서 기준)
        try:
            if cursor_index is not None and "cursor" in ctx:
                cur_ts = ctx["cursor"]["timestamp_ms"]
                timelines = [o for o in objs if o.get("type") == "timeline" and isinstance(o.get("timestamp"), (int, float))]
                timelines.sort(key=lambda x: x.get("timestamp"))
                prev_ = [t for t in timelines if t["timestamp"] < cur_ts][-2:]
                next_ = [t for t in timelines if t["timestamp"] >= cur_ts][:2]
                ctx["timelines_near_cursor"] = prev_ + next_
        except Exception:
            pass

        # 역(Station Jump) 전후 2개 + 스냅샷
        try:
            if cursor_index is not None and "cursor" in ctx:
                cur_ts = ctx["cursor"]["timestamp_ms"]
                station_events = []
                for combo_idx, t_ms in (getattr(self, "station_timestamp_map", {}) or {}).items():
                    try:
                        label = self.cb_stations.itemText(combo_idx)
                    except Exception:
                        label = ""
                    station_events.append({"timestamp_ms": int(t_ms), "label": label})
                station_events.sort(key=lambda x: x["timestamp_ms"])

                # split
                prev_e = [e for e in station_events if e["timestamp_ms"] < cur_ts][-2:]
                next_e = [e for e in station_events if e["timestamp_ms"] >= cur_ts][:2]
                neighbors = prev_e + next_e

                # 스냅샷 부착 (해당 시점 근처 인덱스)
                snapshots = []
                for e in neighbors:
                    idx = None
                    if getattr(self, "start_timestamp", 0):
                        idx = int(round((e["timestamp_ms"] - self.start_timestamp) / 1000.0))
                        idx = max(0, min(len(self.data_list) - 1, idx)) if self.data_list else None
                    snap = {"event": e, "index": idx}
                    if idx is not None and self.data_list:
                        d = self.data_list[idx]
                        snap["record"] = {
                            "time": getattr(d, "time", ""),
                            "trainno": getattr(d, "trainno", ""),
                            "trainspeed": getattr(d, "trainspeed", None),
                            "limitspeed": getattr(d, "limitspeed", None),
                            "dtg": getattr(d, "dtg", None),
                            "atc_status": getattr(d, "atc_status", ""),
                            "atc_code": getattr(d, "atc_code", ""),
                            "tasc": getattr(d, "tasc", False),
                            "tasc_value": getattr(d, "tasc_value", None),
                            "over_spd_warning": getattr(d, "over_spd_warning", False),
                            "door_open": getattr(d, "door_open", False),
                            "door_close": getattr(d, "door_close", False),
                            "psd_open": getattr(d, "psd_open", False),
                            "psd_close": getattr(d, "psd_close", False),
                            "pstn": getattr(d, "pstn", None),
                            "nstn": getattr(d, "nstn", None),
                            "dstn": getattr(d, "dstn", None),
                        }
                    snapshots.append(snap)
                ctx["station_neighbors"] = snapshots
        except Exception:
            pass

        # 신호카드 성격의 "현재 상태 요약"(커서 레코드 + 필터링 값 동시 포함)
        try:
            if cursor_index is not None and self.data_list and 0 <= cursor_index < len(self.data_list):
                d = self.data_list[cursor_index]
                # filtered(그래프 표시) 값
                filt = {}
                try:
                    for k, info in self.signals_map.items():
                        arr = info.get("data") or []
                        if 0 <= cursor_index < len(arr):
                            filt[k] = arr[cursor_index]
                except Exception:
                    pass

                # all-zero 프레임 여부
                is_all_zero = None
                try:
                    if hasattr(self, "_all_zero_mask") and self._all_zero_mask and 0 <= cursor_index < len(self._all_zero_mask):
                        is_all_zero = bool(self._all_zero_mask[cursor_index])
                except Exception:
                    pass

                ctx["signal_cards_like"] = {
                    "raw": {
                        "speed": getattr(d, "trainspeed", None),
                        "pwm": getattr(d, "pwm_value", None),
                        "dtg": getattr(d, "dtg", None),
                        "tasc_value": getattr(d, "tasc_value", None),
                        "atc_code_val": getattr(d, "atc_code_val", None),
                        "atc_status": getattr(d, "atc_status", ""),
                        "marker": getattr(d, "marker", ""),
                        "doormod": getattr(d, "doormod", ""),
                        "tcmsdoor": getattr(d, "tcmsdoor", ""),
                        "nextdoor": getattr(d, "nextdoor", ""),
                    },
                    "filtered": {
                        "speed": filt.get("speed"),
                        "pwm": filt.get("pwm"),
                        "dtg": filt.get("dtg"),
                        "tasc": filt.get("tasc"),
                        "atc": filt.get("atc"),
                    },
                    "all_zero_frame": is_all_zero,
                    "flags": {
                        "system_active": getattr(d, "system_active", False),
                        "tcr": getattr(d, "tcr", False),
                        "hcr": getattr(d, "hcr", False),
                        "fa": getattr(d, "fa", False),
                        "auto": getattr(d, "auto", False),
                        "mcs": getattr(d, "mcs", False),
                        "yard": getattr(d, "yard", False),
                        "fmc": getattr(d, "fmc", False),
                        "over_spd_warning": getattr(d, "over_spd_warning", False),
                        "twct_enable": getattr(d, "twct_enable", False),
                        "tasc": getattr(d, "tasc", False),
                        "door_open": getattr(d, "door_open", False),
                        "door_close": getattr(d, "door_close", False),
                        "psd_open": getattr(d, "psd_open", False),
                        "psd_close": getattr(d, "psd_close", False),
                    },
                }
        except Exception:
            pass
        return ctx

if __name__ == "__main__":
    # 테스트용 더미 데이터
    class Dummy:
        def __init__(self, i):
            import math
            self.trainspeed = abs(50 + 40 * math.sin(i / 20))
            self.pwm_value = 50 if (i // 10) % 2 == 0 else 0
            self.tasc_value = i % 80
            self.atc_code_val = 75
            self.dtg = 1000 - i
            
            self.pstn = 0
            if 10 <= i < 15: self.pstn = 1    
            elif 30 <= i < 35: self.pstn = 32 
            elif 60 <= i < 80: self.pstn = 134 
            
            self.twct_enable = (60 <= i < 70)
            
    app = QApplication(sys.argv)
    w = GraphView(1)
    w.set_data([Dummy(i) for i in range(5000)]) 
    w.resize(1200, 600)
    w.show()
    sys.exit(app.exec())