/
DimerTrue
/
ecg_gui
Обзор
Документация
Войти
/
DimerTrue
/
ecg_gui
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
ui/plot_widget.py
444 строки
17 KB
dimer
feat: add autoscale time plot
31 июл 2026, 15:38
31 июл 2026, 15:38
163bc57
Код
Авторство
О чём код?
# plot_widget.py from typing import List import tkinter as tk from tkinter import ttk import matplotlib.pyplot as plt from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import Figure from config.plot_config import PlotDataConfig from config.theme import Theme from config import ui_config from ui.tooltips import PlotToolTip # Начальное значение границы оси X X_MAX_INIT = 2 class PlotWidget(tk.Frame): """ Виджет для отображения графиков с поддержкой blitting и плавного зума. Не зависит от источников данных, работает только с переданными массивами. """ def __init__(self, parent, **kwargs): super().__init__(parent, **kwargs) # Конфигурация отображения self.scale_coeff = 0.05 self.x_expansion_threshold = 0.9 # Скорость анимации. Для медленного обновления лучше ставить побольше self.zoom_speed = 0.9 # Состояние осей self.x_min = 0 self.x_max = X_MAX_INIT self._target_x_max = None self._current_x_max = self.x_max self.time_unit = "сек" # Blitting state self._background = None self._blit_enabled = True # UI Setup self._setup_matplotlib() self._setup_plots() self._setup_bindings() # Применяем начальную тему self.apply_theme() def _setup_matplotlib(self): """Инициализация Figure и Canvas""" self.fig = Figure(figsize=(7, 4), dpi=100) self.ax = self.fig.add_subplot(111) self.ax2 = self.ax.twinx() bbox = self.ax.get_position() self.ax.set_position([0.12, bbox.y0, bbox.width, bbox.height]) self.canvas = FigureCanvasTkAgg(self.fig, master=self) self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) # Базовые настройки осей self.ax.set_title( "Зависимость параметра от времени", fontsize=12, weight="bold", pad=15 ) # self.ax.set_xlabel("Время", fontsize=10, weight="bold", alpha=0.8) self.rename_x_axis(self.time_unit) self.ax.set_ylabel("Значение", fontsize=10, weight="bold", alpha=0.8) self.ax2.set_ylabel("Мощность, Вт", fontsize=10, weight="bold", alpha=0.8) self.ax.tick_params(labelsize=8) self.ax2.tick_params(labelsize=8) self.ax.grid(True) # Нулевая линия self.zero_line = self.ax.axhline(0, linewidth=0.8, linestyle="--") # TODO: нужна ли 0 линия? Если да, то нужно 2 # self.zero_line_var = tk.BooleanVar(value=True) self.zero_line_var = tk.BooleanVar(value=False) def _setup_plots(self): """Создание линий графиков на основе конфигурации""" self.lines = [] self.show_plots = [] for config in PlotDataConfig: target_ax = self.ax2 if config.yaxis == "right" else self.ax (line,) = target_ax.plot( [], [], label=config.label, color=config.color, linestyle=config.linestyle, linewidth=config.linewidth, ) self.lines.append(line) show_value = tk.BooleanVar(value=config.enabled) self.show_plots.append(show_value) self.plots_count = len(self.lines) self._create_legend() def _create_legend(self): """Создание интерактивной легенды""" lines_ax = [ l for l, cfg in zip(self.lines, PlotDataConfig) if cfg.yaxis == "left" ] lines_ax2 = [ l for l, cfg in zip(self.lines, PlotDataConfig) if cfg.yaxis == "right" ] self.legend = self.ax.legend( handles=lines_ax + lines_ax2, loc="upper left", bbox_to_anchor=(1.08, 1), frameon=True, fontsize=8, fancybox=True, shadow=True, framealpha=0.9, ) self.fig.tight_layout(rect=[0.01, 0, 1, 1]) # Интерактивность легенды for legline, legtext, line, show_var in zip( self.legend.get_lines(), self.legend.get_texts(), self.lines, self.show_plots, ): legline.set_picker(True) legline.set_pickradius(5) legtext.set_picker(True) if not show_var.get(): legline.set_alpha(0.3) legtext.set_alpha(0.3) def _setup_bindings(self): """Привязываем обработчики к легендам и чекбоксам""" self.canvas.mpl_connect("pick_event", self.on_legend_click) self.canvas.mpl_connect( "resize_event", lambda e: setattr(self, "_background", None) ) PlotToolTip( self.canvas, self.legend, "Нажмите на легенду, чтобы переключить график", delay=ui_config.TOOLTIP_DELAY, ) def apply_theme(self): """Применение текущей темы к графику""" colors = Theme.get_colors() # Фоны self.fig.set_facecolor(colors["plot_bg"]) self.ax.set_facecolor(colors["plot_facecolor"]) self.ax2.set_facecolor(colors["plot_facecolor"]) # Текст self.ax.set_title( "Зависимость параметра от времени", color=colors["plot_title"] ) self.ax.set_xlabel(f"Время, {self.time_unit}", color=colors["plot_label_x"]) self.ax.set_ylabel("Значение", color=colors["plot_label_y_left"]) self.ax2.set_ylabel("Мощность, Вт", color=colors["plot_label_y_right"]) # Осевые элементы self.ax.tick_params(colors=colors["plot_ticks_left"]) self.ax2.tick_params(colors=colors["plot_ticks_right"]) self.ax.grid( True, alpha=0.2, linestyle="--", linewidth=0.5, color=colors["plot_grid"] ) for spine in list(self.ax.spines.values()) + list(self.ax2.spines.values()): spine.set_color(colors["plot_axis"]) self.ax.spines["top"].set_visible(False) self.ax2.spines["top"].set_visible(False) if hasattr(self, "zero_line"): self.zero_line.set_color(colors["plot_axis"]) self.zero_line.set_visible(self.zero_line_var.get()) if hasattr(self, "legend"): self.legend.get_frame().set_facecolor(colors["plot_bg"]) self.legend.get_frame().set_edgecolor(colors["plot_grid"]) for text, cfg in zip(self.legend.get_texts(), PlotDataConfig): color_key = ( "plot_legend_text_left" if cfg.yaxis == "left" else "plot_legend_text_right" ) text.set_color(colors[color_key]) self._update_axes_visibility() self._background = None self.canvas.draw_idle() def on_legend_click(self, event=None): """ Обработчик клика по легенде или чекбоксу. Если event=None, значит вызов из чекбокса Tkinter. """ if event is not None: # Вызов из matplotlib pick_event for i, (legline, legtext) in enumerate( zip(self.legend.get_lines(), self.legend.get_texts()) ): if event.artist in (legline, legtext): new_state = not self.show_plots[i].get() self.show_plots[i].set(new_state) self._apply_visibility_change(i, new_state) break else: # Вызов из чекбокса Tkinter - нужно найти какой чекбокс был нажат # Перебираем все чекбоксы и синхронизируем состояние for i, show_var in enumerate(self.show_plots): new_state = show_var.get() self._apply_visibility_change(i, new_state) self._update_axes_visibility() def toggle_zero_line(self): if hasattr(self, "zero_line"): self.zero_line.set_visible(self.zero_line_var.get()) self._background = None self.canvas.draw_idle() def update_data(self, x_data: List, y_data_lists: List[List[float]]): """ Обновляет данные графиков. :param x_data: Список значений по оси X :param y_data_lists: Список списков значений по оси Y для каждого графика """ if not x_data: return # Обновляем данные линий for i in range(self.plots_count): if i < len(y_data_lists): self.lines[i].set_data(x_data, y_data_lists[i]) # Проверяем необходимость расширения оси X self._check_and_expand_x_axis(x_data[-1]) # Обновляем Y лимиты self._update_y_limits(y_data_lists) # Анимируем зум is_zooming = self._update_smooth_zoom() # Отрисовка if self._blit_enabled and not is_zooming: self._blit_draw() else: self.canvas.draw_idle() def rescale_x_axis(self, scale: int): self.x_max /= scale self._current_x_max = self.x_max self._target_x_max = None self.ax.set_xlim(0, self.x_max) self._background = None def rename_x_axis(self, time_unit: str): self.time_unit = time_unit self.ax.set_xlabel(f"Время, {time_unit}", fontsize=10, weight="bold", alpha=0.8) def _check_and_expand_x_axis(self, current_x_end): """Проверяет, нужно ли расширить ось X при приближении к границе""" # Уже идет анимация зума, пропускаем if self._target_x_max is not None: return if current_x_end > self.x_max * self.x_expansion_threshold: self.x_max *= 2 self._target_x_max = self.x_max # Фон неактуален из-за изменения масштаба self._background = None def _update_smooth_zoom(self): """Плавная анимация расширения оси X. Возвращает True, если анимация продолжается""" if self._target_x_max is None: return False diff = self._target_x_max - self._current_x_max if abs(diff) < 0.1: # Анимация завершена self._current_x_max = self._target_x_max self._target_x_max = None self.ax.set_xlim(self.x_min, self._current_x_max) return False self._current_x_max += diff * self.zoom_speed self.ax.set_xlim(self.x_min, self._current_x_max) return True def _update_y_limits(self, y_data_lists): """Адаптивно обновляет лимиты оси Y""" left_data = [] right_data = [] for i in range(self.plots_count): if self.lines[i].get_visible(): if PlotDataConfig[i].yaxis == "left": left_data.append(y_data_lists[i]) else: right_data.append(y_data_lists[i]) if left_data: self._adjust_axis_limit(self.ax, left_data) if right_data: self._adjust_axis_limit(self.ax2, right_data) def _adjust_axis_limit(self, axis, data_lists): """Обновляет лимиты оси Y максимальным значением y во всех данных""" min_y = min(min(lst) for lst in data_lists if lst) max_y = max(max(lst) for lst in data_lists if lst) delta = (max_y - min_y) * self.scale_coeff or 1 new_min, new_max = min_y - delta, max_y + delta curr_min, curr_max = axis.get_ylim() # Порог 5% от текущего диапазона threshold = (curr_max - curr_min) * 0.05 # Обновляем только если изменение значительное (защита от дрожания) if abs(new_min - curr_min) > threshold or abs(new_max - curr_max) > threshold: axis.set_ylim(new_min, new_max) self._background = None def _blit_draw(self): """ Оптимизированная отрисовка через blitting. Сохраняет фон (оси, сетку) и рисует только изменяющиеся линии. """ try: # Первая отрисовка: создаем полный фон if self._background is None: self.canvas.draw() self._background = self.canvas.copy_from_bbox(self.ax.bbox) return # Восстанавливаем сохраненный фон self.canvas.restore_region(self._background) # Быстро выводим изменения на экран for line in self.lines: if line.get_visible(): self.ax.draw_artist(line) if hasattr(self, "zero_line") and self.zero_line.get_visible(): self.ax.draw_artist(self.zero_line) self.canvas.blit(self.ax.bbox) except Exception: # При ошибке сбрасываем фон и делаем полную перерисовку self._background = None self.canvas.draw_idle() def _update_axes_visibility(self): """Показывает/скрывает оси Y в зависимости от видимости графиков""" left_visible = any( l.get_visible() for l, c in zip(self.lines, PlotDataConfig) if c.yaxis == "left" ) right_visible = any( l.get_visible() for l, c in zip(self.lines, PlotDataConfig) if c.yaxis == "right" ) self.ax.yaxis.label.set_visible(left_visible) self.ax.tick_params( axis="y", which="both", labelleft=left_visible, left=left_visible ) self.ax2.yaxis.label.set_visible(right_visible) self.ax2.tick_params( axis="y", which="both", labelright=right_visible, right=right_visible ) self._background = None self.canvas.draw_idle() def _apply_visibility_change(self, index: int, is_visible: bool): """Применяет изменение видимости для конкретного графика""" self.lines[index].set_visible(is_visible) # Обновляем видимость в легенде legline = self.legend.get_lines()[index] legtext = self.legend.get_texts()[index] alpha = 1.0 if is_visible else 0.3 legline.set_alpha(alpha) legtext.set_alpha(alpha) self._background = None self.canvas.draw_idle() def set_initial_x_limits(self, x_data): """Устанавливает начальные границы оси X на основе исторических данных""" if not x_data: self.x_min = 0 self.x_max = X_MAX_INIT elif len(x_data) == 1: self.x_min = x_data[0] self.x_max = X_MAX_INIT * 1.5 else: self.x_min = x_data[0] # self.x_min = 0 last_x = x_data[-1] # self.x_max = int(last_x * 1.5) self.x_max = last_x * 1.5 self._current_x_max = self.x_max self._target_x_max = None self._background = None self.ax.set_xlim(self.x_min, self.x_max) self.canvas.draw_idle() # def set_initial_x_limits(self, x_data): # """Устанавливает начальные границы оси X на основе исторических данных""" # if not x_data: # self.x_min = 0 # self.x_max = X_MAX_INIT # else: # self.x_min = x_data[0] # # self.x_min = 0 # last_x = x_data[-1] # self.x_max = last_x * 1.5 # self._current_x_max = self.x_max # self._target_x_max = None # Сброс анимации # self.ax.set_xlim(self.x_min, self.x_max) # self._background = None # Сброс фона блиттинга # self.canvas.draw_idle()