Coverage for /usr/lib/python3/dist-packages/matplotlib/_layoutgrid.py: 16%
216 statements
« prev ^ index » next coverage.py v7.4.4, created at 2025-06-14 15:25 +0200
« prev ^ index » next coverage.py v7.4.4, created at 2025-06-14 15:25 +0200
1"""
2A layoutgrid is a nrows by ncols set of boxes, meant to be used by
3`._constrained_layout`, each box is analogous to a subplotspec element of
4a gridspec.
6Each box is defined by left[ncols], right[ncols], bottom[nrows] and top[nrows],
7and by two editable margins for each side. The main margin gets its value
8set by the size of ticklabels, titles, etc on each axes that is in the figure.
9The outer margin is the padding around the axes, and space for any
10colorbars.
12The "inner" widths and heights of these boxes are then constrained to be the
13same (relative the values of `width_ratios[ncols]` and `height_ratios[nrows]`).
15The layoutgrid is then constrained to be contained within a parent layoutgrid,
16its column(s) and row(s) specified when it is created.
17"""
19import itertools
20import kiwisolver as kiwi
21import logging
22import numpy as np
24import matplotlib as mpl
25import matplotlib.patches as mpatches
26from matplotlib.transforms import Bbox
28_log = logging.getLogger(__name__)
31class LayoutGrid:
32 """
33 Analogous to a gridspec, and contained in another LayoutGrid.
34 """
36 def __init__(self, parent=None, parent_pos=(0, 0),
37 parent_inner=False, name='', ncols=1, nrows=1,
38 h_pad=None, w_pad=None, width_ratios=None,
39 height_ratios=None):
40 Variable = kiwi.Variable
41 self.parent = parent
42 self.parent_pos = parent_pos
43 self.parent_inner = parent_inner
44 self.name = name + seq_id()
45 if isinstance(parent, LayoutGrid):
46 self.name = f'{parent.name}.{self.name}'
47 self.nrows = nrows
48 self.ncols = ncols
49 self.height_ratios = np.atleast_1d(height_ratios)
50 if height_ratios is None:
51 self.height_ratios = np.ones(nrows)
52 self.width_ratios = np.atleast_1d(width_ratios)
53 if width_ratios is None:
54 self.width_ratios = np.ones(ncols)
56 sn = self.name + '_'
57 if not isinstance(parent, LayoutGrid):
58 # parent can be a rect if not a LayoutGrid
59 # allows specifying a rectangle to contain the layout.
60 self.parent = parent
61 self.solver = kiwi.Solver()
62 else:
63 self.parent = parent
64 parent.add_child(self, *parent_pos)
65 self.solver = self.parent.solver
66 # keep track of artist associated w/ this layout. Can be none
67 self.artists = np.empty((nrows, ncols), dtype=object)
68 self.children = np.empty((nrows, ncols), dtype=object)
70 self.margins = {}
71 self.margin_vals = {}
72 # all the boxes in each column share the same left/right margins:
73 for todo in ['left', 'right', 'leftcb', 'rightcb']:
74 # track the value so we can change only if a margin is larger
75 # than the current value
76 self.margin_vals[todo] = np.zeros(ncols)
78 sol = self.solver
80 # These are redundant, but make life easier if
81 # we define them all. All that is really
82 # needed is left/right, margin['left'], and margin['right']
83 self.widths = [Variable(f'{sn}widths[{i}]') for i in range(ncols)]
84 self.lefts = [Variable(f'{sn}lefts[{i}]') for i in range(ncols)]
85 self.rights = [Variable(f'{sn}rights[{i}]') for i in range(ncols)]
86 self.inner_widths = [Variable(f'{sn}inner_widths[{i}]')
87 for i in range(ncols)]
88 for todo in ['left', 'right', 'leftcb', 'rightcb']:
89 self.margins[todo] = [Variable(f'{sn}margins[{todo}][{i}]')
90 for i in range(ncols)]
91 for i in range(ncols):
92 sol.addEditVariable(self.margins[todo][i], 'strong')
94 for todo in ['bottom', 'top', 'bottomcb', 'topcb']:
95 self.margins[todo] = np.empty((nrows), dtype=object)
96 self.margin_vals[todo] = np.zeros(nrows)
98 self.heights = [Variable(f'{sn}heights[{i}]') for i in range(nrows)]
99 self.inner_heights = [Variable(f'{sn}inner_heights[{i}]')
100 for i in range(nrows)]
101 self.bottoms = [Variable(f'{sn}bottoms[{i}]') for i in range(nrows)]
102 self.tops = [Variable(f'{sn}tops[{i}]') for i in range(nrows)]
103 for todo in ['bottom', 'top', 'bottomcb', 'topcb']:
104 self.margins[todo] = [Variable(f'{sn}margins[{todo}][{i}]')
105 for i in range(nrows)]
106 for i in range(nrows):
107 sol.addEditVariable(self.margins[todo][i], 'strong')
109 # set these margins to zero by default. They will be edited as
110 # children are filled.
111 self.reset_margins()
112 self.add_constraints()
114 self.h_pad = h_pad
115 self.w_pad = w_pad
117 def __repr__(self):
118 str = f'LayoutBox: {self.name:25s} {self.nrows}x{self.ncols},\n'
119 for i in range(self.nrows):
120 for j in range(self.ncols):
121 str += f'{i}, {j}: '\
122 f'L({self.lefts[j].value():1.3f}, ' \
123 f'B{self.bottoms[i].value():1.3f}, ' \
124 f'W{self.widths[j].value():1.3f}, ' \
125 f'H{self.heights[i].value():1.3f}, ' \
126 f'innerW{self.inner_widths[j].value():1.3f}, ' \
127 f'innerH{self.inner_heights[i].value():1.3f}, ' \
128 f'ML{self.margins["left"][j].value():1.3f}, ' \
129 f'MR{self.margins["right"][j].value():1.3f}, \n'
130 return str
132 def reset_margins(self):
133 """
134 Reset all the margins to zero. Must do this after changing
135 figure size, for instance, because the relative size of the
136 axes labels etc changes.
137 """
138 for todo in ['left', 'right', 'bottom', 'top',
139 'leftcb', 'rightcb', 'bottomcb', 'topcb']:
140 self.edit_margins(todo, 0.0)
142 def add_constraints(self):
143 # define self-consistent constraints
144 self.hard_constraints()
145 # define relationship with parent layoutgrid:
146 self.parent_constraints()
147 # define relative widths of the grid cells to each other
148 # and stack horizontally and vertically.
149 self.grid_constraints()
151 def hard_constraints(self):
152 """
153 These are the redundant constraints, plus ones that make the
154 rest of the code easier.
155 """
156 for i in range(self.ncols):
157 hc = [self.rights[i] >= self.lefts[i],
158 (self.rights[i] - self.margins['right'][i] -
159 self.margins['rightcb'][i] >=
160 self.lefts[i] - self.margins['left'][i] -
161 self.margins['leftcb'][i])
162 ]
163 for c in hc:
164 self.solver.addConstraint(c | 'required')
166 for i in range(self.nrows):
167 hc = [self.tops[i] >= self.bottoms[i],
168 (self.tops[i] - self.margins['top'][i] -
169 self.margins['topcb'][i] >=
170 self.bottoms[i] - self.margins['bottom'][i] -
171 self.margins['bottomcb'][i])
172 ]
173 for c in hc:
174 self.solver.addConstraint(c | 'required')
176 def add_child(self, child, i=0, j=0):
177 # np.ix_ returns the cross product of i and j indices
178 self.children[np.ix_(np.atleast_1d(i), np.atleast_1d(j))] = child
180 def parent_constraints(self):
181 # constraints that are due to the parent...
182 # i.e. the first column's left is equal to the
183 # parent's left, the last column right equal to the
184 # parent's right...
185 parent = self.parent
186 if not isinstance(parent, LayoutGrid):
187 # specify a rectangle in figure coordinates
188 hc = [self.lefts[0] == parent[0],
189 self.rights[-1] == parent[0] + parent[2],
190 # top and bottom reversed order...
191 self.tops[0] == parent[1] + parent[3],
192 self.bottoms[-1] == parent[1]]
193 else:
194 rows, cols = self.parent_pos
195 rows = np.atleast_1d(rows)
196 cols = np.atleast_1d(cols)
198 left = parent.lefts[cols[0]]
199 right = parent.rights[cols[-1]]
200 top = parent.tops[rows[0]]
201 bottom = parent.bottoms[rows[-1]]
202 if self.parent_inner:
203 # the layout grid is contained inside the inner
204 # grid of the parent.
205 left += parent.margins['left'][cols[0]]
206 left += parent.margins['leftcb'][cols[0]]
207 right -= parent.margins['right'][cols[-1]]
208 right -= parent.margins['rightcb'][cols[-1]]
209 top -= parent.margins['top'][rows[0]]
210 top -= parent.margins['topcb'][rows[0]]
211 bottom += parent.margins['bottom'][rows[-1]]
212 bottom += parent.margins['bottomcb'][rows[-1]]
213 hc = [self.lefts[0] == left,
214 self.rights[-1] == right,
215 # from top to bottom
216 self.tops[0] == top,
217 self.bottoms[-1] == bottom]
218 for c in hc:
219 self.solver.addConstraint(c | 'required')
221 def grid_constraints(self):
222 # constrain the ratio of the inner part of the grids
223 # to be the same (relative to width_ratios)
225 # constrain widths:
226 w = (self.rights[0] - self.margins['right'][0] -
227 self.margins['rightcb'][0])
228 w = (w - self.lefts[0] - self.margins['left'][0] -
229 self.margins['leftcb'][0])
230 w0 = w / self.width_ratios[0]
231 # from left to right
232 for i in range(1, self.ncols):
233 w = (self.rights[i] - self.margins['right'][i] -
234 self.margins['rightcb'][i])
235 w = (w - self.lefts[i] - self.margins['left'][i] -
236 self.margins['leftcb'][i])
237 c = (w == w0 * self.width_ratios[i])
238 self.solver.addConstraint(c | 'strong')
239 # constrain the grid cells to be directly next to each other.
240 c = (self.rights[i - 1] == self.lefts[i])
241 self.solver.addConstraint(c | 'strong')
243 # constrain heights:
244 h = self.tops[0] - self.margins['top'][0] - self.margins['topcb'][0]
245 h = (h - self.bottoms[0] - self.margins['bottom'][0] -
246 self.margins['bottomcb'][0])
247 h0 = h / self.height_ratios[0]
248 # from top to bottom:
249 for i in range(1, self.nrows):
250 h = (self.tops[i] - self.margins['top'][i] -
251 self.margins['topcb'][i])
252 h = (h - self.bottoms[i] - self.margins['bottom'][i] -
253 self.margins['bottomcb'][i])
254 c = (h == h0 * self.height_ratios[i])
255 self.solver.addConstraint(c | 'strong')
256 # constrain the grid cells to be directly above each other.
257 c = (self.bottoms[i - 1] == self.tops[i])
258 self.solver.addConstraint(c | 'strong')
260 # Margin editing: The margins are variable and meant to
261 # contain things of a fixed size like axes labels, tick labels, titles
262 # etc
263 def edit_margin(self, todo, size, cell):
264 """
265 Change the size of the margin for one cell.
267 Parameters
268 ----------
269 todo : string (one of 'left', 'right', 'bottom', 'top')
270 margin to alter.
272 size : float
273 Size of the margin. If it is larger than the existing minimum it
274 updates the margin size. Fraction of figure size.
276 cell : int
277 Cell column or row to edit.
278 """
279 self.solver.suggestValue(self.margins[todo][cell], size)
280 self.margin_vals[todo][cell] = size
282 def edit_margin_min(self, todo, size, cell=0):
283 """
284 Change the minimum size of the margin for one cell.
286 Parameters
287 ----------
288 todo : string (one of 'left', 'right', 'bottom', 'top')
289 margin to alter.
291 size : float
292 Minimum size of the margin . If it is larger than the
293 existing minimum it updates the margin size. Fraction of
294 figure size.
296 cell : int
297 Cell column or row to edit.
298 """
300 if size > self.margin_vals[todo][cell]:
301 self.edit_margin(todo, size, cell)
303 def edit_margins(self, todo, size):
304 """
305 Change the size of all the margin of all the cells in the layout grid.
307 Parameters
308 ----------
309 todo : string (one of 'left', 'right', 'bottom', 'top')
310 margin to alter.
312 size : float
313 Size to set the margins. Fraction of figure size.
314 """
316 for i in range(len(self.margin_vals[todo])):
317 self.edit_margin(todo, size, i)
319 def edit_all_margins_min(self, todo, size):
320 """
321 Change the minimum size of all the margin of all
322 the cells in the layout grid.
324 Parameters
325 ----------
326 todo : {'left', 'right', 'bottom', 'top'}
327 The margin to alter.
329 size : float
330 Minimum size of the margin. If it is larger than the
331 existing minimum it updates the margin size. Fraction of
332 figure size.
333 """
335 for i in range(len(self.margin_vals[todo])):
336 self.edit_margin_min(todo, size, i)
338 def edit_outer_margin_mins(self, margin, ss):
339 """
340 Edit all four margin minimums in one statement.
342 Parameters
343 ----------
344 margin : dict
345 size of margins in a dict with keys 'left', 'right', 'bottom',
346 'top'
348 ss : SubplotSpec
349 defines the subplotspec these margins should be applied to
350 """
352 self.edit_margin_min('left', margin['left'], ss.colspan.start)
353 self.edit_margin_min('leftcb', margin['leftcb'], ss.colspan.start)
354 self.edit_margin_min('right', margin['right'], ss.colspan.stop - 1)
355 self.edit_margin_min('rightcb', margin['rightcb'], ss.colspan.stop - 1)
356 # rows are from the top down:
357 self.edit_margin_min('top', margin['top'], ss.rowspan.start)
358 self.edit_margin_min('topcb', margin['topcb'], ss.rowspan.start)
359 self.edit_margin_min('bottom', margin['bottom'], ss.rowspan.stop - 1)
360 self.edit_margin_min('bottomcb', margin['bottomcb'],
361 ss.rowspan.stop - 1)
363 def get_margins(self, todo, col):
364 """Return the margin at this position"""
365 return self.margin_vals[todo][col]
367 def get_outer_bbox(self, rows=0, cols=0):
368 """
369 Return the outer bounding box of the subplot specs
370 given by rows and cols. rows and cols can be spans.
371 """
372 rows = np.atleast_1d(rows)
373 cols = np.atleast_1d(cols)
375 bbox = Bbox.from_extents(
376 self.lefts[cols[0]].value(),
377 self.bottoms[rows[-1]].value(),
378 self.rights[cols[-1]].value(),
379 self.tops[rows[0]].value())
380 return bbox
382 def get_inner_bbox(self, rows=0, cols=0):
383 """
384 Return the inner bounding box of the subplot specs
385 given by rows and cols. rows and cols can be spans.
386 """
387 rows = np.atleast_1d(rows)
388 cols = np.atleast_1d(cols)
390 bbox = Bbox.from_extents(
391 (self.lefts[cols[0]].value() +
392 self.margins['left'][cols[0]].value() +
393 self.margins['leftcb'][cols[0]].value()),
394 (self.bottoms[rows[-1]].value() +
395 self.margins['bottom'][rows[-1]].value() +
396 self.margins['bottomcb'][rows[-1]].value()),
397 (self.rights[cols[-1]].value() -
398 self.margins['right'][cols[-1]].value() -
399 self.margins['rightcb'][cols[-1]].value()),
400 (self.tops[rows[0]].value() -
401 self.margins['top'][rows[0]].value() -
402 self.margins['topcb'][rows[0]].value())
403 )
404 return bbox
406 def get_bbox_for_cb(self, rows=0, cols=0):
407 """
408 Return the bounding box that includes the
409 decorations but, *not* the colorbar...
410 """
411 rows = np.atleast_1d(rows)
412 cols = np.atleast_1d(cols)
414 bbox = Bbox.from_extents(
415 (self.lefts[cols[0]].value() +
416 self.margins['leftcb'][cols[0]].value()),
417 (self.bottoms[rows[-1]].value() +
418 self.margins['bottomcb'][rows[-1]].value()),
419 (self.rights[cols[-1]].value() -
420 self.margins['rightcb'][cols[-1]].value()),
421 (self.tops[rows[0]].value() -
422 self.margins['topcb'][rows[0]].value())
423 )
424 return bbox
426 def get_left_margin_bbox(self, rows=0, cols=0):
427 """
428 Return the left margin bounding box of the subplot specs
429 given by rows and cols. rows and cols can be spans.
430 """
431 rows = np.atleast_1d(rows)
432 cols = np.atleast_1d(cols)
434 bbox = Bbox.from_extents(
435 (self.lefts[cols[0]].value() +
436 self.margins['leftcb'][cols[0]].value()),
437 (self.bottoms[rows[-1]].value()),
438 (self.lefts[cols[0]].value() +
439 self.margins['leftcb'][cols[0]].value() +
440 self.margins['left'][cols[0]].value()),
441 (self.tops[rows[0]].value()))
442 return bbox
444 def get_bottom_margin_bbox(self, rows=0, cols=0):
445 """
446 Return the left margin bounding box of the subplot specs
447 given by rows and cols. rows and cols can be spans.
448 """
449 rows = np.atleast_1d(rows)
450 cols = np.atleast_1d(cols)
452 bbox = Bbox.from_extents(
453 (self.lefts[cols[0]].value()),
454 (self.bottoms[rows[-1]].value() +
455 self.margins['bottomcb'][rows[-1]].value()),
456 (self.rights[cols[-1]].value()),
457 (self.bottoms[rows[-1]].value() +
458 self.margins['bottom'][rows[-1]].value() +
459 self.margins['bottomcb'][rows[-1]].value()
460 ))
461 return bbox
463 def get_right_margin_bbox(self, rows=0, cols=0):
464 """
465 Return the left margin bounding box of the subplot specs
466 given by rows and cols. rows and cols can be spans.
467 """
468 rows = np.atleast_1d(rows)
469 cols = np.atleast_1d(cols)
471 bbox = Bbox.from_extents(
472 (self.rights[cols[-1]].value() -
473 self.margins['right'][cols[-1]].value() -
474 self.margins['rightcb'][cols[-1]].value()),
475 (self.bottoms[rows[-1]].value()),
476 (self.rights[cols[-1]].value() -
477 self.margins['rightcb'][cols[-1]].value()),
478 (self.tops[rows[0]].value()))
479 return bbox
481 def get_top_margin_bbox(self, rows=0, cols=0):
482 """
483 Return the left margin bounding box of the subplot specs
484 given by rows and cols. rows and cols can be spans.
485 """
486 rows = np.atleast_1d(rows)
487 cols = np.atleast_1d(cols)
489 bbox = Bbox.from_extents(
490 (self.lefts[cols[0]].value()),
491 (self.tops[rows[0]].value() -
492 self.margins['topcb'][rows[0]].value()),
493 (self.rights[cols[-1]].value()),
494 (self.tops[rows[0]].value() -
495 self.margins['topcb'][rows[0]].value() -
496 self.margins['top'][rows[0]].value()))
497 return bbox
499 def update_variables(self):
500 """
501 Update the variables for the solver attached to this layoutgrid.
502 """
503 self.solver.updateVariables()
505_layoutboxobjnum = itertools.count()
508def seq_id():
509 """Generate a short sequential id for layoutbox objects."""
510 return '%06d' % next(_layoutboxobjnum)
513def plot_children(fig, lg=None, level=0):
514 """Simple plotting to show where boxes are."""
515 if lg is None:
516 _layoutgrids = fig.get_layout_engine().execute(fig)
517 lg = _layoutgrids[fig]
518 colors = mpl.rcParams["axes.prop_cycle"].by_key()["color"]
519 col = colors[level]
520 for i in range(lg.nrows):
521 for j in range(lg.ncols):
522 bb = lg.get_outer_bbox(rows=i, cols=j)
523 fig.add_artist(
524 mpatches.Rectangle(bb.p0, bb.width, bb.height, linewidth=1,
525 edgecolor='0.7', facecolor='0.7',
526 alpha=0.2, transform=fig.transFigure,
527 zorder=-3))
528 bbi = lg.get_inner_bbox(rows=i, cols=j)
529 fig.add_artist(
530 mpatches.Rectangle(bbi.p0, bbi.width, bbi.height, linewidth=2,
531 edgecolor=col, facecolor='none',
532 transform=fig.transFigure, zorder=-2))
534 bbi = lg.get_left_margin_bbox(rows=i, cols=j)
535 fig.add_artist(
536 mpatches.Rectangle(bbi.p0, bbi.width, bbi.height, linewidth=0,
537 edgecolor='none', alpha=0.2,
538 facecolor=[0.5, 0.7, 0.5],
539 transform=fig.transFigure, zorder=-2))
540 bbi = lg.get_right_margin_bbox(rows=i, cols=j)
541 fig.add_artist(
542 mpatches.Rectangle(bbi.p0, bbi.width, bbi.height, linewidth=0,
543 edgecolor='none', alpha=0.2,
544 facecolor=[0.7, 0.5, 0.5],
545 transform=fig.transFigure, zorder=-2))
546 bbi = lg.get_bottom_margin_bbox(rows=i, cols=j)
547 fig.add_artist(
548 mpatches.Rectangle(bbi.p0, bbi.width, bbi.height, linewidth=0,
549 edgecolor='none', alpha=0.2,
550 facecolor=[0.5, 0.5, 0.7],
551 transform=fig.transFigure, zorder=-2))
552 bbi = lg.get_top_margin_bbox(rows=i, cols=j)
553 fig.add_artist(
554 mpatches.Rectangle(bbi.p0, bbi.width, bbi.height, linewidth=0,
555 edgecolor='none', alpha=0.2,
556 facecolor=[0.7, 0.2, 0.7],
557 transform=fig.transFigure, zorder=-2))
558 for ch in lg.children.flat:
559 if ch is not None:
560 plot_children(fig, ch, level=level+1)