1111from packaging import version
1212
1313try :
14- from typing import List , Optional , Tuple , Union
14+ from typing import Any , Iterable , List , Optional , Tuple , Union
1515except ImportError :
16- from typing_extensions import List , Optional , Tuple , Union
16+ from typing_extensions import Any , Iterable , List , Optional , Tuple , Union
1717
1818import matplotlib .axes as maxes
1919import matplotlib .figure as mfigure
@@ -868,6 +868,7 @@ def _normalize_share(value):
868868 self ._supylabel_dict = {} # an axes: label mapping
869869 self ._suplabel_dict = {"left" : {}, "right" : {}, "bottom" : {}, "top" : {}}
870870 self ._share_label_groups = {"x" : {}, "y" : {}} # explicit label-sharing groups
871+ self ._subset_title_dict = {}
871872 self ._suptitle_pad = rc ["suptitle.pad" ]
872873 d = self ._suplabel_props = {} # store the super label props
873874 d ["left" ] = {"va" : "center" , "ha" : "right" }
@@ -1662,7 +1663,9 @@ def _get_align_coord(self, side, axs, align="center", includepanels=False):
16621663 ax = ax ._panel_parent or ax # always use main subplot for spanning labels
16631664 return pos , ax
16641665
1665- def _get_offset_coord (self , side , axs , renderer , * , pad = None , extra = None ):
1666+ def _get_offset_coord (
1667+ self , side , axs , renderer , * , pad = None , extra = None , include_subset_titles = True
1668+ ):
16661669 """
16671670 Return the figure coordinate for offsetting super labels and super titles.
16681671 """
@@ -1675,7 +1678,12 @@ def _get_offset_coord(self, side, axs, renderer, *, pad=None, extra=None):
16751678 ) # noqa: E501
16761679 objs = objs + (extra or ()) # e.g. top super labels
16771680 for obj in objs :
1678- bbox = obj .get_tightbbox (renderer ) # cannot use cached bbox
1681+ if isinstance (obj , paxes .Axes ):
1682+ bbox = obj .get_tightbbox (
1683+ renderer , include_subset_titles = include_subset_titles
1684+ )
1685+ else :
1686+ bbox = obj .get_tightbbox (renderer ) # cannot use cached bbox
16791687 attr = s + "max" if side in ("top" , "right" ) else s + "min"
16801688 c = getattr (bbox , attr )
16811689 c = (c , 0 ) if side in ("left" , "right" ) else (0 , c )
@@ -2523,6 +2531,12 @@ def _align_super_title(self, renderer):
25232531 if not axs :
25242532 return
25252533 labs = tuple (t for t in self ._suplabel_dict ["top" ].values () if t .get_text ())
2534+ subset_titles = tuple (
2535+ group ["artist" ]
2536+ for group in self ._subset_title_dict .values ()
2537+ if group ["artist" ].get_text ()
2538+ )
2539+ labs = labs + subset_titles
25262540 pad = (self ._suptitle_pad / 72 ) / self .get_size_inches ()[1 ]
25272541
25282542 # Get current alignment settings from suptitle (may be set via suptitle_kw)
@@ -2548,6 +2562,183 @@ def _align_super_title(self, renderer):
25482562 y = y_target - y_bbox
25492563 self ._suptitle .set_position ((x , y ))
25502564
2565+ def _update_subset_title (
2566+ self ,
2567+ axes : Iterable [paxes .Axes ],
2568+ title : str | None ,
2569+ * ,
2570+ fontdict : dict [str , Any ] | None = None ,
2571+ loc : str | None = None ,
2572+ pad : float | str | None = None ,
2573+ y : float | None = None ,
2574+ ** kwargs : Any ,
2575+ ) -> mtext .Text :
2576+ """
2577+ Create or update a title spanning a subset of subplots.
2578+ """
2579+ fontdict = _not_none (fontdict , kwargs .pop ("fontdict" , None ))
2580+ loc = _not_none (
2581+ loc ,
2582+ kwargs .pop ("loc" , None ),
2583+ rc .find ("title.loc" , context = True ),
2584+ rc ["title.loc" ],
2585+ )
2586+ pad = _not_none (
2587+ pad ,
2588+ kwargs .pop ("pad" , None ),
2589+ rc .find ("title.pad" , context = True ),
2590+ rc ["title.pad" ],
2591+ )
2592+ y = _not_none (y , kwargs .pop ("y" , None ))
2593+ axes = [ax for ax in axes if ax is not None and ax .figure is self ]
2594+ if not axes :
2595+ raise ValueError ("Need at least one axes to create a shared subplot title." )
2596+
2597+ seen = set ()
2598+ unique_axes = []
2599+ for ax in axes :
2600+ ax = ax ._panel_parent or ax
2601+ ax_id = id (ax )
2602+ if ax_id in seen :
2603+ continue
2604+ seen .add (ax_id )
2605+ unique_axes .append (ax )
2606+ axes = unique_axes
2607+ if len (axes ) < 2 :
2608+ return axes [0 ].set_title (
2609+ title , fontdict = fontdict , loc = loc , pad = pad , y = y , ** kwargs
2610+ )
2611+
2612+ key = tuple (sorted (id (ax ) for ax in axes ))
2613+ group = self ._subset_title_dict .get (key )
2614+ kw = rc .fill (
2615+ {
2616+ "size" : "title.size" ,
2617+ "weight" : "title.weight" ,
2618+ "color" : "title.color" ,
2619+ "family" : "font.family" ,
2620+ },
2621+ context = True ,
2622+ )
2623+ if "color" in kw and kw ["color" ] == "auto" :
2624+ del kw ["color" ]
2625+ if fontdict :
2626+ kw .update (fontdict )
2627+ kw .update (kwargs )
2628+ align = _translate_loc (loc , "text" )
2629+ match align :
2630+ case "left" | "outer left" | "upper left" | "lower left" :
2631+ align = "left"
2632+ case "center" | "upper center" | "lower center" :
2633+ align = "center"
2634+ case "right" | "outer right" | "upper right" | "lower right" :
2635+ align = "right"
2636+ case _:
2637+ raise ValueError (f"Invalid shared subplot title location { loc !r} ." )
2638+ if group is None :
2639+ artist = self .text (
2640+ 0.5 ,
2641+ 0.0 ,
2642+ "" ,
2643+ transform = self .transFigure ,
2644+ ha = align ,
2645+ va = "baseline" ,
2646+ zorder = 3.5 ,
2647+ )
2648+ group = {"axes" : axes , "artist" : artist , "pad" : None , "y" : None }
2649+ self ._subset_title_dict [key ] = group
2650+ else :
2651+ artist = group ["artist" ]
2652+ group ["axes" ] = axes
2653+ group ["pad" ] = pad
2654+ group ["y" ] = y
2655+ artist .set_ha (align )
2656+ artist .set_va ("baseline" )
2657+ if title is not None :
2658+ artist .set_text (title )
2659+ if kw :
2660+ artist .update (kw )
2661+ return artist
2662+
2663+ def _get_subset_title_bbox (
2664+ self , ax : paxes .Axes , renderer
2665+ ) -> mtransforms .Bbox | None :
2666+ """
2667+ Return the union bbox for shared titles covering the given axes.
2668+
2669+ Shared subset titles live above the subset's top edge, so they should
2670+ only contribute to the tight bounding boxes for axes that actually touch
2671+ that top boundary. Otherwise, multi-row subsets can incorrectly claim
2672+ the title as extra inter-row spacing.
2673+ """
2674+ ax = ax ._panel_parent or ax
2675+ bboxes = []
2676+ for group in self ._subset_title_dict .values ():
2677+ artist = group ["artist" ]
2678+ if not artist .get_visible () or not artist .get_text ():
2679+ continue
2680+ axs = [
2681+ group_ax ._panel_parent or group_ax
2682+ for group_ax in group ["axes" ]
2683+ if group_ax is not None
2684+ and group_ax .figure is self
2685+ and group_ax .get_visible ()
2686+ ]
2687+ if not axs or ax not in axs :
2688+ continue
2689+ top = min (group_ax ._range_subplotspec ("y" )[0 ] for group_ax in axs )
2690+ if ax ._range_subplotspec ("y" )[0 ] == top :
2691+ bboxes .append (artist .get_window_extent (renderer ))
2692+ return mtransforms .Bbox .union (bboxes ) if bboxes else None
2693+
2694+ def _align_subset_titles (self , renderer ):
2695+ """
2696+ Update the positions of titles spanning subplot subsets.
2697+ """
2698+ for key in list (self ._subset_title_dict ):
2699+ group = self ._subset_title_dict [key ]
2700+ artist = group ["artist" ]
2701+ axs = [
2702+ ax
2703+ for ax in group ["axes" ]
2704+ if ax is not None and ax .figure is self and ax .get_visible ()
2705+ ]
2706+ if not axs :
2707+ artist .remove ()
2708+ del self ._subset_title_dict [key ]
2709+ continue
2710+ if not artist .get_text ():
2711+ continue
2712+ align = artist .get_ha ()
2713+ x , _ = self ._get_align_coord (
2714+ "top" ,
2715+ axs ,
2716+ includepanels = self ._includepanels ,
2717+ align = align ,
2718+ )
2719+ top_labels = tuple (
2720+ lab
2721+ for ax , lab in self ._suplabel_dict ["top" ].items ()
2722+ if lab .get_text () and ax in axs
2723+ )
2724+ artist .set_x (x )
2725+ manual_y = group ["y" ]
2726+ if manual_y is not None :
2727+ artist .set_y (manual_y )
2728+ continue
2729+ pad = group ["pad" ]
2730+ if pad is not None :
2731+ pad = units (pad , "pt" ) / (72 * self .get_size_inches ()[1 ])
2732+ y_target = self ._get_offset_coord (
2733+ "top" ,
2734+ axs ,
2735+ renderer ,
2736+ pad = pad ,
2737+ extra = top_labels ,
2738+ include_subset_titles = False ,
2739+ )
2740+ artist .set_y (y_target )
2741+
25512742 def _update_axis_label (self , side , axs ):
25522743 """
25532744 Update the aligned axis label for the input axes.
@@ -2777,6 +2968,7 @@ def _align_content(): # noqa: E306
27772968 self ._align_axis_label (axis )
27782969 for side in ("left" , "right" , "top" , "bottom" ):
27792970 self ._align_super_labels (side , renderer )
2971+ self ._align_subset_titles (renderer )
27802972 self ._align_super_title (renderer )
27812973
27822974 # Update the layout
0 commit comments