Skip to content

Commit ccba89c

Browse files
Refactor correlogram window and wizard; improve layout, resizing, and dataset name handling
1 parent 13498cc commit ccba89c

3 files changed

Lines changed: 109 additions & 102 deletions

File tree

-408 KB
Binary file not shown.

live_processing/correlogram.py

Lines changed: 78 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -15,27 +15,27 @@
1515
# ----------------------------- Tk Figure Window -----------------------------
1616

1717
def show_correlogram_window(parent: tk.Misc, fig, title: str):
18-
"""
19-
Embed a Matplotlib Figure in its own persistent Tk Toplevel, parented to the app root.
20-
Centers the window on screen.
21-
"""
2218
root = parent.winfo_toplevel() if isinstance(parent, tk.Misc) else None
2319

2420
win = tk.Toplevel(master=root)
2521
win.title(title)
2622

27-
container = tk.Frame(win)
28-
container.pack(fill="both", expand=True)
23+
outer = tk.Frame(win)
24+
outer.pack(fill="both", expand=True)
2925

30-
canvas = FigureCanvasTkAgg(fig, master=container)
31-
canvas.draw()
32-
canvas.get_tk_widget().pack(fill="both", expand=True)
26+
# Canvas (created first so we can hand it to the toolbar)
27+
canvas = FigureCanvasTkAgg(fig, master=outer)
28+
canvas_widget = canvas.get_tk_widget()
3329

34-
toolbar = NavigationToolbar2Tk(canvas, container)
30+
# Toolbar ON TOP so controls don't get hidden
31+
toolbar = NavigationToolbar2Tk(canvas, outer)
3532
toolbar.update()
36-
toolbar.pack(fill="x")
33+
toolbar.pack(side="top", fill="x")
34+
35+
# Canvas fills the rest
36+
canvas_widget.pack(side="top", fill="both", expand=True)
3737

38-
# keep refs so they don’t get garbage collected
38+
# Keep refs
3939
win._canvas = canvas
4040
win._toolbar = toolbar
4141
win._fig = fig
@@ -47,30 +47,43 @@ def _on_close():
4747
except Exception:
4848
pass
4949
win.destroy()
50-
5150
win.protocol("WM_DELETE_WINDOW", _on_close)
5251

53-
# --- Center on screen ---
54-
# Let widgets compute their natural size, then center to that size
52+
# Cap initial window size to screen and center it
5553
win.update_idletasks()
56-
width = max(900, win.winfo_reqwidth()) # ensure a reasonable minimum width
57-
height = max(650, win.winfo_reqheight()) # ensure a reasonable minimum height
58-
center_window(win, width, height)
54+
sw, sh = win.winfo_screenwidth(), win.winfo_screenheight()
55+
req_w, req_h = win.winfo_reqwidth(), win.winfo_reqheight()
56+
width = min(max(900, req_w), int(sw * 0.9))
57+
height = min(max(650, req_h), int(sh * 0.9))
58+
x = (sw - width) // 2
59+
y = (sh - height) // 2
60+
win.geometry(f"{width}x{height}+{x}+{y}")
61+
62+
# Resize: match figure size to the canvas widget in pixels
63+
def _resize_figure(event=None):
64+
try:
65+
w_px = canvas_widget.winfo_width()
66+
h_px = canvas_widget.winfo_height()
67+
# Ignore early calls when Tk hasn’t assigned size yet
68+
if w_px < 200 or h_px < 200:
69+
return
70+
dpi = fig.get_dpi() or 100
71+
fig.set_size_inches(w_px / dpi, h_px / dpi, forward=True)
72+
canvas_widget.configure(width=w_px, height=h_px)
73+
canvas.draw() # no tight_layout() or engine changes here
74+
except Exception:
75+
pass
76+
77+
canvas_widget.bind("<Configure>", _resize_figure)
78+
win.bind("<Configure>", _resize_figure)
79+
win.after(60, _resize_figure) # let Tk map the window first
5980

6081
return win
6182

6283

