Spaces:
Runtime error
Runtime error
import pandas as pd | |
import numpy as np | |
import matplotlib.pyplot as plt | |
import seaborn as sns | |
from datetime import datetime, timedelta | |
import logging | |
logger = logging.getLogger(__name__) | |
class JiraVisualizer: | |
""" | |
Клас для створення візуалізацій даних Jira | |
""" | |
def __init__(self, df): | |
""" | |
Ініціалізація візуалізатора. | |
Args: | |
df (pandas.DataFrame): DataFrame з даними Jira | |
""" | |
self.df = df | |
self._setup_plot_style() | |
def _setup_plot_style(self): | |
""" | |
Налаштування стилю візуалізацій. | |
""" | |
plt.style.use('ggplot') | |
sns.set(style="whitegrid") | |
# Налаштування для українських символів | |
plt.rcParams['font.family'] = 'DejaVu Sans' | |
def plot_status_counts(self): | |
""" | |
Створення діаграми розподілу тікетів за статусами. | |
Returns: | |
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки | |
""" | |
try: | |
if 'Status' not in self.df.columns: | |
logger.warning("Колонка 'Status' відсутня") | |
return None | |
status_counts = self.df['Status'].value_counts() | |
fig, ax = plt.subplots(figsize=(10, 6)) | |
# Спроба впорядкувати статуси логічно | |
try: | |
status_order = ['To Do', 'In Progress', 'In Review', 'Done', 'Closed'] | |
available_statuses = [s for s in status_order if s in status_counts.index] | |
other_statuses = [s for s in status_counts.index if s not in status_order] | |
ordered_statuses = available_statuses + other_statuses | |
status_counts = status_counts.reindex(ordered_statuses) | |
except Exception as ex: | |
logger.warning(f"Не вдалося впорядкувати статуси: {ex}") | |
sns.barplot(x=status_counts.index, y=status_counts.values, ax=ax) | |
for i, v in enumerate(status_counts.values): | |
ax.text(i, v + 0.5, str(v), ha='center') | |
ax.set_title('Розподіл тікетів за статусами') | |
ax.set_xlabel('Статус') | |
ax.set_ylabel('Кількість') | |
plt.xticks(rotation=45) | |
plt.tight_layout() | |
logger.info("Діаграма статусів успішно створена") | |
return fig | |
except Exception as e: | |
logger.error(f"Помилка при створенні діаграми статусів: {e}") | |
return None | |
def plot_priority_counts(self): | |
""" | |
Створення діаграми розподілу тікетів за пріоритетами. | |
Returns: | |
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки | |
""" | |
try: | |
if 'Priority' not in self.df.columns: | |
logger.warning("Колонка 'Priority' відсутня") | |
return None | |
priority_counts = self.df['Priority'].value_counts() | |
fig, ax = plt.subplots(figsize=(10, 6)) | |
# Спроба впорядкувати пріоритети логічно | |
try: | |
priority_order = ['Highest', 'High', 'Medium', 'Low', 'Lowest'] | |
available_priorities = [p for p in priority_order if p in priority_counts.index] | |
other_priorities = [p for p in priority_counts.index if p not in priority_order] | |
ordered_priorities = available_priorities + other_priorities | |
priority_counts = priority_counts.reindex(ordered_priorities) | |
except Exception as ex: | |
logger.warning(f"Не вдалося впорядкувати пріоритети: {ex}") | |
colors = ['#FF5555', '#FF9C5A', '#FFCC5A', '#5AFF96', '#5AC8FF'] | |
if len(priority_counts) <= len(colors): | |
sns.barplot(x=priority_counts.index, y=priority_counts.values, ax=ax, palette=colors[:len(priority_counts)]) | |
else: | |
sns.barplot(x=priority_counts.index, y=priority_counts.values, ax=ax) | |
for i, v in enumerate(priority_counts.values): | |
ax.text(i, v + 0.5, str(v), ha='center') | |
ax.set_title('Розподіл тікетів за пріоритетами') | |
ax.set_xlabel('Пріоритет') | |
ax.set_ylabel('Кількість') | |
plt.xticks(rotation=45) | |
plt.tight_layout() | |
logger.info("Діаграма пріоритетів успішно створена") | |
return fig | |
except Exception as e: | |
logger.error(f"Помилка при створенні діаграми пріоритетів: {e}") | |
return None | |
def plot_type_counts(self): | |
""" | |
Створення діаграми розподілу тікетів за типами. | |
Returns: | |
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки | |
""" | |
try: | |
if 'Issue Type' not in self.df.columns: | |
logger.warning("Колонка 'Issue Type' відсутня") | |
return None | |
type_counts = self.df['Issue Type'].value_counts() | |
fig, ax = plt.subplots(figsize=(10, 6)) | |
sns.barplot(x=type_counts.index, y=type_counts.values, ax=ax) | |
for i, v in enumerate(type_counts.values): | |
ax.text(i, v + 0.5, str(v), ha='center') | |
ax.set_title('Розподіл тікетів за типами') | |
ax.set_xlabel('Тип') | |
ax.set_ylabel('Кількість') | |
plt.xticks(rotation=45) | |
plt.tight_layout() | |
logger.info("Діаграма типів успішно створена") | |
return fig | |
except Exception as e: | |
logger.error(f"Помилка при створенні діаграми типів: {e}") | |
return None | |
def plot_created_timeline(self): | |
""" | |
Створення часової діаграми створення тікетів. | |
Returns: | |
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки | |
""" | |
try: | |
if 'Created' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Created']): | |
logger.warning("Колонка 'Created' відсутня або не містить дат") | |
return None | |
if 'Created_Date' not in self.df.columns: | |
self.df['Created_Date'] = self.df['Created'].dt.date | |
created_by_date = self.df['Created_Date'].value_counts().sort_index() | |
fig, ax = plt.subplots(figsize=(12, 6)) | |
created_by_date.plot(kind='line', marker='o', ax=ax) | |
ax.set_title('Кількість створених тікетів за датами') | |
ax.set_xlabel('Дата') | |
ax.set_ylabel('Кількість') | |
ax.grid(True) | |
plt.tight_layout() | |
logger.info("Часова діаграма успішно створена") | |
return fig | |
except Exception as e: | |
logger.error(f"Помилка при створенні часової діаграми: {e}") | |
return None | |
def plot_inactive_issues(self, days=14): | |
""" | |
Створення діаграми неактивних тікетів. | |
Args: | |
days (int): Кількість днів неактивності | |
Returns: | |
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки | |
""" | |
try: | |
if 'Updated' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Updated']): | |
logger.warning("Колонка 'Updated' відсутня або не містить дат") | |
return None | |
cutoff_date = datetime.now() - timedelta(days=days) | |
inactive_issues = self.df[self.df['Updated'] < cutoff_date] | |
if len(inactive_issues) == 0: | |
logger.warning("Немає неактивних тікетів для візуалізації") | |
return None | |
if 'Status' in inactive_issues.columns: | |
inactive_by_status = inactive_issues['Status'].value_counts() | |
fig, ax = plt.subplots(figsize=(10, 6)) | |
sns.barplot(x=inactive_by_status.index, y=inactive_by_status.values, ax=ax) | |
for i, v in enumerate(inactive_by_status.values): | |
ax.text(i, v + 0.5, str(v), ha='center') | |
ax.set_title(f'Розподіл неактивних тікетів за статусами (>{days} днів)') | |
ax.set_xlabel('Статус') | |
ax.set_ylabel('Кількість') | |
plt.xticks(rotation=45) | |
plt.tight_layout() | |
logger.info("Діаграма неактивних тікетів успішно створена") | |
return fig | |
else: | |
logger.warning("Колонка 'Status' відсутня для неактивних тікетів") | |
return None | |
except Exception as e: | |
logger.error(f"Помилка при створенні діаграми неактивних тікетів: {e}") | |
return None | |
def plot_status_timeline(self, timeline_df=None): | |
""" | |
Створення діаграми зміни статусів з часом. | |
Args: | |
timeline_df (pandas.DataFrame): DataFrame з часовими даними або None для автоматичного генерування | |
Returns: | |
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки | |
""" | |
try: | |
if timeline_df is None: | |
if 'Created' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Created']): | |
logger.warning("Колонка 'Created' відсутня або не містить дат") | |
return None | |
if 'Updated' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Updated']): | |
logger.warning("Колонка 'Updated' відсутня або не містить дат") | |
return None | |
min_date = self.df['Created'].min().date() | |
max_date = self.df['Updated'].max().date() | |
date_range = pd.date_range(start=min_date, end=max_date, freq='D') | |
timeline_data = [] | |
for date in date_range: | |
date_str = date.strftime('%Y-%m-%d') | |
created_until = self.df[self.df['Created'].dt.date <= date.date()] | |
status_counts = {} | |
for _, row in created_until.iterrows(): | |
if row['Updated'].date() >= date.date(): | |
status = row.get('Status', 'Unknown') | |
status_counts[status] = status_counts.get(status, 0) + 1 | |
timeline_data.append({ | |
'Date': date_str, | |
'Total': len(created_until), | |
**status_counts | |
}) | |
timeline_df = pd.DataFrame(timeline_data) | |
timeline_df['Date'] = pd.to_datetime(timeline_df['Date']) | |
else: | |
if not pd.api.types.is_datetime64_dtype(timeline_df['Date']): | |
timeline_df['Date'] = pd.to_datetime(timeline_df['Date']) | |
status_columns = [col for col in timeline_df.columns if col not in ['Date', 'Total']] | |
if not status_columns: | |
logger.warning("Немає даних про статуси для візуалізації") | |
return None | |
fig, ax = plt.subplots(figsize=(14, 8)) | |
status_data = timeline_df[['Date'] + status_columns].set_index('Date') | |
status_data.plot.area(ax=ax, stacked=True, alpha=0.7) | |
ax.set_title('Зміна статусів тікетів з часом') | |
ax.set_xlabel('Дата') | |
ax.set_ylabel('Кількість тікетів') | |
ax.grid(True) | |
plt.tight_layout() | |
logger.info("Часова діаграма статусів успішно створена") | |
return fig | |
except Exception as e: | |
logger.error(f"Помилка при створенні часової діаграми статусів: {e}") | |
return None | |
def plot_lead_time_by_type(self): | |
""" | |
Створення діаграми часу виконання за типами тікетів. | |
Returns: | |
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки | |
""" | |
try: | |
if 'Created' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Created']): | |
logger.warning("Колонка 'Created' відсутня або не містить дат") | |
return None | |
if 'Resolved' not in self.df.columns: | |
logger.warning("Колонка 'Resolved' відсутня") | |
return None | |
if 'Issue Type' not in self.df.columns: | |
logger.warning("Колонка 'Issue Type' відсутня") | |
return None | |
if not pd.api.types.is_datetime64_dtype(self.df['Resolved']): | |
self.df['Resolved'] = pd.to_datetime(self.df['Resolved'], errors='coerce') | |
completed_issues = self.df.dropna(subset=['Resolved']) | |
if len(completed_issues) == 0: | |
logger.warning("Немає завершених тікетів для аналізу") | |
return None | |
completed_issues['Lead_Time_Days'] = (completed_issues['Resolved'] - completed_issues['Created']).dt.days | |
valid_lead_time = completed_issues[completed_issues['Lead_Time_Days'] >= 0] | |
if len(valid_lead_time) == 0: | |
logger.warning("Немає валідних даних про час виконання") | |
return None | |
lead_time_by_type = valid_lead_time.groupby('Issue Type')['Lead_Time_Days'].mean() | |
fig, ax = plt.subplots(figsize=(10, 6)) | |
sns.barplot(x=lead_time_by_type.index, y=lead_time_by_type.values, ax=ax) | |
for i, v in enumerate(lead_time_by_type.values): | |
ax.text(i, v + 0.5, f"{v:.1f}", ha='center') | |
ax.set_title('Середній час виконання тікетів за типами (дні)') | |
ax.set_xlabel('Тип') | |
ax.set_ylabel('Дні') | |
plt.xticks(rotation=45) | |
plt.tight_layout() | |
logger.info("Діаграма часу виконання успішно створена") | |
return fig | |
except Exception as e: | |
logger.error(f"Помилка при створенні діаграми часу виконання: {e}") | |
return None | |
# Нові методи, додані до класу JiraVisualizer | |
def plot_assignee_counts(self, limit=10): | |
""" | |
Створення діаграми розподілу тікетів за призначеними користувачами. | |
Args: | |
limit (int): Обмеження на кількість користувачів для відображення | |
Returns: | |
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки | |
""" | |
try: | |
if 'Assignee' not in self.df.columns: | |
logger.warning("Колонка 'Assignee' відсутня") | |
return None | |
assignee_counts = self.df['Assignee'].value_counts().head(limit) | |
fig, ax = plt.subplots(figsize=(14, 6)) | |
sns.barplot(x=assignee_counts.index, y=assignee_counts.values, ax=ax) | |
for i, v in enumerate(assignee_counts.values): | |
ax.text(i, v + 0.5, str(v), ha='center') | |
ax.set_title(f'Кількість тікетів за призначеними користувачами (Топ {limit})') | |
ax.set_xlabel('Призначений користувач') | |
ax.set_ylabel('Кількість') | |
plt.xticks(rotation=45) | |
plt.tight_layout() | |
logger.info("Діаграма призначених користувачів успішно створена") | |
return fig | |
except Exception as e: | |
logger.error(f"Помилка при створенні діаграми призначених користувачів: {e}") | |
return None | |
def plot_timeline(self, date_column='Created', groupby='day', cumulative=False): | |
""" | |
Створення часової діаграми тікетів. | |
Args: | |
date_column (str): Колонка з датою ('Created' або 'Updated') | |
groupby (str): Рівень групування ('day', 'week', 'month') | |
cumulative (bool): Чи показувати кумулятивну суму | |
Returns: | |
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки | |
""" | |
try: | |
if date_column not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df[date_column]): | |
logger.warning(f"Колонка '{date_column}' відсутня або не містить дати") | |
return None | |
date_col = f"{date_column}_Date" if f"{date_column}_Date" in self.df.columns else date_column | |
if f"{date_column}_Date" not in self.df.columns: | |
self.df[f"{date_column}_Date"] = self.df[date_column].dt.date | |
date_col = f"{date_column}_Date" | |
if groupby == 'week': | |
grouped = self.df[date_column].dt.to_period('W').value_counts().sort_index() | |
title_period = 'тижнями' | |
elif groupby == 'month': | |
grouped = self.df[date_column].dt.to_period('M').value_counts().sort_index() | |
title_period = 'місяцями' | |
else: | |
grouped = self.df[date_col].value_counts().sort_index() | |
title_period = 'датами' | |
if cumulative: | |
grouped = grouped.cumsum() | |
title_prefix = 'Загальна кількість' | |
else: | |
title_prefix = 'Кількість' | |
fig, ax = plt.subplots(figsize=(14, 6)) | |
grouped.plot(kind='line', marker='o', ax=ax) | |
ax.set_title(f'{title_prefix} {date_column.lower()}них тікетів за {title_period}') | |
ax.set_xlabel('Період') | |
ax.set_ylabel('Кількість') | |
ax.grid(True) | |
plt.tight_layout() | |
logger.info(f"Часова діаграма для {date_column} успішно створена") | |
return fig | |
except Exception as e: | |
logger.error(f"Помилка при створенні часової діаграми: {e}") | |
return None | |
def plot_heatmap(self, row_col='Issue Type', column_col='Status'): | |
""" | |
Створення теплової карти для візуалізації взаємозв'язку між двома категоріями. | |
Args: | |
row_col (str): Назва колонки для рядків (наприклад, 'Issue Type') | |
column_col (str): Назва колонки для стовпців (наприклад, 'Status') | |
Returns: | |
matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки | |
""" | |
try: | |
if row_col not in self.df.columns or column_col not in self.df.columns: | |
logger.warning(f"Колонки '{row_col}' або '{column_col}' відсутні в даних") | |
return None | |
pivot_table = pd.crosstab(self.df[row_col], self.df[column_col]) | |
fig, ax = plt.subplots(figsize=(14, 8)) | |
sns.heatmap(pivot_table, annot=True, fmt='d', cmap='YlGnBu', ax=ax) | |
ax.set_title(f'Розподіл тікетів: {row_col} за {column_col}') | |
plt.tight_layout() | |
logger.info(f"Теплова карта для {row_col} за {column_col} успішно створена") | |
return fig | |
except Exception as e: | |
logger.error(f"Помилка при створенні теплової карти: {e}") | |
return None | |
def plot_project_timeline(self): | |
""" | |
Створення часової шкали проекту, що показує зміну статусів з часом. | |
Returns: | |
tuple: (fig1, fig2) - об'єкти figure для різних візуалізацій або (None, None) у випадку помилки | |
""" | |
try: | |
if 'Created' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Created']): | |
logger.warning("Колонка 'Created' відсутня або не містить дати") | |
return None, None | |
if 'Updated' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Updated']): | |
logger.warning("Колонка 'Updated' відсутня або не містить дати") | |
return None, None | |
if 'Status' not in self.df.columns: | |
logger.warning("Колонка 'Status' відсутня") | |
return None, None | |
min_date = self.df['Created'].min().date() | |
max_date = self.df['Updated'].max().date() | |
date_range = pd.date_range(start=min_date, end=max_date, freq='D') | |
timeline_data = [] | |
for date in date_range: | |
date_str = date.strftime('%Y-%m-%d') | |
created_until = self.df[self.df['Created'].dt.date <= date.date()] | |
status_counts = {} | |
for _, row in created_until.iterrows(): | |
if row['Updated'].date() >= date.date(): | |
status = row.get('Status', 'Unknown') | |
status_counts[status] = status_counts.get(status, 0) + 1 | |
timeline_data.append({ | |
'Date': date_str, | |
'Total': len(created_until), | |
**status_counts | |
}) | |
timeline_df = pd.DataFrame(timeline_data) | |
timeline_df['Date'] = pd.to_datetime(timeline_df['Date']) | |
fig1, ax1 = plt.subplots(figsize=(16, 8)) | |
ax1.plot(timeline_df['Date'], timeline_df['Total'], marker='o', linewidth=2, label='Загальна кількість') | |
status_columns = [col for col in timeline_df.columns if col not in ['Date', 'Total']] | |
for status in status_columns: | |
ax1.plot(timeline_df['Date'], timeline_df[status], marker='.', linestyle='--', label=status) | |
ax1.set_title('Зміна стану проекту з часом') | |
ax1.set_xlabel('Дата') | |
ax1.set_ylabel('Кількість тікетів') | |
plt.xticks(rotation=45) | |
ax1.grid(True) | |
ax1.legend() | |
plt.tight_layout() | |
fig2, ax2 = plt.subplots(figsize=(16, 8)) | |
status_data = timeline_df[['Date'] + status_columns].set_index('Date') | |
status_data.plot.area(ax=ax2, stacked=True, alpha=0.7) | |
ax2.set_title('Склад тікетів за статусами') | |
ax2.set_xlabel('Дата') | |
ax2.set_ylabel('Кількість тікетів') | |
ax2.grid(True) | |
plt.tight_layout() | |
logger.info("Часова шкала проекту успішно створена") | |
return fig1, fig2 | |
except Exception as e: | |
logger.error(f"Помилка при створенні часової шкали проекту: {e}") | |
return None, None | |
def generate_infographic(self): | |
""" | |
Генерація комплексної інфографіки з ключовими показниками | |
Returns: | |
matplotlib.figure.Figure: Об'єкт figure з інфографікою | |
""" | |
try: | |
fig = plt.figure(figsize=(20, 15)) | |
fig.suptitle('Зведений аналіз проекту в Jira', fontsize=24) | |
ax1 = fig.add_subplot(2, 2, 1) | |
if 'Status' in self.df.columns: | |
status_counts = self.df['Status'].value_counts() | |
sns.barplot(x=status_counts.index, y=status_counts.values, ax=ax1) | |
ax1.set_title('Розподіл за статусами') | |
ax1.set_xlabel('Статус') | |
ax1.set_ylabel('Кількість') | |
ax1.tick_params(axis='x', rotation=45) | |
ax2 = fig.add_subplot(2, 2, 2) | |
if 'Priority' in self.df.columns: | |
priority_counts = self.df['Priority'].value_counts() | |
try: | |
priority_order = ['Highest', 'High', 'Medium', 'Low', 'Lowest'] | |
priority_counts = priority_counts.reindex(priority_order, fill_value=0) | |
except Exception as ex: | |
logger.warning(f"Не вдалося впорядкувати пріоритети: {ex}") | |
colors = ['#FF5555', '#FF9C5A', '#FFCC5A', '#5AFF96', '#5AC8FF'] | |
sns.barplot(x=priority_counts.index, y=priority_counts.values, ax=ax2, palette=colors[:len(priority_counts)]) | |
ax2.set_title('Розподіл за пріоритетами') | |
ax2.set_xlabel('Пріоритет') | |
ax2.set_ylabel('Кількість') | |
ax2.tick_params(axis='x', rotation=45) | |
ax3 = fig.add_subplot(2, 2, 3) | |
if 'Created' in self.df.columns and pd.api.types.is_datetime64_dtype(self.df['Created']): | |
created_dates = self.df['Created'].dt.date.value_counts().sort_index() | |
created_cumulative = created_dates.cumsum() | |
created_cumulative.plot(ax=ax3, marker='o') | |
ax3.set_title('Кумулятивне створення тікетів') | |
ax3.set_xlabel('Дата') | |
ax3.set_ylabel('Кількість') | |
ax3.grid(True) | |
ax4 = fig.add_subplot(2, 2, 4) | |
if 'Status' in self.df.columns and 'Issue Type' in self.df.columns: | |
pivot_table = pd.crosstab(self.df['Issue Type'], self.df['Status']) | |
sns.heatmap(pivot_table, annot=True, fmt='d', cmap='YlGnBu', ax=ax4) | |
ax4.set_title('Розподіл: Типи за Статусами') | |
ax4.tick_params(axis='x', rotation=45) | |
plt.tight_layout(rect=[0, 0, 1, 0.96]) | |
logger.info("Інфографіка успішно створена") | |
return fig | |
except Exception as e: | |
logger.error(f"Помилка при створенні інфографіки: {e}") | |
return None | |
def plot_all(self, output_dir=None): | |
""" | |
Створення та збереження всіх діаграм. | |
Args: | |
output_dir (str): Директорія для збереження діаграм. | |
Якщо None, діаграми не зберігаються. | |
Returns: | |
dict: Словник з об'єктами figure для всіх діаграм | |
""" | |
plots = {} | |
plots['status'] = self.plot_status_counts() | |
plots['priority'] = self.plot_priority_counts() | |
plots['type'] = self.plot_type_counts() | |
plots['assignee'] = self.plot_assignee_counts(limit=10) | |
plots['created_timeline'] = self.plot_timeline(date_column='Created', groupby='day') | |
plots['updated_timeline'] = self.plot_timeline(date_column='Updated', groupby='day') | |
plots['created_cumulative'] = self.plot_timeline(date_column='Created', cumulative=True) | |
plots['inactive'] = self.plot_inactive_issues() | |
plots['heatmap_type_status'] = self.plot_heatmap(row_col='Issue Type', column_col='Status') | |
timeline_plots = self.plot_project_timeline() | |
if timeline_plots[0] is not None: | |
plots['project_timeline'] = timeline_plots[0] | |
plots['project_composition'] = timeline_plots[1] | |
if output_dir: | |
from pathlib import Path | |
output_path = Path(output_dir) | |
output_path.mkdir(exist_ok=True, parents=True) | |
for name, fig in plots.items(): | |
if fig: | |
fig_path = output_path / f"{name}.png" | |
fig.savefig(fig_path, dpi=300) | |
logger.info(f"Діаграма {name} збережена у {fig_path}") | |
return plots | |