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())