6384
def ask_correlogram_options(parent: tk.Misc, *, ds1: str, ds2: str):
6485
"""
6586
Modal dialog to pick plot options right after the wizard finishes.
66-
Centers itself on screen before showing.
67-
Returns a dict like:
68-
{ 'normalize': 'none'|'row'|'col'|'all',
69-
'cmap': str,
70-
'annotate': bool,
71-
'zero_diag': bool,
72-
'title': str }
73-
or None if cancelled.
7487
"""
7588
root = parent.winfo_toplevel() if isinstance(parent, tk.Misc) else None
7689

@@ -98,30 +111,30 @@ def ask_correlogram_options(parent: tk.Misc, *, ds1: str, ds2: str):
98111
frm.pack(fill="both", expand=True)
99112

100113
# Title
101-
ttk.Label(frm, text="Title:").grid(row=0, column=0, sticky="w", padx=(0,8), pady=(0,8))
114+
ttk.Label(frm, text="Title:").grid(row=0, column=0, sticky="w", padx=(0, 8), pady=(0, 8))
102115
title_entry = ttk.Entry(frm, textvariable=title_var, width=48)
103-
title_entry.grid(row=0, column=1, sticky="ew", pady=(0,8))
116+
title_entry.grid(row=0, column=1, sticky="ew", pady=(0, 8))
104117

105118
# Normalize
106-
ttk.Label(frm, text="Normalize:").grid(row=1, column=0, sticky="w", padx=(0,8), pady=4)
107-
normalize_combo = ttk.Combobox(frm, textvariable=normalize_var, values=["none", "row", "col", "all"], state="readonly", width=12)
119+
ttk.Label(frm, text="Normalize:").grid(row=1, column=0, sticky="w", padx=(0, 8), pady=4)
120+
normalize_combo = ttk.Combobox(frm, textvariable=normalize_var, values=["none", "row", "col", "all"],
121+
state="readonly", width=12)
108122
normalize_combo.grid(row=1, column=1, sticky="w", pady=4)
109123

110124
# Colormap
111-
ttk.Label(frm, text="Colormap:").grid(row=2, column=0, sticky="w", padx=(0,8), pady=4)
125+
ttk.Label(frm, text="Colormap:").grid(row=2, column=0, sticky="w", padx=(0, 8), pady=4)
112126
cmap_combo = ttk.Combobox(frm, textvariable=cmap_var, values=cmaps, state="readonly", width=20)
113127
cmap_combo.grid(row=2, column=1, sticky="w", pady=4)
114128

115129
# Checkboxes
116130
annotate_chk = ttk.Checkbutton(frm, text="Annotate cells", variable=annotate_var)
117131
annotate_chk.grid(row=3, column=1, sticky="w", pady=4)
118-
119132
zero_diag_chk = ttk.Checkbutton(frm, text="Zero-out diagonal", variable=zero_diag_var)
120133
zero_diag_chk.grid(row=4, column=1, sticky="w", pady=4)
121134

122135
# Buttons
123136
btns = ttk.Frame(frm)
124-
btns.grid(row=5, column=0, columnspan=2, sticky="e", pady=(12,0))
137+
btns.grid(row=5, column=0, columnspan=2, sticky="e", pady=(12, 0))
125138
result = {"_ok": False}
126139

127140
def on_ok():
@@ -139,21 +152,19 @@ def on_cancel():
139152
result["_ok"] = False
140153
win.destroy()
141154

142-
ttk.Button(btns, text="Cancel", command=on_cancel).pack(side="right", padx=(0,8))
155+
ttk.Button(btns, text="Cancel", command=on_cancel).pack(side="right", padx=(0, 8))
143156
ttk.Button(btns, text="OK", command=on_ok).pack(side="right")
144157

145158
# Sizing and focus
146159
frm.columnconfigure(1, weight=1)
147160
title_entry.focus_set()
148161

149-
# --- Center on screen ---
162+
# Center
150163
win.update_idletasks()
151-
# Use the natural requested size, but ensure a sensible minimum
152164
width = max(520, win.winfo_reqwidth())
153165
height = max(240, win.winfo_reqheight())
154166
center_window(win, width, height)
155167

