310 lines
12 KiB
Python
310 lines
12 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
|
|
|
|
|
|
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)
|