3488 lines
143 KiB
Python
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()) |