""" ui/analysis.py -------------- Analysis screen — three Matplotlib charts embedded in a ttk.Notebook. • Frequency — bar chart of how often each number appears • Heatmap — positional frequency matrix • Gap — draws since each number last appeared """ import os import tkinter as tk from tkinter import ttk, filedialog, messagebox import numpy as np import matplotlib matplotlib.use("TkAgg") from matplotlib.figure import Figure from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk from db.models import get_all_games, get_game_by_id, get_game_by_name from core.analyzer import frequency_analysis, gap_analysis, positional_frequency from core.exporter import export_frequency_excel, ensure_exports_dir _LAST_N_OPTIONS = { "All draws": None, "Last 50": 50, "Last 100": 100, "Last 200": 200, "Last 500": 500, } class AnalysisScreen(ttk.Frame): def __init__(self, parent, **kwargs): super().__init__(parent, **kwargs) self._game_id: int | None = None self._build_ui() # ── UI construction ─────────────────────────────────────────────────────── def _build_ui(self): # Controls bar bar = ttk.Frame(self, padding=(6, 6, 6, 4)) bar.pack(fill="x") ttk.Label(bar, text="Game:").pack(side="left") self._game_var = tk.StringVar() self._game_cb = ttk.Combobox( bar, textvariable=self._game_var, state="readonly", width=15 ) self._game_cb.pack(side="left", padx=(4, 14)) self._game_cb.bind("<>", lambda _: self._on_game_change()) ttk.Label(bar, text="Frequency window:").pack(side="left") self._last_n_var = tk.StringVar(value="All draws") last_n_cb = ttk.Combobox( bar, textvariable=self._last_n_var, values=list(_LAST_N_OPTIONS.keys()), state="readonly", width=12, ) last_n_cb.pack(side="left", padx=(4, 0)) last_n_cb.bind("<>", lambda _: self._redraw_frequency()) ttk.Button(bar, text="↻ Refresh", command=self.refresh).pack(side="right") ttk.Button(bar, text="Export Frequency", command=self._export_frequency).pack(side="right", padx=(0, 4)) # Notebook with three chart tabs nb = ttk.Notebook(self) nb.pack(fill="both", expand=True, padx=6, pady=(0, 6)) self._freq_fig, self._freq_canvas = self._make_tab(nb, "Frequency") self._heat_fig, self._heat_canvas = self._make_tab(nb, "Heatmap") self._gap_fig, self._gap_canvas = self._make_tab(nb, "Gap") def _make_tab(self, notebook, title): frame = ttk.Frame(notebook) notebook.add(frame, text=title) fig = Figure(tight_layout=True) canvas = FigureCanvasTkAgg(fig, master=frame) canvas.get_tk_widget().pack(fill="both", expand=True) tb_frame = ttk.Frame(frame) tb_frame.pack(fill="x") NavigationToolbar2Tk(canvas, tb_frame) return fig, canvas # ── Data loading ────────────────────────────────────────────────────────── def refresh(self): self._load_games() self._redraw_all() def _load_games(self): games = get_all_games(active_only=True) names = [g["name"] for g in games] self._game_cb["values"] = names if not self._game_var.get() or self._game_var.get() not in names: if names: self._game_var.set(names[0]) game = get_game_by_name(self._game_var.get()) self._game_id = game["id"] if game else None def _on_game_change(self): game = get_game_by_name(self._game_var.get()) self._game_id = game["id"] if game else None self._redraw_all() def _get_last_n(self): return _LAST_N_OPTIONS.get(self._last_n_var.get()) # ── Redraw helpers ──────────────────────────────────────────────────────── def _redraw_all(self): self._redraw_frequency() self._redraw_heatmap() self._redraw_gap() def _redraw_frequency(self): self._freq_fig.clear() ax = self._freq_fig.add_subplot(111) if self._game_id is None: _empty(ax, "Select a game above") else: _draw_frequency(ax, self._game_id, self._get_last_n()) self._freq_canvas.draw() def _redraw_heatmap(self): self._heat_fig.clear() ax = self._heat_fig.add_subplot(111) if self._game_id is None: _empty(ax, "Select a game above") else: _draw_heatmap(ax, self._game_id) self._heat_canvas.draw() def _redraw_gap(self): self._gap_fig.clear() ax = self._gap_fig.add_subplot(111) if self._game_id is None: _empty(ax, "Select a game above") else: _draw_gap(ax, self._game_id) self._gap_canvas.draw() def _export_frequency(self): if self._game_id is None: messagebox.showinfo("Export", "Select a game first.") return ensure_exports_dir() fp = filedialog.asksaveasfilename( title="Export frequency analysis", defaultextension=".xlsx", filetypes=[("Excel workbook", "*.xlsx")], initialfile=f"frequency_{__import__('datetime').datetime.now().strftime('%Y%m%d')}.xlsx", ) if not fp: return try: last_n = _LAST_N_OPTIONS.get(self._last_n_var.get()) export_frequency_excel(fp, self._game_id, last_n=last_n) except Exception as e: messagebox.showerror("Export failed", str(e)) # ── Pure chart-drawing functions (no Tkinter, just axes) ───────────────────── def _empty(ax, message="No data available — use Fetch Now to download draws"): ax.set_axis_off() ax.text(0.5, 0.5, message, ha="center", va="center", fontsize=13, color="#888888", transform=ax.transAxes) def _draw_frequency(ax, game_id, last_n=None): data = frequency_analysis(game_id, last_n=last_n) if not data: _empty(ax) return nums = sorted(data.keys()) counts = [data.get(n, 0) for n in nums] avg = sum(counts) / len(counts) max_c = max(counts) or 1 # Gradient: cold (blue) → hot (red) based on frequency colors = [ (0.15 + 0.7 * (c / max_c), 0.25, 1.0 - 0.75 * (c / max_c)) for c in counts ] ax.bar(nums, counts, color=colors, width=0.75, edgecolor="none") ax.axhline(avg, color="#e74c3c", linewidth=1.3, linestyle="--", label=f"Avg {avg:.1f}") title = "Number Frequency" if last_n: title += f" (last {last_n} draws)" ax.set_title(title, fontsize=11) ax.set_xlabel("Number", fontsize=9) ax.set_ylabel("Count", fontsize=9) ax.legend(fontsize=9) ax.tick_params(axis="x", labelsize=7) ax.grid(axis="y", alpha=0.35) def _draw_heatmap(ax, game_id): pos_freq = positional_frequency(game_id) game = get_game_by_id(game_id) if not pos_freq or not any(pos_freq.values()): _empty(ax) return main_count = game["main_count"] main_max = game["main_max"] # Build rows × cols matrix (positions × numbers) matrix = np.zeros((main_count, main_max)) for pos in range(1, main_count + 1): for num, cnt in pos_freq.get(pos, {}).items(): if 1 <= num <= main_max: matrix[pos - 1, num - 1] = cnt im = ax.imshow(matrix, aspect="auto", cmap="YlOrRd", interpolation="nearest") ax.figure.colorbar(im, ax=ax, fraction=0.025, pad=0.02, label="Count") ax.set_yticks(range(main_count)) ax.set_yticklabels([f"Pos {i + 1}" for i in range(main_count)], fontsize=9) step = 5 xticks = range(0, main_max, step) ax.set_xticks(list(xticks)) ax.set_xticklabels([str(i + 1) for i in xticks], fontsize=8) ax.set_title("Positional Frequency Heatmap", fontsize=11) ax.set_xlabel("Number", fontsize=9) def _draw_gap(ax, game_id): gaps = gap_analysis(game_id) if not gaps: _empty(ax) return nums = sorted(gaps.keys()) gap_vals = [gaps[n] for n in nums] avg = sum(gap_vals) / len(gap_vals) max_g = max(gap_vals) or 1 # Gradient: low gap = blue (hot), high gap = red (due) colors = [ (0.8 * (g / max_g), 0.15, 1.0 - 0.8 * (g / max_g)) for g in gap_vals ] ax.bar(nums, gap_vals, color=colors, width=0.75, edgecolor="none") ax.axhline(avg, color="#e74c3c", linewidth=1.3, linestyle="--", label=f"Avg {avg:.1f}") ax.set_title("Gap Analysis — Draws Since Last Appearance", fontsize=11) ax.set_xlabel("Number", fontsize=9) ax.set_ylabel("Draws since last seen", fontsize=9) ax.legend(fontsize=9) ax.tick_params(axis="x", labelsize=7) ax.grid(axis="y", alpha=0.35)