""" matplotlib 기반 차트 위젯. stats_view에서 주간/월간 추세를 시각화. matplotlib 미설치 시 ImportError 안내 라벨로 fallback. """ from typing import List, Tuple from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel from PyQt5.QtCore import Qt try: from matplotlib.figure import Figure from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas import matplotlib matplotlib.rcParams['font.family'] = ['Malgun Gothic', 'Apple SD Gothic Neo', 'sans-serif'] matplotlib.rcParams['axes.unicode_minus'] = False _MPL = True except ImportError: _MPL = False # 다크 테마 색상 (dark_components 톤과 일치) _CHART_BG = '#14141c' _CHART_GRID = '#2a2a3a' _CHART_TEXT = '#c0c0d0' _CHART_BAR_NORMAL = '#6b9eff' # blue _CHART_BAR_OVERTIME = '#ff90b8' # pink _CHART_BAR_WEEKEND = '#fcd34d' # gold _CHART_AVG_LINE = '#4ade80' # green def _apply_dark_axes(ax) -> None: """차트 ax에 다크 테마 적용 — 텍스트, 그리드, spines, 배경.""" ax.set_facecolor(_CHART_BG) ax.tick_params(axis='both', colors=_CHART_TEXT) ax.xaxis.label.set_color(_CHART_TEXT) ax.yaxis.label.set_color(_CHART_TEXT) ax.title.set_color(_CHART_TEXT) for spine in ax.spines.values(): spine.set_color(_CHART_GRID) ax.grid(axis='y', alpha=0.25, color=_CHART_GRID) def _apply_dark_figure(fig) -> None: """figure 배경을 다크 톤으로.""" fig.patch.set_facecolor(_CHART_BG) class _Fallback(QWidget): """matplotlib 미설치 시 안내.""" def __init__(self, message: str): super().__init__() layout = QVBoxLayout() label = QLabel(message) label.setAlignment(Qt.AlignCenter) label.setWordWrap(True) label.setStyleSheet("color: #888; padding: 20px;") layout.addWidget(label) self.setLayout(layout) def make_chart_widget(parent=None) -> QWidget: """차트가 그려질 빈 캔버스 위젯. matplotlib 없으면 fallback.""" if not _MPL: return _Fallback("차트 표시에는 matplotlib가 필요합니다.\npip install matplotlib") widget = QWidget(parent) widget.setStyleSheet(f"background: {_CHART_BG}; border-radius: 8px;") layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) fig = Figure(figsize=(5, 3), dpi=100, tight_layout=True, facecolor=_CHART_BG) canvas = FigureCanvas(fig) canvas.setStyleSheet(f"background: {_CHART_BG};") layout.addWidget(canvas) widget.setLayout(layout) widget._figure = fig widget._canvas = canvas return widget def draw_daily_hours(widget: QWidget, records: List[dict]) -> None: """일별 근무시간 막대 그래프 (호버 시 정확한 수치 툴팁).""" if not getattr(widget, '_figure', None): return fig = widget._figure fig.clear() _apply_dark_figure(fig) if not records: ax = fig.add_subplot(111) _apply_dark_axes(ax) ax.text(0.5, 0.5, '기록 없음', ha='center', va='center', transform=ax.transAxes, color=_CHART_TEXT, fontsize=11) widget._canvas.draw() return dates = [r['date'][5:] for r in records] # MM-DD만 full_dates = [r['date'] for r in records] hours = [r.get('total_hours', 0) or 0 for r in records] overtimes = [(r.get('overtime_minutes', 0) or 0) / 60 for r in records] base = [max(h - o, 0) for h, o in zip(hours, overtimes)] ax = fig.add_subplot(111) bars_base = ax.bar(dates, base, label='정상', color=_CHART_BAR_NORMAL) bars_ot = ax.bar(dates, overtimes, bottom=base, label='연장', color=_CHART_BAR_OVERTIME) ax.set_ylabel('시간') legend = ax.legend(loc='upper left', fontsize=8, facecolor=_CHART_BG, edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT) ax.tick_params(axis='x', labelrotation=45, labelsize=8) _apply_dark_axes(ax) # 호버 annotation 설정 annot = ax.annotate( "", xy=(0, 0), xytext=(15, 15), textcoords="offset points", bbox=dict(boxstyle="round,pad=0.4", fc="#1a1a26", ec=_CHART_BAR_NORMAL, alpha=0.95), color="white", fontsize=9, arrowprops=dict(arrowstyle="->", color=_CHART_BAR_NORMAL), ) annot.set_visible(False) def on_hover(event): if event.inaxes != ax: if annot.get_visible(): annot.set_visible(False) widget._canvas.draw_idle() return for bars, kind in ((bars_base, 'base'), (bars_ot, 'ot')): for i, bar in enumerate(bars): if bar.contains(event)[0]: h = hours[i]; ot = overtimes[i] text = f"▼ {full_dates[i]}\n근무 {h:.1f}h" if ot > 0: text += f"\n연장 +{ot:.1f}h" annot.xy = (bar.get_x() + bar.get_width() / 2, bar.get_height() + bar.get_y()) annot.set_text(text) annot.set_visible(True) widget._canvas.draw_idle() # 도전과제 #stat_chart_hover — 첫 발견 시 1회만 기록 db = getattr(widget, '_achievement_db', None) if db is not None: try: if db.get_setting('chart_hover_discovered', 'false').lower() != 'true': db.set_setting('chart_hover_discovered', 'true') except Exception: pass return if annot.get_visible(): annot.set_visible(False) widget._canvas.draw_idle() widget._canvas.mpl_connect("motion_notify_event", on_hover) widget._canvas.draw() def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None: """출근 시각 분포 히스토그램 (30분 빈).""" if not getattr(widget, '_figure', None): return fig = widget._figure fig.clear() _apply_dark_figure(fig) if not records: ax = fig.add_subplot(111) _apply_dark_axes(ax) ax.text(0.5, 0.5, '기록 없음', ha='center', va='center', transform=ax.transAxes, color=_CHART_TEXT, fontsize=11) widget._canvas.draw() return minutes_list = [] for r in records: ci = r.get('clock_in') if not ci: continue parts = ci.split(':') if len(parts) >= 2: try: minutes_list.append(int(parts[0]) * 60 + int(parts[1])) except ValueError: pass if not minutes_list: ax = fig.add_subplot(111) _apply_dark_axes(ax) ax.text(0.5, 0.5, '기록 없음', ha='center', va='center', transform=ax.transAxes, color=_CHART_TEXT, fontsize=11) widget._canvas.draw() return bin_size = 30 min_m = (min(minutes_list) // bin_size) * bin_size max_m = ((max(minutes_list) // bin_size) + 1) * bin_size bins = list(range(min_m, max_m + bin_size, bin_size)) ax = fig.add_subplot(111) ax.hist(minutes_list, bins=bins, color=_CHART_BAR_NORMAL, edgecolor=_CHART_BG, linewidth=1) avg = sum(minutes_list) / len(minutes_list) ax.axvline(avg, color=_CHART_AVG_LINE, linestyle='--', linewidth=2, label=f'평균 {int(avg//60):02d}:{int(avg%60):02d}') ax.set_xticks([m for m in bins if m % 60 == 0]) ax.set_xticklabels([f"{m//60:02d}:00" for m in bins if m % 60 == 0], rotation=45, fontsize=8) ax.set_ylabel('일수') legend = ax.legend(loc='upper right', fontsize=8, facecolor=_CHART_BG, edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT) _apply_dark_axes(ax) widget._canvas.draw() def draw_weekday_avg(widget: QWidget, records: List[dict]) -> None: """요일별 평균 근무시간 막대 그래프.""" if not getattr(widget, '_figure', None): return fig = widget._figure fig.clear() _apply_dark_figure(fig) from datetime import datetime as _dt weekday_totals = [0.0] * 7 weekday_counts = [0] * 7 for r in records: try: d = _dt.strptime(r['date'], '%Y-%m-%d') except (ValueError, TypeError): continue weekday_totals[d.weekday()] += r.get('total_hours', 0) or 0 weekday_counts[d.weekday()] += 1 avg = [(t / c) if c else 0 for t, c in zip(weekday_totals, weekday_counts)] labels = ['월', '화', '수', '목', '금', '토', '일'] ax = fig.add_subplot(111) colors = [_CHART_BAR_NORMAL] * 5 + [_CHART_BAR_WEEKEND] * 2 # 주말 골드 강조 ax.bar(labels, avg, color=colors) ax.set_ylabel('평균 시간') _apply_dark_axes(ax) widget._canvas.draw()