1515# ----------------------------- Tk Figure Window -----------------------------
1616
1717def 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
6384def 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
317306def 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