156-
# Modal behavior
157168
win.protocol("WM_DELETE_WINDOW", on_cancel)
158169
win.wait_visibility()
159170
win.wait_window()
@@ -169,73 +180,51 @@ def plot_label_correlogram(
169180
col_y: str, # dataset 1 name (y-axis)
170181
col_x: str, # dataset 2 name (x-axis)
171182
weight: str = "count", # "count" (kept for parity)
172-
normalize: str = "row", # "none" | "row" | "col" | "all"
183+
normalize: str = "row", # "none" | "row" | "col" | "all"
173184
cmap: str = "Blues",
174185
annotate: bool = True,
175186
zero_diag: bool = False, # set True to zero-out perfect matches on the diagonal
176187
title: str | None = None,
177188
):
178189
"""
179190
Plot a correlogram (co-occurrence heatmap) between two categorical columns.
180-
181-
Parameters
182-
----------
183-
df : pd.DataFrame
184-
Must contain columns: 'text', col_y, col_x.
185-
col_y : str
186-
Column name for Y axis (Dataset 1 label column).
187-
col_x : str
188-
Column name for X axis (Dataset 2 label column).
189-
weight : {"count"}, default "count"
190-
- "count": cell value = number of rows with (col_y, col_x)
191-
normalize : {"none", "row", "col", "all"}, default "none"
192-
- "row": each row sums to 1
193-
- "col": each column sums to 1
194-
- "all": divide by grand total
195-
- "none": raw counts
196-
cmap : str, default "Blues"
197-
Matplotlib colormap name.
198-
annotate : bool, default True
199-
Draw numeric labels on each cell.
200-
zero_diag : bool, default False
201-
If True, sets the diagonal to zero before normalization/plotting.
202-
title : str or None
203-
Optional plot title.
204-
205-
Returns
206-
-------
207-
fig, ax : matplotlib Figure and Axes
191+
Returns fig, ax.
208192
"""
209-
210193
required_cols = {"text", col_y, col_x}
211194
missing = required_cols.difference(df.columns)
212195
if missing:
213196
raise ValueError(f"DataFrame is missing required columns: {sorted(missing)}")
214197

215-
# Work on a copy to avoid modifying the caller's df
216198
data = df.copy()
217-
218-
# Ensure labels are strings (prevents issues with None/NaN vs. ints)
219199
data[col_y] = data[col_y].astype(str)
220200
data[col_x] = data[col_x].astype(str)
221201

222-
# Axis order: alphabetical by default (stable and predictable)
202+
# Axis order
223203
y_order = sorted(pd.unique(data[col_y]))
224204
x_order = sorted(pd.unique(data[col_x]))
225205

226-
# Build the matrix (counts)
206+
# Choose a smaller label font when many categories
207+
n_labels = max(len(x_order), len(y_order))
208+
if n_labels <= 15:
209+
lab_fs = 10
210+
elif n_labels <= 30:
211+
lab_fs = 8
212+
else:
213+
lab_fs = 6
214+
215+
# Counts matrix
227216
mat = pd.crosstab(
228217
index=pd.Categorical(data[col_y], categories=y_order, ordered=True),
229218
columns=pd.Categorical(data[col_x], categories=x_order, ordered=True),
230219
).reindex(index=y_order, columns=x_order, fill_value=0)
231220

232-
# Optionally zero the diagonal (useful if you only care about cross-label associations)
221+
# Optionally zero the diagonal
233222
if zero_diag:
234223
common = set(mat.index).intersection(mat.columns)
235224
for k in common:
236225
mat.loc[k, k] = 0
237226

