AI_MMI_Analyser/app/ui/views/graph_view.py

3488 lines
143 KiB
Python

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