Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
355 lines
13 KiB
Python
355 lines
13 KiB
Python
import tkinter as tk
|
|
from tkinter import ttk
|
|
|
|
import matplotlib.figure
|
|
import matplotlib.pyplot as plt
|
|
import pandas as pd
|
|
from matplotlib.axes import Axes
|
|
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
|
|
|
from medicine_manager import MedicineManager
|
|
from pathology_manager import PathologyManager
|
|
|
|
|
|
class GraphManager:
|
|
"""Optimized version - Handle all graph-related operations for the
|
|
application with performance improvements."""
|
|
|
|
def __init__(
|
|
self,
|
|
parent_frame: ttk.LabelFrame,
|
|
medicine_manager: MedicineManager,
|
|
pathology_manager: PathologyManager,
|
|
) -> None:
|
|
self.parent_frame: ttk.LabelFrame = parent_frame
|
|
self.medicine_manager = medicine_manager
|
|
self.pathology_manager = pathology_manager
|
|
|
|
# Initialize matplotlib with optimized settings
|
|
self.fig: matplotlib.figure.Figure = plt.figure(figsize=(10, 6), dpi=80)
|
|
self.ax: Axes = self.fig.add_subplot(111)
|
|
|
|
# Cache for current data to avoid reprocessing
|
|
self.current_data: pd.DataFrame = pd.DataFrame()
|
|
self._last_plot_hash: str = ""
|
|
|
|
# Initialize UI components
|
|
self.toggle_vars: dict[str, tk.IntVar] = {}
|
|
self._setup_ui()
|
|
self._initialize_toggle_vars()
|
|
self._create_chart_toggles()
|
|
|
|
def _initialize_toggle_vars(self) -> None:
|
|
"""Initialize toggle variables for chart elements with optimization."""
|
|
# Initialize pathology toggles
|
|
for pathology_key in self.pathology_manager.get_pathology_keys():
|
|
self.toggle_vars[pathology_key] = tk.IntVar(value=1)
|
|
|
|
# Initialize medicine toggles (unchecked by default)
|
|
for medicine_key in self.medicine_manager.get_medicine_keys():
|
|
self.toggle_vars[medicine_key] = tk.IntVar(value=0)
|
|
|
|
def _setup_ui(self) -> None:
|
|
"""Set up the UI components with performance optimizations."""
|
|
# Create canvas with optimized settings
|
|
self.canvas = FigureCanvasTkAgg(self.fig, master=self.parent_frame)
|
|
self.canvas.draw_idle() # Use draw_idle for better performance
|
|
|
|
# Pack canvas
|
|
canvas_widget = self.canvas.get_tk_widget()
|
|
canvas_widget.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
|
|
|
|
# Create control frame
|
|
self.control_frame = ttk.Frame(self.parent_frame)
|
|
self.control_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=5, pady=2)
|
|
|
|
def _create_chart_toggles(self) -> None:
|
|
"""Create toggle controls for chart elements with improved layout."""
|
|
# Pathology toggles
|
|
pathology_frame = ttk.LabelFrame(
|
|
self.control_frame, text="Pathologies", padding="5"
|
|
)
|
|
pathology_frame.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=2)
|
|
|
|
# Use grid for better layout
|
|
row, col = 0, 0
|
|
for pathology_key in self.pathology_manager.get_pathology_keys():
|
|
pathology = self.pathology_manager.get_pathology(pathology_key)
|
|
if pathology:
|
|
display_name = pathology.display_name
|
|
text = (
|
|
display_name[:10] + "..."
|
|
if len(display_name) > 10
|
|
else display_name
|
|
)
|
|
cb = ttk.Checkbutton(
|
|
pathology_frame,
|
|
text=text,
|
|
variable=self.toggle_vars[pathology_key],
|
|
command=self._handle_toggle_changed,
|
|
)
|
|
cb.grid(row=row, column=col, sticky="w", padx=2)
|
|
col += 1
|
|
if col > 1: # 2 columns max
|
|
col = 0
|
|
row += 1
|
|
|
|
# Medicine toggles
|
|
medicine_frame = ttk.LabelFrame(
|
|
self.control_frame, text="Medicines", padding="5"
|
|
)
|
|
medicine_frame.pack(side=tk.RIGHT, fill=tk.X, expand=True, padx=2)
|
|
|
|
# Use grid for medicines too
|
|
row, col = 0, 0
|
|
for medicine_key in self.medicine_manager.get_medicine_keys():
|
|
medicine = self.medicine_manager.get_medicine(medicine_key)
|
|
if medicine:
|
|
med_name = medicine.display_name
|
|
text = med_name[:10] + "..." if len(med_name) > 10 else med_name
|
|
cb = ttk.Checkbutton(
|
|
medicine_frame,
|
|
text=text,
|
|
variable=self.toggle_vars[medicine_key],
|
|
command=self._handle_toggle_changed,
|
|
)
|
|
cb.grid(row=row, column=col, sticky="w", padx=2)
|
|
col += 1
|
|
if col > 2: # 3 columns max for medicines
|
|
col = 0
|
|
row += 1
|
|
|
|
def _handle_toggle_changed(self) -> None:
|
|
"""Handle toggle changes by replotting the graph with optimization."""
|
|
if not self.current_data.empty:
|
|
self._plot_graph_data(self.current_data)
|
|
|
|
def update_graph(self, df: pd.DataFrame) -> None:
|
|
"""Update the graph with new data using optimization checks."""
|
|
# Create hash of data to avoid unnecessary redraws
|
|
data_hash = str(hash(str(df.values.tobytes()) if not df.empty else "empty"))
|
|
|
|
# Only update if data actually changed
|
|
if data_hash != self._last_plot_hash or self.current_data.empty:
|
|
self.current_data = df.copy() if not df.empty else pd.DataFrame()
|
|
self._last_plot_hash = data_hash
|
|
self._plot_graph_data(df)
|
|
|
|
def _plot_graph_data(self, df: pd.DataFrame) -> None:
|
|
"""Plot the graph data with current toggle settings using optimizations."""
|
|
# Use batch updates to reduce redraws
|
|
with plt.ioff(): # Turn off interactive mode for batch updates
|
|
self.ax.clear()
|
|
|
|
if not df.empty:
|
|
# Optimize data processing
|
|
df_processed = self._preprocess_data(df)
|
|
|
|
# Track if any series are plotted
|
|
has_plotted_series = self._plot_pathology_data(df_processed)
|
|
medicine_data = self._plot_medicine_data(df_processed)
|
|
|
|
if has_plotted_series or medicine_data["has_plotted"]:
|
|
self._configure_graph_appearance(medicine_data)
|
|
|
|
# Single draw call at the end
|
|
self.canvas.draw_idle()
|
|
|
|
def _preprocess_data(self, df: pd.DataFrame) -> pd.DataFrame:
|
|
"""Preprocess data for plotting with optimizations."""
|
|
df = df.copy()
|
|
# Batch convert dates and sort
|
|
df["date"] = pd.to_datetime(df["date"], cache=True)
|
|
df = df.sort_values(by="date")
|
|
df.set_index(keys="date", inplace=True)
|
|
return df
|
|
|
|
def _plot_pathology_data(self, df: pd.DataFrame) -> bool:
|
|
"""Plot pathology data series with optimizations."""
|
|
has_plotted_series = False
|
|
|
|
# Batch plot pathology data
|
|
pathology_keys = self.pathology_manager.get_pathology_keys()
|
|
active_pathologies = [
|
|
key
|
|
for key in pathology_keys
|
|
if self.toggle_vars[key].get() and key in df.columns
|
|
]
|
|
|
|
for pathology_key in active_pathologies:
|
|
pathology = self.pathology_manager.get_pathology(pathology_key)
|
|
if pathology:
|
|
label = f"{pathology.display_name} ({pathology.scale_info})"
|
|
linestyle = (
|
|
"dashed" if pathology.scale_orientation == "inverted" else "-"
|
|
)
|
|
self._plot_series(df, pathology_key, label, "o", linestyle)
|
|
has_plotted_series = True
|
|
|
|
return has_plotted_series
|
|
|
|
def _plot_medicine_data(self, df: pd.DataFrame) -> dict:
|
|
"""Plot medicine data with optimizations."""
|
|
result = {"has_plotted": False, "with_data": [], "without_data": []}
|
|
|
|
# Get medicine colors and keys in batch
|
|
medicine_colors = self.medicine_manager.get_graph_colors()
|
|
medicines = self.medicine_manager.get_medicine_keys()
|
|
|
|
# Pre-calculate daily doses for all medicines to avoid repeated computation
|
|
medicine_doses = {}
|
|
for medicine in medicines:
|
|
dose_column = f"{medicine}_doses"
|
|
if dose_column in df.columns:
|
|
daily_doses = [
|
|
self._calculate_daily_dose(dose_str) for dose_str in df[dose_column]
|
|
]
|
|
medicine_doses[medicine] = daily_doses
|
|
|
|
# Plot medicines with data
|
|
for medicine in medicines:
|
|
if self.toggle_vars[medicine].get() and medicine in medicine_doses:
|
|
daily_doses = medicine_doses[medicine]
|
|
|
|
# Check if there's any data to plot
|
|
if any(dose > 0 for dose in daily_doses):
|
|
result["with_data"].append(medicine)
|
|
|
|
# Optimize dose scaling and bar plotting
|
|
scaled_doses = [dose / 10 for dose in daily_doses]
|
|
|
|
# Calculate statistics more efficiently
|
|
non_zero_doses = [d for d in daily_doses if d > 0]
|
|
if non_zero_doses:
|
|
avg_dose = sum(daily_doses) / len(non_zero_doses)
|
|
label = f"{medicine.capitalize()} (avg: {avg_dose:.1f}mg)"
|
|
|
|
# Single bar plot call
|
|
self.ax.bar(
|
|
df.index,
|
|
scaled_doses,
|
|
alpha=0.6,
|
|
color=medicine_colors.get(medicine, "#DDA0DD"),
|
|
label=label,
|
|
width=0.6,
|
|
bottom=-max(scaled_doses) * 1.1 if scaled_doses else -1,
|
|
)
|
|
result["has_plotted"] = True
|
|
else:
|
|
# Medicine is toggled on but has no dose data
|
|
if self.toggle_vars[medicine].get():
|
|
result["without_data"].append(medicine)
|
|
|
|
return result
|
|
|
|
def _configure_graph_appearance(self, medicine_data: dict) -> None:
|
|
"""Configure graph appearance with optimizations."""
|
|
# Get legend data in batch
|
|
handles, labels = self.ax.get_legend_handles_labels()
|
|
|
|
# Add information about medicines without data if any are toggled on
|
|
if medicine_data["without_data"]:
|
|
med_list = ", ".join(medicine_data["without_data"])
|
|
info_text = f"Tracked (no doses): {med_list}"
|
|
labels.append(info_text)
|
|
|
|
# Create dummy handle more efficiently
|
|
from matplotlib.patches import Rectangle
|
|
|
|
dummy_handle = Rectangle(
|
|
(0, 0), 1, 1, fc="w", fill=False, edgecolor="none", linewidth=0
|
|
)
|
|
handles.append(dummy_handle)
|
|
|
|
# Create legend with optimized settings
|
|
if handles and labels:
|
|
self.ax.legend(
|
|
handles,
|
|
labels,
|
|
loc="upper left",
|
|
bbox_to_anchor=(0, 1),
|
|
ncol=2,
|
|
fontsize="small",
|
|
frameon=True,
|
|
fancybox=True,
|
|
shadow=True,
|
|
framealpha=0.9,
|
|
)
|
|
|
|
# Set titles and labels
|
|
self.ax.set_title("Medication Effects Over Time")
|
|
self.ax.set_xlabel("Date")
|
|
self.ax.set_ylabel("Rating (0-10) / Dose (mg)")
|
|
|
|
# Optimize y-axis configuration
|
|
current_ylim = self.ax.get_ylim()
|
|
self.ax.set_ylim(bottom=current_ylim[0], top=max(10, current_ylim[1]))
|
|
|
|
# Optimize date formatting
|
|
self.fig.autofmt_xdate()
|
|
|
|
def _plot_series(
|
|
self,
|
|
df: pd.DataFrame,
|
|
column: str,
|
|
label: str,
|
|
marker: str,
|
|
linestyle: str,
|
|
) -> None:
|
|
"""Helper method to plot a data series with optimizations."""
|
|
# Use more efficient plotting parameters
|
|
self.ax.plot(
|
|
df.index,
|
|
df[column],
|
|
marker=marker,
|
|
linestyle=linestyle,
|
|
label=label,
|
|
markersize=4, # Smaller markers for better performance
|
|
linewidth=1.5, # Optimized line width
|
|
)
|
|
|
|
def _calculate_daily_dose(self, dose_str: str) -> float:
|
|
"""Calculate total daily dose from dose string format with optimizations."""
|
|
if not dose_str or pd.isna(dose_str) or str(dose_str).lower() == "nan":
|
|
return 0.0
|
|
|
|
total_dose = 0.0
|
|
# Optimize string processing
|
|
dose_str = str(dose_str).replace("•", "").strip()
|
|
|
|
# More efficient splitting and processing
|
|
dose_entries = dose_str.split("|") if "|" in dose_str else [dose_str]
|
|
|
|
for entry in dose_entries:
|
|
entry = entry.strip()
|
|
if not entry:
|
|
continue
|
|
|
|
try:
|
|
# More efficient dose extraction
|
|
dose_part = entry.split(":")[-1] if ":" in entry else entry
|
|
|
|
# Optimized numeric extraction
|
|
dose_value = ""
|
|
for char in dose_part:
|
|
if char.isdigit() or char == ".":
|
|
dose_value += char
|
|
elif dose_value:
|
|
break
|
|
|
|
if dose_value:
|
|
total_dose += float(dose_value)
|
|
except (ValueError, IndexError):
|
|
continue
|
|
|
|
return total_dose
|
|
|
|
def close(self) -> None:
|
|
"""Clean up resources with proper optimization."""
|
|
try:
|
|
# Clear the plot before closing
|
|
self.ax.clear()
|
|
plt.close(self.fig)
|
|
except Exception:
|
|
pass # Ignore cleanup errors
|