KINDNICK e7e85dcf7b v2.11.1: 통계 차트(frozen) 수정 + 통계/도움말/도전과제 테마 대응
- fix: main.exe에서 통계 차트 안 뜨던 문제 (backend_qt5agg→backend_qtagg 우선 import + spec 보강 + 실패 로깅)
- fix: 통계/도움말/도전과제 + 차트가 라이트 테마에서도 다크 고정 → 현재 테마(ThemeColors) 추종
- dark_components/chart_widget를 테마 인식형으로 리팩터 (등급 카드·차트 막대 등 강조색은 유지)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:08:24 +09:00

268 lines
10 KiB
Python

"""
matplotlib 기반 차트 위젯.
stats_view에서 주간/월간 추세를 시각화. matplotlib 미설치 시
ImportError 안내 라벨로 fallback.
"""
from typing import List, Tuple
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
from PyQt5.QtCore import Qt
try:
import matplotlib
from matplotlib.figure import Figure
# frozen(main.exe) 빌드는 PyInstaller matplotlib hook이 'QtAgg'(backend_qtagg)만
# 번들함 → backend_qt5agg import가 실패해 차트가 안 뜨던 문제.
# 번들된 backend_qtagg를 우선 사용하고, 구버전(dev) 호환으로 qt5agg 폴백.
try:
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
except Exception:
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
matplotlib.rcParams['font.family'] = ['NanumSquare', 'Malgun Gothic', 'Apple SD Gothic Neo', 'sans-serif']
matplotlib.rcParams['axes.unicode_minus'] = False
_MPL = True
except Exception as _mpl_err:
# ImportError 외 backend/sip 로딩 오류도 폴백 처리 + 실제 원인 기록(진단용)
_MPL = False
try:
from utils.debug_log import dlog
dlog(f"chart_widget: matplotlib unavailable: {type(_mpl_err).__name__}: {_mpl_err}")
except Exception:
pass
# 차트 색상 — 배경/그리드/텍스트는 현재 테마를 따름(_refresh_chart_colors),
# 막대/선은 데이터 구분용 고정 색.
_CHART_BG = '#25262B'
_CHART_GRID = '#2C2E33'
_CHART_TEXT = '#909296'
_CHART_BAR_NORMAL = '#4DABF7' # accent blue
_CHART_BAR_OVERTIME = '#ff90b8' # pink (데이터 구분용)
_CHART_BAR_WEEKEND = '#fcd34d' # gold (데이터 구분용)
_CHART_AVG_LINE = '#51CF66' # green
def _refresh_chart_colors() -> None:
"""배경/그리드/텍스트 색을 현재 앱 테마로 갱신 (라이트/다크 추종)."""
global _CHART_BG, _CHART_GRID, _CHART_TEXT
try:
from ui.styles import ThemeColors
_CHART_BG = ThemeColors.get('bg_secondary')
_CHART_GRID = ThemeColors.get('border_subtle')
_CHART_TEXT = ThemeColors.get('text_secondary')
except Exception:
pass
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 배경을 현재 테마 톤으로 (모든 draw_* 진입점에서 호출됨)."""
_refresh_chart_colors()
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: #909296; 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")
_refresh_chart_colors()
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()