238-
# Normalize as requested
227+
# Normalize
239228
mat_values = mat.to_numpy(dtype=float)
240229
if normalize == "row":
241230
row_sums = mat_values.sum(axis=1, keepdims=True)
@@ -248,8 +237,7 @@ def plot_label_correlogram(
248237
mat_plot = mat_values / col_sums
249238
fmt = ".2f"
250239
elif normalize == "all":
251-
total = mat_values.sum()
252-
total = total if total != 0 else 1.0
240+
total = mat_values.sum() or 1.0
253241
mat_plot = mat_values / total
254242
fmt = ".3f"
255243
elif normalize == "none":
@@ -258,10 +246,12 @@ def plot_label_correlogram(
258246
else:
259247
raise ValueError("normalize must be one of {'none','row','col','all'}")
260248

261-
# Plot (size scales with label cardinality)
262-
fig_w = max(6, len(x_order) * 0.5 + 2)
263-
fig_h = max(5, len(y_order) * 0.5 + 2)
249+
# Start with a modest base and cap growth
250+
fig_w = min(max(8, len(x_order) * 0.30 + 3), 14)
251+
fig_h = min(max(6, len(y_order) * 0.30 + 2), 12)
264252
fig, ax = plt.subplots(figsize=(fig_w, fig_h))
253+
fig.set_layout_engine('constrained') # choose ONE engine before adding colorbar
254+
265255
im = ax.imshow(mat_plot, aspect="auto", cmap=cmap)
266256

267257
# Colorbar
@@ -275,16 +265,16 @@ def plot_label_correlogram(
275265
# Ticks & labels
276266
ax.set_xticks(np.arange(len(x_order)))
277267
ax.set_yticks(np.arange(len(y_order)))
278-
ax.set_xticklabels(x_order, rotation=45, ha="right")
279-
ax.set_yticklabels(y_order)
268+
ax.set_xticklabels(x_order, rotation=45, ha="right", fontsize=lab_fs)
269+
ax.set_yticklabels(y_order, fontsize=lab_fs)
270+
ax.set_title(title, pad=10)
280271

281-
# Grid lines
272+
# Grid
282273
ax.set_xticks(np.arange(-.5, len(x_order), 1), minor=True)
283274
ax.set_yticks(np.arange(-.5, len(y_order), 1), minor=True)
284275
ax.grid(which="minor", color="w", linestyle="-", linewidth=0.8)
285276
ax.tick_params(which="minor", bottom=False, left=False)
286277

287-
# Annotations
288278
if annotate:
289279
vmax = np.nanmax(mat_plot) if mat_plot.size else 0
290280
threshold = vmax * 0.6 if vmax > 0 else 0
@@ -301,9 +291,8 @@ def plot_label_correlogram(
301291
norm = "" if normalize == "none" else f" ({normalize}-normalized)"
302292
title = f"Correlogram of {col_y} × {col_x}{base}{norm}"
303293
ax.set_title(title)
304-
fig.tight_layout()
305294

306-
# Give the native window a nice title (helps when multiple figures are open)
295+
# Nice window title (if backend supports it)
307296
try:
308297
fig.canvas.manager.set_window_title(title)
309298
except Exception:
@@ -316,19 +305,15 @@ def plot_label_correlogram(
316305

317306
def open_correlogram_wizard(parent: tk.Misc):
318307
"""
319-
Opens the reusable two-page wizard and, on finish, prompts for plot options
320-
and then renders the correlogram in a persistent Tk window.
321-
Mirrors the usage pattern in reliability_calculator.
308+
Opens the two-page wizard and, on finish, prompts for plot options,
309+
then renders the correlogram in a persistent Tk window.
322310
"""
323-
324311
def _finish_handler(*args):
325-
# Accept either signature: (res) or (res, win)
326312
if not args:
327313
return False
328314
res: WizardResult = args[0]
329315
host_parent = parent
330316

331-
# Validate expected fields from the wizard
332317
try:
333318
merged = res.merged.copy()
334319
ds1 = res.ds1_name
@@ -347,10 +332,9 @@ def _finish_handler(*args):
347332
)
348333
return False
349334

350-
# === NEW: Ask user for plotting options ===
335+
# Ask user for plotting options
351336
opts = ask_correlogram_options(host_parent, ds1=ds1, ds2=ds2)
352337
if opts is None:
353-
# User cancelled the options dialog; do not open the correlogram window.
354338
return False
355339

356340
# Build figure with chosen options
@@ -366,11 +350,8 @@ def _finish_handler(*args):
366350
title=opts["title"],
367351
)
368352

369-
# Show in persistent Tk window (no plt.show())
353+
# Show in persistent Tk window
370354
show_correlogram_window(parent=host_parent, fig=fig, title=opts["title"])
371-
372-
# Return True so wizards that expect a truthy value will close.
373355
return True
374356

375-
# Launch the wizard; our finish handler handles options + plotting.
376357
return open_two_page_wizard(parent, on_finish=_finish_handler)

0 commit comments

Comments
 (0)