Source code for clabtoolkit.plottools

import numpy as np
import matplotlib.pyplot as plt
from typing import Tuple, List


#####################################################################################################
[docs] def get_screen_size() -> Tuple[int, int]: """ Get the current screen size in pixels. Returns ------- tuple of int Screen width and height in pixels (width, height). Examples -------- >>> width, height = get_screen_size() >>> print(f"Screen size: {width}x{height}") """ import tkinter as tk root = tk.Tk() root.withdraw() # Hide the main window width = root.winfo_screenwidth() height = root.winfo_screenheight() root.destroy() # Clean up the Tkinter instance return width, height
#####################################################################################################
[docs] def get_current_monitor_size() -> Tuple[int, int]: """Get the size of the monitor where the mouse cursor is located.""" import tkinter as tk import screeninfo # Get mouse position root = tk.Tk() root.withdraw() mouse_x = root.winfo_pointerx() mouse_y = root.winfo_pointery() root.destroy() # Find which monitor contains the mouse monitors = screeninfo.get_monitors() for monitor in monitors: if ( monitor.x <= mouse_x < monitor.x + monitor.width and monitor.y <= mouse_y < monitor.y + monitor.height ): return monitor.width, monitor.height # Fallback to primary monitor primary = next((m for m in monitors if m.is_primary), monitors[0]) return primary.width, primary.height
#######################################################################################################
[docs] def estimate_monitor_dpi(screen_width: int, screen_height: int) -> float: """ Estimate monitor DPI based on screen resolution using common monitor configurations. Parameters ---------- screen_width : int Screen width in pixels screen_height : int Screen height in pixels Returns ------- float Estimated DPI based on common monitor size/resolution combinations Examples -------- >>> estimate_monitor_dpi(1920, 1080) 96.0 >>> >>> estimate_monitor_dpi(2560, 1440) 109.0 """ # Common monitor configurations: (width, height): typical_dpi monitor_configs = { # Full HD displays (1920, 1080): { "laptop_13_15": 147, # 13-15" laptop "laptop_17": 130, # 17" laptop "monitor_21_24": 92, # 21-24" monitor "monitor_27": 82, # 27" monitor }, # QHD displays (2560, 1440): { "laptop_13_15": 196, # 13-15" laptop "monitor_27": 109, # 27" monitor "monitor_32": 92, # 32" monitor }, # 4K displays (3840, 2160): { "laptop_15_17": 294, # 15-17" laptop "monitor_27": 163, # 27" monitor "monitor_32": 138, # 32" monitor "monitor_43": 103, # 43" monitor }, # Other common resolutions (1366, 768): { "laptop_11_14": 112, # Small laptops }, (1680, 1050): { "monitor_22": 90, # 22" monitor }, (2880, 1800): { "laptop_15": 220, # MacBook Pro 15" }, (3440, 1440): { "ultrawide_34": 110, # 34" ultrawide }, } # Find exact match first resolution = (screen_width, screen_height) if resolution in monitor_configs: # For known resolutions, estimate based on pixel density configs = monitor_configs[resolution] pixel_count = screen_width * screen_height # Estimate based on total pixels and common usage patterns if pixel_count < 1500000: # < 1.5M pixels (likely smaller screen) return max(configs.values()) # Higher DPI (smaller screen) elif pixel_count > 8000000: # > 8M pixels (4K+, likely larger screen) return min(configs.values()) # Lower DPI (larger screen) else: # Medium resolution, use median DPI return sorted(configs.values())[len(configs.values()) // 2] # Fallback: calculate approximate DPI based on pixel density # Assume reasonable screen diagonal based on resolution total_pixels = screen_width * screen_height if total_pixels <= 1000000: # ≤ 1M pixels estimated_diagonal = 13 # Small laptop/tablet elif total_pixels <= 2000000: # ≤ 2M pixels estimated_diagonal = 21 # Standard monitor elif total_pixels <= 4000000: # ≤ 4M pixels estimated_diagonal = 24 # Larger monitor elif total_pixels <= 8000000: # ≤ 8M pixels estimated_diagonal = 27 # QHD monitor else: # > 8M pixels estimated_diagonal = 32 # 4K monitor # Calculate DPI: sqrt(width² + height²) / diagonal_inches diagonal_pixels = (screen_width**2 + screen_height**2) ** 0.5 estimated_dpi = diagonal_pixels / estimated_diagonal return round(estimated_dpi, 1)
###############################################################################################
[docs] def calculate_optimal_subplots_grid(num_views: int) -> List[int]: """ Calculate optimal grid dimensions for a given number of views. Parameters ---------- num_views : int Number of views to arrange. Returns ------- List[int] [rows, columns] for optimal grid layout. Examples -------- >>> calculate_optimal_subplots_grid(4) [2, 2] >>> >>> calculate_optimal_subplots_grid(6) [2, 3] >>> >>> calculate_optimal_subplots_grid(1) [1, 1] """ # Calculate optimal grid dimensions based on number of views if num_views == 1: grid_size = [1, 1] position = [(0, 0)] return grid_size, position elif num_views == 2: grid_size = [1, 2] position = [(0, 0), (0, 1)] return grid_size, position elif num_views == 3: grid_size = [1, 3] position = [(0, 0), (0, 1), (0, 2)] return grid_size, position elif num_views == 4: # For 4 views, arrange in a 2x2 grid grid_size = [2, 2] position = [(0, 0), (0, 1), (1, 0), (1, 1)] return grid_size, position elif num_views <= 6: # For 5 or 6 views, arrange in a 2x3 grid grid_size = [2, 3] position = [(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2)] return grid_size, position elif num_views <= 8: # For 7 or 8 views, arrange in a 2x4 grid grid_size = [2, 4] position = [(0, 0), (0, 1), (0, 2), (0, 3), (1, 0), (1, 1), (1, 2), (1, 3)] return grid_size, position else: # For more than 8 views, try to keep a proportion with the screen shape screen_size = get_screen_size() # Calculate the number of columns and rows based on the number of views rows, cols, aspect = calculate_subplot_layout( num_views, screen_size[0], screen_size[1] ) grid_size = [rows, cols] position = [] for i in range(rows): for j in range(cols): if len(position) < num_views: position.append((i, j)) return grid_size, position
#####################################################################################################
[docs] def calculate_subplot_layout( n_plots, screen_width=None, screen_height=None, target_aspect_ratio=None ): """ Calculate optimal rows and columns for subplots based on screen proportions Parameters: ----------- n_plots : int Number of subplots needed screen_width : int, optional Screen width in pixels (auto-detected if not provided) screen_height : int, optional Screen height in pixels (auto-detected if not provided) target_aspect_ratio : float, optional Target aspect ratio (width/height). If provided, overrides screen detection Returns: -------- tuple: (rows, cols, aspect_ratio_used) """ if target_aspect_ratio is None: if screen_width is None or screen_height is None: screen_width, screen_height = get_screen_size() aspect_ratio = screen_width / screen_height else: aspect_ratio = target_aspect_ratio # Start with square root as baseline base_dim = np.ceil(np.sqrt(n_plots)) best_rows, best_cols = base_dim, base_dim best_score = float("inf") # Try different combinations around the baseline for rows in range(1, n_plots + 1): cols = np.ceil(n_plots / rows) # Skip if this creates too many empty subplots if rows * cols - n_plots > min(rows, cols): continue # Calculate how close this layout's aspect ratio is to screen ratio layout_aspect_ratio = cols / rows aspect_diff = abs(layout_aspect_ratio - aspect_ratio) # Prefer layouts that minimize aspect ratio difference # and minimize total subplots (less wasted space) total_subplots = rows * cols score = aspect_diff + 0.1 * (total_subplots - n_plots) if score < best_score: best_score = score best_rows, best_cols = rows, cols return int(best_rows), int(best_cols), aspect_ratio
#####################################################################################################
[docs] def create_proportional_subplots(n_plots, figsize_base=4, **layout_kwargs): """ Create a figure with subplots arranged according to screen proportions Parameters: ----------- n_plots : int Number of subplots figsize_base : float Base size for figure scaling **layout_kwargs : Additional arguments for calculate_subplot_layout() Returns: -------- tuple: (fig, axes, layout_info) """ rows, cols, aspect_ratio = calculate_subplot_layout(n_plots, **layout_kwargs) # Calculate figure size based on layout and aspect ratio fig_width = figsize_base * cols fig_height = fig_width / aspect_ratio # Create the figure and subplots fig, axes = plt.subplots(rows, cols, figsize=(fig_width, fig_height)) # Handle the case where there's only one subplot if n_plots == 1: axes = [axes] elif rows == 1 or cols == 1: axes = axes.flatten() else: axes = axes.flatten() # Hide extra subplots if any for i in range(n_plots, len(axes)): axes[i].set_visible(False) layout_info = { "rows": rows, "cols": cols, "aspect_ratio": aspect_ratio, "total_subplots": rows * cols, "used_subplots": n_plots, } plt.tight_layout() return fig, axes, layout_info
######################################################################################################
[docs] def calculate_font_sizes( plot_width, plot_height, screen_width=None, screen_height=None, colorbar_orientation="vertical", colorbar_width=None, colorbar_height=None, auto_detect_monitor=True, ): """ Calculate appropriate font sizes for matplotlib plots based on dimensions, monitor DPI, and colorbar configuration. This function automatically scales font sizes for plot elements (title, axis labels, tick labels, colorbar title, and colorbar ticks) based on the plot dimensions, monitor characteristics, and colorbar orientation. The scaling ensures optimal readability across different plot sizes and display configurations by calculating monitor DPI from screen resolution. The algorithm uses a reference plot size of 6×4 inches as a baseline and scales font sizes proportionally based on plot area and monitor DPI. Colorbar fonts are scaled independently based on colorbar dimensions and orientation constraints. Parameters ---------- plot_width : float Width of the plot in inches. Must be positive. Typical values: 3-20 inches for scientific plots. plot_height : float Height of the plot in inches. Must be positive. Typical values: 2-15 inches for scientific plots. screen_width : int, optional Screen width in pixels, by default None. If None and auto_detect_monitor=True, automatically detected. Used together with screen_height to calculate monitor DPI. screen_height : int, optional Screen height in pixels, by default None. If None and auto_detect_monitor=True, automatically detected. Used together with screen_width to calculate monitor DPI. colorbar_orientation : {'vertical', 'horizontal'}, optional Orientation of the colorbar, by default 'vertical'. - 'vertical': Colorbar positioned to the right/left of the plot - 'horizontal': Colorbar positioned above/below the plot colorbar_width : float, optional Width of the colorbar in inches, by default None. If None, automatically calculated as 5% of plot width (vertical) or 80% of plot width (horizontal), with minimum constraints. colorbar_height : float, optional Height of the colorbar in inches, by default None. If None, automatically calculated as 80% of plot height (vertical) or 5% of plot height (horizontal), with minimum constraints. auto_detect_monitor : bool, optional Whether to automatically detect current monitor size, by default True. If False, uses fallback values when screen dimensions are not provided. Returns ------- dict Dictionary containing font sizes for different plot elements: - 'title' : float Font size for the main plot title (8-28 pt range) - 'axis_labels' : float Font size for axis labels (6-20 pt range) - 'tick_labels' : float Font size for axis tick labels (6-20 pt range) - 'colorbar_title' : float Font size for colorbar title (6-16 pt range, orientation-dependent) - 'colorbar_ticks' : float Font size for colorbar tick labels (5-12 pt range, orientation-dependent) - '_monitor_info' : dict Debug information about monitor detection and DPI calculation All font sizes are rounded to 1 decimal place. Raises ------ ValueError If plot_width or plot_height are not positive numbers. ValueError If colorbar_orientation is not 'vertical' or 'horizontal'. TypeError If numeric parameters are not of appropriate numeric types. ImportError If auto_detect_monitor=True but required packages (tkinter, screeninfo) are not available. Examples -------- Basic usage with automatic monitor detection: >>> fonts = calculate_font_sizes(6, 4) >>> print(f"Title: {fonts['title']}, Colorbar title: {fonts['colorbar_title']}") Title: 14.2, Colorbar title: 11.4 Specify monitor dimensions manually: >>> fonts = calculate_font_sizes(6, 4, screen_width=2560, screen_height=1440) >>> fonts['_monitor_info']['estimated_dpi'] 109.0 Small subplot with horizontal colorbar on high-DPI display: >>> fonts = calculate_font_sizes(3, 2, ... screen_width=3840, screen_height=2160, ... colorbar_orientation='horizontal') >>> fonts['colorbar_title'] # Scaled up for high DPI 10.2 Disable auto-detection for headless environments: >>> fonts = calculate_font_sizes(12, 8, ... screen_width=1920, screen_height=1080, ... auto_detect_monitor=False) >>> fonts['title'] 19.1 Custom colorbar dimensions: >>> fonts = calculate_font_sizes(8, 6, ... colorbar_orientation='horizontal', ... colorbar_width=6.0, ... colorbar_height=0.5) Apply to matplotlib plot: >>> import matplotlib.pyplot as plt >>> fig, ax = plt.subplots(figsize=(6, 4)) >>> fonts = calculate_font_sizes(6, 4, colorbar_orientation='horizontal') >>> ax.set_title('Brain Activation', fontsize=fonts['title']) >>> ax.tick_params(labelsize=fonts['tick_labels']) >>> # For colorbar: >>> # cbar.set_label('Values', fontsize=fonts['colorbar_title']) >>> # cbar.ax.tick_params(labelsize=fonts['colorbar_ticks']) Notes ----- DPI Calculation and Scaling: 1. Automatically detects current monitor resolution using get_current_monitor_size() 2. Estimates monitor DPI based on resolution and common monitor configurations 3. Applies DPI-based scaling factor: scale = estimated_dpi / 96.0 (Windows standard) 4. Combines with plot area scaling for final font sizes Font scaling algorithm: 1. Calculate plot area scaling factor relative to 6×4 inch reference 2. Calculate monitor DPI scaling factor relative to 96 DPI baseline 3. Scale colorbar fonts based on constraining dimension: - Vertical colorbars: limited by width → scale by width/0.5" - Horizontal colorbars: limited by height → scale by height/0.4" 4. Apply orientation-specific bounds to ensure readability The function prioritizes readability over exact proportional scaling, applying reasonable minimum and maximum font sizes for each element type. Monitor configurations used for DPI estimation: - 1920×1080: 82-147 DPI (depending on screen size) - 2560×1440: 92-196 DPI - 3840×2160: 103-294 DPI - Other resolutions estimated using pixel density For PyVista compatibility, convert results to integers: >>> pv_fonts = {k: int(round(v)) for k, v in fonts.items() if not k.startswith('_')} """ # Input validation if not isinstance(plot_width, (int, float)) or plot_width <= 0: raise ValueError("plot_width must be a positive number") if not isinstance(plot_height, (int, float)) or plot_height <= 0: raise ValueError("plot_height must be a positive number") if colorbar_orientation not in ["vertical", "horizontal"]: raise ValueError("colorbar_orientation must be 'vertical' or 'horizontal'") # Get monitor dimensions if screen_width is None or screen_height is None: if auto_detect_monitor: try: detected_width, detected_height = get_current_monitor_size() screen_width = screen_width or detected_width screen_height = screen_height or detected_height except ImportError as e: raise ImportError( "Auto monitor detection requires 'tkinter' and 'screeninfo' packages. " "Install with: pip install screeninfo\n" "Or disable auto-detection: auto_detect_monitor=False" ) from e else: # Fallback to common resolution screen_width = screen_width or 1920 screen_height = screen_height or 1080 # Calculate monitor DPI estimated_dpi = estimate_monitor_dpi(screen_width, screen_height) # Calculate scaling factors plot_area = plot_width * plot_height base_area = 24.0 # 6 * 4 inches reference area_scale_factor = (plot_area / base_area) ** 0.5 # DPI scaling relative to 96 DPI baseline (Windows standard) dpi_scale_factor = estimated_dpi / 96.0 # Cap DPI scaling to reasonable bounds dpi_scale_factor = max(0.8, min(2.0, dpi_scale_factor)) # Combined plot scaling plot_scale = area_scale_factor * dpi_scale_factor # Colorbar-specific scaling if colorbar_orientation == "vertical": if colorbar_width is None: colorbar_width = max(0.3, plot_width * 0.05) if colorbar_height is None: colorbar_height = plot_height * 0.8 colorbar_scale = ( colorbar_width / 0.5 ) * dpi_scale_factor # Reference: 0.5" wide else: # horizontal if colorbar_width is None: colorbar_width = plot_width * 0.8 if colorbar_height is None: colorbar_height = max(0.3, plot_height * 0.05) colorbar_scale = ( colorbar_height / 0.4 ) * dpi_scale_factor # Reference: 0.4" tall # Base font sizes (optimized for 96 DPI) base_sizes = { "title": 14, "axis_labels": 12, "tick_labels": 10, "colorbar_title": 11, "colorbar_ticks": 9, } font_sizes = {} for element, base_size in base_sizes.items(): if element.startswith("colorbar"): scaled_size = base_size * colorbar_scale else: scaled_size = base_size * plot_scale # Apply reasonable bounds if element == "title": scaled_size = max(8, min(28, scaled_size)) elif element == "colorbar_title": if colorbar_orientation == "horizontal": scaled_size = max(7, min(16, scaled_size)) else: scaled_size = max(6, min(14, scaled_size)) elif element == "colorbar_ticks": if colorbar_orientation == "horizontal": scaled_size = max(6, min(12, scaled_size)) else: scaled_size = max(5, min(10, scaled_size)) else: scaled_size = max(6, min(20, scaled_size)) font_sizes[element] = round(scaled_size, 1) # Add monitor info for debugging font_sizes["_monitor_info"] = { "screen_width": screen_width, "screen_height": screen_height, "estimated_dpi": estimated_dpi, "dpi_scale_factor": round(dpi_scale_factor, 2), "area_scale_factor": round(area_scale_factor, 2), "plot_scale": round(plot_scale, 2), "colorbar_scale": round(colorbar_scale, 2), } return font_sizes
###################################################################################################### # Additional utility functions for common use cases
[docs] def get_pyvista_fonts(plot_width, plot_height, **kwargs): """ Get integer font sizes specifically for PyVista compatibility. Parameters ---------- plot_width : float Width of the plot in inches. plot_height : float Height of the plot in inches. **kwargs Additional keyword arguments passed to calculate_font_sizes(). Returns ------- dict Dictionary with integer font sizes suitable for PyVista (excludes debug info). Examples -------- >>> fonts = get_pyvista_fonts(8, 6, colorbar_orientation='vertical') >>> plotter.add_title('3D Brain', font_size=fonts['title']) """ fonts = calculate_font_sizes(plot_width, plot_height, **kwargs) # Exclude debug info for clean PyVista usage return { key: int(round(value)) for key, value in fonts.items() if not key.startswith("_") }