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 class GraphManager: """Handle all graph-related operations for the application.""" def __init__(self, parent_frame: ttk.LabelFrame) -> None: self.parent_frame: ttk.LabelFrame = parent_frame # Configure graph frame to expand self.parent_frame.grid_rowconfigure(0, weight=1) self.parent_frame.grid_columnconfigure(0, weight=1) # Initialize toggle variables for chart elements self.toggle_vars: dict[str, tk.BooleanVar] = { "depression": tk.BooleanVar(value=True), "anxiety": tk.BooleanVar(value=True), "sleep": tk.BooleanVar(value=True), "appetite": tk.BooleanVar(value=True), "bupropion": tk.BooleanVar(value=False), "hydroxyzine": tk.BooleanVar(value=False), "gabapentin": tk.BooleanVar(value=False), "propranolol": tk.BooleanVar(value=False), "quetiapine": tk.BooleanVar(value=False), } # Create control frame for toggles self.control_frame: ttk.Frame = ttk.Frame(self.parent_frame) self.control_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5) # Create toggle checkboxes self._create_chart_toggles() # Create graph frame self.graph_frame: ttk.Frame = ttk.Frame(self.parent_frame) self.graph_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) # Reconfigure parent frame for new layout self.parent_frame.grid_rowconfigure(1, weight=1) self.parent_frame.grid_columnconfigure(0, weight=1) # Initialize matplotlib figure and canvas self.fig: matplotlib.figure.Figure self.ax: Axes self.fig, self.ax = plt.subplots() self.canvas: FigureCanvasTkAgg = FigureCanvasTkAgg( figure=self.fig, master=self.graph_frame ) self.canvas.get_tk_widget().pack(fill="both", expand=True) # Store current data for replotting self.current_data: pd.DataFrame = pd.DataFrame() def _create_chart_toggles(self) -> None: """Create toggle controls for chart elements.""" ttk.Label(self.control_frame, text="Show/Hide Elements:").pack( side="left", padx=5 ) # Symptoms toggles symptoms_frame = ttk.LabelFrame(self.control_frame, text="Symptoms") symptoms_frame.pack(side="left", padx=5, pady=2) symptom_configs = [ ("depression", "Depression"), ("anxiety", "Anxiety"), ("sleep", "Sleep"), ("appetite", "Appetite"), ] for key, label in symptom_configs: checkbox = ttk.Checkbutton( symptoms_frame, text=label, variable=self.toggle_vars[key], command=self._handle_toggle_changed, ) checkbox.pack(side="left", padx=3) # Medicines toggles medicines_frame = ttk.LabelFrame(self.control_frame, text="Medicines") medicines_frame.pack(side="left", padx=5, pady=2) medicine_configs = [ ("bupropion", "Bupropion"), ("hydroxyzine", "Hydroxyzine"), ("gabapentin", "Gabapentin"), ("propranolol", "Propranolol"), ("quetiapine", "Quetiapine"), ] for key, label in medicine_configs: checkbox = ttk.Checkbutton( medicines_frame, text=label, variable=self.toggle_vars[key], command=self._handle_toggle_changed, ) checkbox.pack(side="left", padx=3) def _handle_toggle_changed(self) -> None: """Handle toggle changes by replotting the graph.""" 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.""" self.current_data = df.copy() if not df.empty else pd.DataFrame() self._plot_graph_data(df) def _plot_graph_data(self, df: pd.DataFrame) -> None: """Plot the graph data with current toggle settings.""" self.ax.clear() if not df.empty: # Convert dates and sort df = df.copy() # Create a copy to avoid modifying the original df["date"] = pd.to_datetime(df["date"]) df = df.sort_values(by="date") df.set_index(keys="date", inplace=True) # Track if any series are plotted has_plotted_series = False # Plot data series based on toggle states if self.toggle_vars["depression"].get(): self._plot_series( df, "depression", "Depression (0:good, 10:bad)", "o", "-" ) has_plotted_series = True if self.toggle_vars["anxiety"].get(): self._plot_series(df, "anxiety", "Anxiety (0:good, 10:bad)", "o", "-") has_plotted_series = True if self.toggle_vars["sleep"].get(): self._plot_series(df, "sleep", "Sleep (0:bad, 10:good)", "o", "dashed") has_plotted_series = True if self.toggle_vars["appetite"].get(): self._plot_series( df, "appetite", "Appetite (0:bad, 10:good)", "o", "dashed" ) has_plotted_series = True # Plot medicine dose data medicine_colors = { "bupropion": "#FF6B6B", # Red "hydroxyzine": "#4ECDC4", # Teal "gabapentin": "#45B7D1", # Blue "propranolol": "#96CEB4", # Green "quetiapine": "#FFEAA7", # Yellow } medicines = [ "bupropion", "hydroxyzine", "gabapentin", "propranolol", "quetiapine", ] # Track medicines with and without data for legend medicines_with_data = [] medicines_without_data = [] for medicine in medicines: dose_column = f"{medicine}_doses" if self.toggle_vars[medicine].get() and dose_column in df.columns: # Calculate daily dose totals daily_doses = [] for dose_str in df[dose_column]: total_dose = self._calculate_daily_dose(dose_str) daily_doses.append(total_dose) # Only plot if there are non-zero doses if any(dose > 0 for dose in daily_doses): medicines_with_data.append(medicine) # Scale doses for better visibility # (divide by 10 to fit with 0-10 scale) scaled_doses = [dose / 10 for dose in daily_doses] # Calculate total dosage for this medicine across all days total_medicine_dose = sum(daily_doses) non_zero_doses = [d for d in daily_doses if d > 0] avg_dose = total_medicine_dose / len(non_zero_doses) # Create more informative label label = f"{medicine.capitalize()} (avg: {avg_dose:.1f}mg)" 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, ) has_plotted_series = True else: # Medicine is toggled on but has no dose data if self.toggle_vars[medicine].get(): medicines_without_data.append(medicine) # Configure graph appearance if has_plotted_series: # Get current legend handles and labels handles, labels = self.ax.get_legend_handles_labels() # Add information about medicines without data if any are toggled on if medicines_without_data: # Add a text note about medicines without dose data med_list = ", ".join(medicines_without_data) info_text = f"Tracked (no doses): {med_list}" labels.append(info_text) # Create a dummy handle for the info text (invisible) 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 an expanded legend with better formatting self.ax.legend( handles, labels, loc="upper left", bbox_to_anchor=(0, 1), ncol=2, # Display in 2 columns for better space usage fontsize="small", frameon=True, fancybox=True, shadow=True, framealpha=0.9, ) self.ax.set_title("Medication Effects Over Time") self.ax.set_xlabel("Date") self.ax.set_ylabel("Rating (0-10) / Dose (mg)") # Adjust y-axis to accommodate medicine bars at bottom current_ylim = self.ax.get_ylim() self.ax.set_ylim(bottom=current_ylim[0], top=max(10, current_ylim[1])) self.fig.autofmt_xdate() # Redraw the canvas self.canvas.draw() def _plot_series( self, df: pd.DataFrame, column: str, label: str, marker: str, linestyle: str, ) -> None: """Helper method to plot a data series.""" self.ax.plot( df.index, df[column], marker=marker, linestyle=linestyle, label=label, ) def _calculate_daily_dose(self, dose_str: str) -> float: """Calculate total daily dose from dose string format.""" if not dose_str or pd.isna(dose_str) or str(dose_str).lower() == "nan": return 0.0 total_dose = 0.0 # Handle different separators and clean the string dose_str = str(dose_str).replace("•", "").strip() # Split by | or by spaces if no | present 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: # Extract dose part after the last colon (timestamp:dose format) dose_part = entry.split(":")[-1] if ":" in entry else entry # Extract numeric part from dose (e.g., "150mg" -> 150) dose_value = "" for char in dose_part: if char.isdigit() or char == ".": dose_value += char elif dose_value: # Stop at first non-digit after finding digits break if dose_value: total_dose += float(dose_value) except (ValueError, IndexError): continue return total_dose def close(self) -> None: """Clean up resources.""" plt.close(self.fig)