/
DimerTrue
/
ecg_gui
Обзор
Документация
Войти
/
DimerTrue
/
ecg_gui
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
ui/plot_window.py
325 строк
13 KB
dimer
feat: add autoscale time plot
31 июл 2026, 15:38
31 июл 2026, 15:38
163bc57
Код
Авторство
О чём код?
# plot_window.py import tkinter as tk from tkinter import ttk import threading import math from models.plot_models import PlotSample from models.data_history import DataHistory from config.plot_config import PlotDataConfig from config.theme import Theme from ui.plot_widget import PlotWidget from config import ui_config PLOT_WINDOW_UPDATE_TIME = 50 # Максимальное количество точек, после которого включается прореживание MAX_POINTS_THRESHOLD = 800 # Целевое количество точек на экране при прореживании (баланс между производительностью и детализацией) TARGET_PLOT_POINTS = 400 SECONDS_RANGE = 0 MINUTES_RANGE = 1 HOURS_RANGE = 2 SECONDS_LABEL = "сек" MINUTES_LABEL = "мин" HOURS_LABEL = "час" SECONDS_PER_SECOND = 1 SECONDS_PER_MINUTE = 60 SECONDS_PER_HOUR = 3600 # Количество секунд, после которых будут масштабироваться значения и пересчитываться оси на другой временной диапазон SECONDS_THRESHOLD = 60 * 2 MINUTES_THRESHOLD = 3600 * 2 class PlotWindow: """Окно с графиком, управляющее данными и содержащее PlotWidget""" def __init__( self, root: tk.Tk, stop_event: threading.Event, id: int, history: DataHistory ): self.root = root self.stop_event = stop_event self.id = id self.history = history self.root.title(f"Графики ЭХГ топаз ID {self.id}") self.root.geometry("650x400") self.root.minsize(650, 350) self.has_new_data = False self.time_unit = SECONDS_LABEL self.time_range = SECONDS_RANGE self.time_scale = SECONDS_PER_SECOND # Флаг включения прореживания self._decimate_enabled = tk.BooleanVar(value=False) # Кэш данных для отрисовки (сырые данные) self._cached_x = [] self._cached_y = [[] for _ in range(len(PlotDataConfig))] self._setup_ui() self.apply_theme() self.root.protocol("WM_DELETE_WINDOW", self.close_window) self._load_history_to_cache() # Инициализируем пределы виджета после загрузки истории self.plot_widget.set_initial_x_limits(self._cached_x) # Запускаем цикл обновления self._update_plot() def _setup_ui(self): style = ttk.Style() style.configure("Custom.TFrame", background="#ffffff") self.main_frame = ttk.Frame(master=self.root, style="Custom.TFrame") self.main_frame.pack(fill=tk.BOTH, expand=True) self.main_frame.rowconfigure(0, weight=1) self.main_frame.columnconfigure(0, weight=1) # Создаем наш изолированный виджет графика self.plot_widget = PlotWidget(self.main_frame) self.plot_widget.grid(row=0, column=0, sticky="nsew") self._setup_control_panel() def _setup_control_panel(self): colors = Theme.get_colors() self.control_panel = tk.Frame( self.main_frame, bg=colors["control_bg"], height=25, bd=0, highlightthickness=0, relief="flat", ) self.control_panel.grid(row=1, column=0, sticky="ew", padx=1, pady=1) self.control_panel.pack_propagate(False) for i, config in enumerate(PlotDataConfig): cb = tk.Checkbutton( self.control_panel, text=config.label, variable=self.plot_widget.show_plots[i], command=lambda idx=i: self.plot_widget.on_legend_click(), bg=colors["control_bg"], fg=colors["control_fg"], font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), activebackground=colors["control_bg"], activeforeground=colors["control_fg"], selectcolor=colors["control_bg"], bd=0, highlightthickness=0, padx=0, pady=0, ) cb.pack(side=tk.LEFT, padx=10, pady=1) # TODO: нужна ли 0 линия? Если да, то нужно 2 # cb_zero = tk.Checkbutton( # self.control_panel, # text="0-линия", # variable=self.plot_widget.zero_line_var, # command=self.plot_widget.toggle_zero_line, # bg=colors["control_bg"], # fg=colors["control_fg"], # selectcolor=colors["control_bg"], # font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), # bd=0, # highlightthickness=0, # padx=0, # pady=0, # ) # cb_zero.pack(side=tk.LEFT, padx=10) # Чекбокс прореживания cb_decimate = tk.Checkbutton( self.control_panel, text="Прореживание", variable=self._decimate_enabled, command=self._on_decimate_toggle, bg=colors["control_bg"], fg=colors["control_fg"], activebackground=colors["control_bg"], activeforeground=colors["control_fg"], selectcolor=colors["control_bg"], font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), bd=0, highlightthickness=0, padx=0, pady=0, ) cb_decimate.pack(side=tk.LEFT, padx=10, pady=1) def apply_theme(self): colors = Theme.get_colors() style = ttk.Style() style.configure("Custom.TFrame", background=colors["bg"]) self.root.configure(bg=colors["bg"]) self.main_frame.configure(style="Custom.TFrame") if hasattr(self, "control_panel"): self.control_panel.configure(bg=colors["control_bg"]) for child in self.control_panel.winfo_children(): if isinstance(child, tk.Checkbutton): child.configure( bg=colors["control_bg"], fg=colors["control_fg"], activebackground=colors["control_bg"], activeforeground=colors["control_fg"], selectcolor=colors["control_bg"], ) # Применяем тему к виджету графика if hasattr(self, "plot_widget"): self.plot_widget.apply_theme() def close_window(self): if self.root.winfo_exists(): self.root.destroy() def _load_history_to_cache(self): # Загружает данные в кэш их хранилища samples = self.history.get_all_samples() if not samples: return # Масштабируем данные time_now_sec = samples[-1].timestamp if time_now_sec > MINUTES_THRESHOLD: self.time_scale = SECONDS_PER_HOUR self.time_range = HOURS_RANGE self.time_unit = HOURS_LABEL elif time_now_sec > SECONDS_THRESHOLD: self.time_scale = SECONDS_PER_MINUTE self.time_range = MINUTES_RANGE self.time_unit = MINUTES_LABEL else: self.time_scale = SECONDS_PER_SECOND self.time_range = SECONDS_RANGE self.time_unit = SECONDS_LABEL self._cached_x = [s.timestamp / self.time_scale for s in samples] self._cached_y = [[] for _ in range(len(PlotDataConfig))] for s in samples: for i, config in enumerate(PlotDataConfig): val = config.extractor(s) * config.y_scale + config.y_offset self._cached_y[i].append(val) self.plot_widget.rename_x_axis(self.time_unit) # TODO: надо продумать, как фильтровать выбросы, ибо это убивает внешний вид def add_point(self, latest_point: PlotSample): """Добавляет точку в кэш""" timestamp = latest_point.timestamp # Гарантия, что мастшабирование значения произойдет лишь один раз if self.time_range == MINUTES_RANGE and timestamp > MINUTES_THRESHOLD: self.time_scale = SECONDS_PER_HOUR self.time_range = HOURS_RANGE self.time_unit = HOURS_LABEL # Делим на количество минут для оптимизации self._cached_x = [x / SECONDS_PER_MINUTE for x in self._cached_x] # self._cached_x = [x * SECONDS_PER_MINUTE / SECONDS_PER_HOUR for x in self._cached_x] self.plot_widget.rescale_x_axis(SECONDS_PER_MINUTE) self.plot_widget.rename_x_axis(HOURS_LABEL) elif self.time_range == SECONDS_RANGE and timestamp > SECONDS_THRESHOLD: self.time_scale = SECONDS_PER_MINUTE self.time_range = MINUTES_RANGE self.time_unit = MINUTES_LABEL # Обновляем кэш self._cached_x = [x / SECONDS_PER_MINUTE for x in self._cached_x] # Обновляем ось виджета self.plot_widget.rescale_x_axis(SECONDS_PER_MINUTE) self.plot_widget.rename_x_axis(MINUTES_LABEL) self._cached_x.append(latest_point.timestamp / self.time_scale) for i, config in enumerate(PlotDataConfig): value = config.extractor(latest_point) * config.y_scale + config.y_offset self._cached_y[i].append(value) self.has_new_data = True def _on_decimate_toggle(self): """Вызывается при переключении чекбокса прореживания. Принудительно обновляет график, чтобы сразу отразить изменения.""" if self.has_new_data or True: # Всегда перерисовываем при смене режима self.has_new_data = True def _get_decimated_data(self, x_data, y_data_lists): """ Возвращает прореженные данные, если их количество превышает порог. Использует упрощенный алгоритм bucketing (усреднение по корзинам) для сохранения общей формы сигнала без сильных искажений. """ total_points = len(x_data) # Если прореживание выключено или точек мало, возвращаем оригинал if not self._decimate_enabled.get() or total_points <= MAX_POINTS_THRESHOLD: return x_data, y_data_lists # Определяем шаг прореживания # Мы хотим получить примерно TARGET_PLOT_POINTS точек step = max(1, math.ceil(total_points / TARGET_PLOT_POINTS)) decimated_x = [] decimated_y = [[] for _ in range(len(y_data_lists))] # Проходим по данным с шагом # Чтобы избежать "дрожания", мы берем не просто каждую N-ю точку, # а усредняем значения внутри "корзины" размером step. # Это стабилизирует линию. num_buckets = math.ceil(total_points / step) for i in range(num_buckets): start_idx = i * step end_idx = min(start_idx + step, total_points) # Для X берем среднее время или первую точку интервала (для линейности лучше первую/последнюю) # Но для равномерной сетки можно взять середину интервала по индексу if start_idx < total_points: # Берем среднее время для более точного позиционирования "центра тяжести" пакета данных avg_x = sum(x_data[start_idx:end_idx]) / (end_idx - start_idx) decimated_x.append(avg_x) # decimated_x.append(x_data[start_idx]) # Усредняем значения Y в корзине for list_idx, y_list in enumerate(y_data_lists): bucket_values = y_list[start_idx:end_idx] if bucket_values: avg_val = sum(bucket_values) / len(bucket_values) decimated_y[list_idx].append(avg_val) return decimated_x, decimated_y def _update_plot(self): # Обновляем графики, если имеются новые данные if not self.root.winfo_exists(): return if self.has_new_data: self.has_new_data = False # Получаем данные для отрисовки (возможно прореженные) draw_x, draw_y = self._get_decimated_data(self._cached_x, self._cached_y) # Передаем данные в виджет для отрисовки self.plot_widget.update_data(draw_x, draw_y) self.root.after(PLOT_WINDOW_UPDATE_TIME, self._update_plot)