495 lines
16 KiB
Python
495 lines
16 KiB
Python
"""
|
|
차트 생성 유틸리티
|
|
|
|
matplotlib 및 wordcloud를 사용하여 VOC 통계 차트를 생성합니다.
|
|
한글 폰트 설정, 메모리 관리, 파일 저장 등을 처리합니다.
|
|
|
|
주요 기능:
|
|
- 막대 그래프 (부서별, 상태별)
|
|
- 선 그래프 (시계열 추이)
|
|
- 파이 차트 (비율)
|
|
- 워드 클라우드 (키워드)
|
|
|
|
작성자: KH.Choi
|
|
최종 수정: 2026-02-18
|
|
버전: 1.0
|
|
"""
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
import matplotlib
|
|
matplotlib.use('Agg') # GUI 없이 백그라운드에서 실행
|
|
import matplotlib.pyplot as plt
|
|
import matplotlib.font_manager as fm
|
|
from collections import Counter
|
|
|
|
from utils.logger import get_logger
|
|
from utils.path_utils import get_data_dir
|
|
|
|
|
|
class ChartGenerator:
|
|
"""
|
|
차트 생성 유틸리티 클래스
|
|
|
|
matplotlib을 사용하여 다양한 형태의 차트를 생성합니다.
|
|
한글 폰트 설정, 메모리 관리, 파일 저장을 자동으로 처리합니다.
|
|
|
|
Attributes:
|
|
logger: 로거 인스턴스
|
|
output_dir (Path): 차트 저장 디렉토리
|
|
font_name (str): 사용할 한글 폰트명
|
|
|
|
주요 메서드:
|
|
create_bar_chart: 막대 그래프 생성
|
|
create_line_chart: 선 그래프 생성
|
|
create_pie_chart: 파이 차트 생성
|
|
create_wordcloud: 워드 클라우드 생성
|
|
|
|
사용 예시:
|
|
>>> generator = ChartGenerator()
|
|
>>> data = {"차량": 80, "시설": 50, "역무": 20}
|
|
>>> path = generator.create_bar_chart(data, "부서별 VOC 현황")
|
|
"""
|
|
|
|
def __init__(self, output_dir: Optional[Path] = None):
|
|
"""
|
|
차트 생성기 초기화
|
|
|
|
Args:
|
|
output_dir (Path, optional): 차트 저장 디렉토리. 기본값은 data/charts
|
|
"""
|
|
self.logger = get_logger("ChartGenerator")
|
|
self.output_dir = output_dir or (get_data_dir() / "charts")
|
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# 한글 폰트 설정
|
|
self._setup_korean_font()
|
|
|
|
def _setup_korean_font(self):
|
|
"""
|
|
한글 폰트 설정
|
|
|
|
시스템에 설치된 한글 폰트를 찾아 matplotlib에 설정합니다.
|
|
폰트를 찾지 못하면 기본 폰트를 사용합니다.
|
|
"""
|
|
try:
|
|
# Windows 기본 한글 폰트 시도
|
|
font_candidates = [
|
|
'Malgun Gothic', # 맑은 고딕
|
|
'NanumGothic', # 나눔고딕
|
|
'NanumBarunGothic',
|
|
'Gulim', # 굴림
|
|
'Dotum' # 돋움
|
|
]
|
|
|
|
available_fonts = [f.name for f in fm.fontManager.ttflist]
|
|
|
|
for font in font_candidates:
|
|
if font in available_fonts:
|
|
plt.rcParams['font.family'] = font
|
|
self.font_name = font
|
|
self.logger.info(f"한글 폰트 설정: {font}")
|
|
break
|
|
else:
|
|
# 한글 폰트를 찾지 못한 경우
|
|
self.logger.warning("한글 폰트를 찾을 수 없습니다. 기본 폰트를 사용합니다.")
|
|
self.font_name = plt.rcParams['font.family']
|
|
|
|
# 마이너스 기호 깨짐 방지
|
|
plt.rcParams['axes.unicode_minus'] = False
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"폰트 설정 실패: {e}")
|
|
self.font_name = None
|
|
|
|
def create_bar_chart(
|
|
self,
|
|
data: Dict[str, int],
|
|
title: str,
|
|
xlabel: str = "항목",
|
|
ylabel: str = "건수",
|
|
filename: Optional[str] = None
|
|
) -> str:
|
|
"""
|
|
막대 그래프 생성
|
|
|
|
Args:
|
|
data: 데이터 딕셔너리 {항목명: 값}
|
|
title: 차트 제목
|
|
xlabel: X축 레이블
|
|
ylabel: Y축 레이블
|
|
filename: 저장 파일명 (선택, 기본값은 자동 생성)
|
|
|
|
Returns:
|
|
str: 저장된 파일 경로
|
|
|
|
Raises:
|
|
ValueError: 데이터가 비어있는 경우
|
|
"""
|
|
if not data:
|
|
raise ValueError("차트 생성을 위한 데이터가 비어있습니다.")
|
|
|
|
try:
|
|
# 그래프 생성
|
|
fig, ax = plt.subplots(figsize=(10, 6))
|
|
|
|
items = list(data.keys())
|
|
values = list(data.values())
|
|
|
|
bars = ax.bar(items, values, color='steelblue', alpha=0.8)
|
|
|
|
# 막대 위에 값 표시
|
|
for bar in bars:
|
|
height = bar.get_height()
|
|
ax.text(
|
|
bar.get_x() + bar.get_width() / 2.,
|
|
height,
|
|
f'{int(height)}',
|
|
ha='center',
|
|
va='bottom',
|
|
fontsize=10
|
|
)
|
|
|
|
ax.set_xlabel(xlabel, fontsize=12)
|
|
ax.set_ylabel(ylabel, fontsize=12)
|
|
ax.set_title(title, fontsize=14, fontweight='bold')
|
|
ax.grid(axis='y', alpha=0.3)
|
|
|
|
plt.xticks(rotation=45, ha='right')
|
|
plt.tight_layout()
|
|
|
|
# 파일 저장
|
|
if not filename:
|
|
filename = f"bar_chart_{title.replace(' ', '_')}.png"
|
|
|
|
filepath = self.output_dir / filename
|
|
plt.savefig(filepath, dpi=150, bbox_inches='tight')
|
|
plt.close(fig) # 메모리 해제
|
|
|
|
self.logger.info(f"막대 그래프 생성 완료: {filepath}")
|
|
return str(filepath)
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"막대 그래프 생성 실패: {e}")
|
|
plt.close('all') # 에러 시에도 메모리 해제
|
|
raise
|
|
|
|
def create_line_chart(
|
|
self,
|
|
data: Dict[str, int],
|
|
title: str,
|
|
xlabel: str = "날짜",
|
|
ylabel: str = "건수",
|
|
filename: Optional[str] = None
|
|
) -> str:
|
|
"""
|
|
선 그래프 생성 (시계열 데이터)
|
|
|
|
Args:
|
|
data: 데이터 딕셔너리 {날짜: 값}
|
|
title: 차트 제목
|
|
xlabel: X축 레이블
|
|
ylabel: Y축 레이블
|
|
filename: 저장 파일명 (선택)
|
|
|
|
Returns:
|
|
str: 저장된 파일 경로
|
|
"""
|
|
if not data:
|
|
raise ValueError("차트 생성을 위한 데이터가 비어있습니다.")
|
|
|
|
try:
|
|
fig, ax = plt.subplots(figsize=(12, 6))
|
|
|
|
dates = list(data.keys())
|
|
values = list(data.values())
|
|
|
|
ax.plot(dates, values, marker='o', linewidth=2, markersize=6, color='steelblue')
|
|
|
|
# 데이터 포인트에 값 표시 (데이터가 많지 않을 때만)
|
|
if len(dates) <= 31:
|
|
for i, (date, value) in enumerate(zip(dates, values)):
|
|
ax.text(i, value, str(value), ha='center', va='bottom', fontsize=9)
|
|
|
|
ax.set_xlabel(xlabel, fontsize=12)
|
|
ax.set_ylabel(ylabel, fontsize=12)
|
|
ax.set_title(title, fontsize=14, fontweight='bold')
|
|
ax.grid(True, alpha=0.3)
|
|
|
|
# X축 레이블 회전 (날짜가 많을 때)
|
|
if len(dates) > 10:
|
|
plt.xticks(rotation=45, ha='right')
|
|
|
|
plt.tight_layout()
|
|
|
|
if not filename:
|
|
filename = f"line_chart_{title.replace(' ', '_')}.png"
|
|
|
|
filepath = self.output_dir / filename
|
|
plt.savefig(filepath, dpi=150, bbox_inches='tight')
|
|
plt.close(fig)
|
|
|
|
self.logger.info(f"선 그래프 생성 완료: {filepath}")
|
|
return str(filepath)
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"선 그래프 생성 실패: {e}")
|
|
plt.close('all')
|
|
raise
|
|
|
|
def create_pie_chart(
|
|
self,
|
|
data: Dict[str, int],
|
|
title: str,
|
|
filename: Optional[str] = None
|
|
) -> str:
|
|
"""
|
|
파이 차트 생성
|
|
|
|
Args:
|
|
data: 데이터 딕셔너리 {항목명: 값}
|
|
title: 차트 제목
|
|
filename: 저장 파일명 (선택)
|
|
|
|
Returns:
|
|
str: 저장된 파일 경로
|
|
"""
|
|
if not data:
|
|
raise ValueError("차트 생성을 위한 데이터가 비어있습니다.")
|
|
|
|
try:
|
|
fig, ax = plt.subplots(figsize=(10, 8))
|
|
|
|
labels = list(data.keys())
|
|
sizes = list(data.values())
|
|
|
|
# 색상 팔레트
|
|
colors = plt.cm.Set3(range(len(labels)))
|
|
|
|
# 파이 차트 생성
|
|
wedges, texts, autotexts = ax.pie(
|
|
sizes,
|
|
labels=labels,
|
|
autopct='%1.1f%%',
|
|
startangle=90,
|
|
colors=colors,
|
|
textprops={'fontsize': 11}
|
|
)
|
|
|
|
# 퍼센트 텍스트 스타일
|
|
for autotext in autotexts:
|
|
autotext.set_color('white')
|
|
autotext.set_fontweight('bold')
|
|
|
|
ax.set_title(title, fontsize=14, fontweight='bold', pad=20)
|
|
|
|
plt.tight_layout()
|
|
|
|
if not filename:
|
|
filename = f"pie_chart_{title.replace(' ', '_')}.png"
|
|
|
|
filepath = self.output_dir / filename
|
|
plt.savefig(filepath, dpi=150, bbox_inches='tight')
|
|
plt.close(fig)
|
|
|
|
self.logger.info(f"파이 차트 생성 완료: {filepath}")
|
|
return str(filepath)
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"파이 차트 생성 실패: {e}")
|
|
plt.close('all')
|
|
raise
|
|
|
|
def create_wordcloud(
|
|
self,
|
|
texts: List[str],
|
|
title: str,
|
|
max_words: int = 50,
|
|
filename: Optional[str] = None
|
|
) -> str:
|
|
"""
|
|
워드 클라우드 생성
|
|
|
|
Args:
|
|
texts: 텍스트 리스트
|
|
title: 차트 제목
|
|
max_words: 최대 단어 수
|
|
filename: 저장 파일명 (선택)
|
|
|
|
Returns:
|
|
str: 저장된 파일 경로
|
|
|
|
Note:
|
|
wordcloud 라이브러리가 설치되어 있어야 합니다.
|
|
한글 불용어 제거 및 빈도 기반 생성을 지원합니다.
|
|
"""
|
|
if not texts:
|
|
raise ValueError("워드 클라우드 생성을 위한 텍스트가 비어있습니다.")
|
|
|
|
try:
|
|
# wordcloud 라이브러리 import (선택적 의존성)
|
|
try:
|
|
from wordcloud import WordCloud
|
|
except ImportError:
|
|
self.logger.warning("wordcloud 라이브러리가 설치되지 않았습니다. 간단한 빈도 차트를 생성합니다.")
|
|
return self._create_keyword_bar_chart(texts, title, max_words, filename)
|
|
|
|
# 텍스트 전처리 및 단어 추출
|
|
word_freq = self._extract_word_frequencies(texts)
|
|
|
|
if not word_freq:
|
|
self.logger.warning("추출된 키워드가 없습니다. 빈 차트를 생성합니다.")
|
|
raise ValueError("유효한 키워드가 없습니다.")
|
|
|
|
# 워드 클라우드 생성 (빈도 딕셔너리 사용)
|
|
wordcloud = WordCloud(
|
|
font_path=self._get_font_path(),
|
|
width=1200,
|
|
height=800,
|
|
background_color='white',
|
|
max_words=max_words,
|
|
relative_scaling=0.3,
|
|
min_font_size=10,
|
|
colormap='viridis',
|
|
prefer_horizontal=0.7,
|
|
collocations=False # 단어 조합 비활성화
|
|
).generate_from_frequencies(word_freq)
|
|
|
|
# 그래프 생성
|
|
fig, ax = plt.subplots(figsize=(14, 10))
|
|
ax.imshow(wordcloud, interpolation='bilinear')
|
|
ax.axis('off')
|
|
ax.set_title(title, fontsize=16, fontweight='bold', pad=20)
|
|
|
|
plt.tight_layout(pad=0)
|
|
|
|
if not filename:
|
|
filename = f"wordcloud_{title.replace(' ', '_')}.png"
|
|
|
|
filepath = self.output_dir / filename
|
|
plt.savefig(filepath, dpi=200, bbox_inches='tight', facecolor='white')
|
|
plt.close(fig)
|
|
|
|
self.logger.info(f"워드 클라우드 생성 완료: {filepath} (단어 수: {len(word_freq)})")
|
|
return str(filepath)
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"워드 클라우드 생성 실패: {e}")
|
|
plt.close('all')
|
|
raise
|
|
|
|
def _extract_word_frequencies(self, texts: List[str]) -> dict:
|
|
"""
|
|
텍스트에서 단어 빈도 추출 (내부 메서드)
|
|
|
|
Args:
|
|
texts: 텍스트 리스트
|
|
|
|
Returns:
|
|
dict: {단어: 빈도}
|
|
"""
|
|
# 한글 불용어 (조사, 접속사 등)
|
|
stopwords = {
|
|
# 조사
|
|
"의", "를", "이", "가", "은", "는", "에", "에서", "으로", "로", "와", "과",
|
|
"도", "만", "까지", "부터", "께서", "에게", "한테", "보다", "처럼",
|
|
# 접속사/부사
|
|
"그리고", "그러나", "하지만", "또한", "또", "및", "등", "즉",
|
|
# 기타
|
|
"것", "수", "등등", "때문", "위해", "대한", "통해", "관련", "경우",
|
|
"있습니다", "없습니다", "합니다", "됩니다", "입니다",
|
|
# VOC 관련 일반 단어 (너무 흔한 것들)
|
|
"고객", "민원", "문의", "요청", "답변", "처리", "확인"
|
|
}
|
|
|
|
all_words = []
|
|
|
|
for text in texts:
|
|
if not text:
|
|
continue
|
|
|
|
# 공백 기준 단어 분리
|
|
words = text.split()
|
|
|
|
for word in words:
|
|
# 길이 체크 (2글자 이상, 15글자 이하)
|
|
if len(word) < 2 or len(word) > 15:
|
|
continue
|
|
|
|
# 불용어 제거
|
|
if word in stopwords:
|
|
continue
|
|
|
|
# 숫자만 있는 단어 제거
|
|
if word.isdigit():
|
|
continue
|
|
|
|
# 특수문자만 있는 단어 제거
|
|
if not any(c.isalnum() for c in word):
|
|
continue
|
|
|
|
all_words.append(word)
|
|
|
|
# 빈도 계산
|
|
word_counts = Counter(all_words)
|
|
|
|
# 빈도가 2 이상인 단어만 (너무 드문 단어 제거)
|
|
filtered_counts = {word: count for word, count in word_counts.items() if count >= 2}
|
|
|
|
return filtered_counts
|
|
|
|
def _create_keyword_bar_chart(
|
|
self,
|
|
texts: List[str],
|
|
title: str,
|
|
top_n: int = 10,
|
|
filename: Optional[str] = None
|
|
) -> str:
|
|
"""
|
|
키워드 빈도 막대 그래프 생성 (워드 클라우드 대체)
|
|
|
|
Args:
|
|
texts: 텍스트 리스트
|
|
title: 차트 제목
|
|
top_n: 상위 N개 키워드
|
|
filename: 저장 파일명
|
|
|
|
Returns:
|
|
str: 저장된 파일 경로
|
|
"""
|
|
# 간단한 단어 추출 (공백 기준)
|
|
all_words = []
|
|
for text in texts:
|
|
words = text.split()
|
|
# 2글자 이상만 추출
|
|
all_words.extend([w for w in words if len(w) >= 2])
|
|
|
|
# 빈도 계산
|
|
word_counts = Counter(all_words)
|
|
top_words = dict(word_counts.most_common(top_n))
|
|
|
|
# 막대 그래프 생성
|
|
return self.create_bar_chart(
|
|
top_words,
|
|
f"{title} (TOP {top_n})",
|
|
xlabel="키워드",
|
|
ylabel="빈도",
|
|
filename=filename or f"keyword_bar_{title.replace(' ', '_')}.png"
|
|
)
|
|
|
|
def _get_font_path(self) -> Optional[str]:
|
|
"""
|
|
한글 폰트 경로 반환
|
|
|
|
Returns:
|
|
str: 폰트 파일 경로 (없으면 None)
|
|
"""
|
|
if not self.font_name:
|
|
return None
|
|
|
|
for font in fm.fontManager.ttflist:
|
|
if font.name == self.font_name:
|
|
return font.fname
|
|
|
|
return None
|