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

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. 

5 

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. 

11 

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]`). 

14 

15The layoutgrid is then constrained to be contained within a parent layoutgrid, 

16its column(s) and row(s) specified when it is created. 

17""" 

18 

19import itertools 

20import kiwisolver as kiwi 

21import logging 

22import numpy as np 

23 

24import matplotlib as mpl 

25import matplotlib.patches as mpatches 

26from matplotlib.transforms import Bbox 

27 

28_log = logging.getLogger(__name__) 

29 

30 

31class LayoutGrid: 

32 """ 

33 Analogous to a gridspec, and contained in another LayoutGrid. 

34 """ 

35 

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) 

55 

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) 

69 

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) 

77 

78 sol = self.solver 

79 

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') 

93 

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) 

97 

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') 

108 

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() 

113 

114 self.h_pad = h_pad 

115 self.w_pad = w_pad 

116 

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 

131 

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) 

141 

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() 

150 

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') 

165 

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') 

175 

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 

179 

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) 

197 

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') 

220 

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) 

224 

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') 

242 

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') 

259 

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. 

266 

267 Parameters 

268 ---------- 

269 todo : string (one of 'left', 'right', 'bottom', 'top') 

270 margin to alter. 

271 

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. 

275 

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 

281 

282 def edit_margin_min(self, todo, size, cell=0): 

283 """ 

284 Change the minimum size of the margin for one cell. 

285 

286 Parameters 

287 ---------- 

288 todo : string (one of 'left', 'right', 'bottom', 'top') 

289 margin to alter. 

290 

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. 

295 

296 cell : int 

297 Cell column or row to edit. 

298 """ 

299 

300 if size > self.margin_vals[todo][cell]: 

301 self.edit_margin(todo, size, cell) 

302 

303 def edit_margins(self, todo, size): 

304 """ 

305 Change the size of all the margin of all the cells in the layout grid. 

306 

307 Parameters 

308 ---------- 

309 todo : string (one of 'left', 'right', 'bottom', 'top') 

310 margin to alter. 

311 

312 size : float 

313 Size to set the margins. Fraction of figure size. 

314 """ 

315 

316 for i in range(len(self.margin_vals[todo])): 

317 self.edit_margin(todo, size, i) 

318 

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. 

323 

324 Parameters 

325 ---------- 

326 todo : {'left', 'right', 'bottom', 'top'} 

327 The margin to alter. 

328 

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 """ 

334 

335 for i in range(len(self.margin_vals[todo])): 

336 self.edit_margin_min(todo, size, i) 

337 

338 def edit_outer_margin_mins(self, margin, ss): 

339 """ 

340 Edit all four margin minimums in one statement. 

341 

342 Parameters 

343 ---------- 

344 margin : dict 

345 size of margins in a dict with keys 'left', 'right', 'bottom', 

346 'top' 

347 

348 ss : SubplotSpec 

349 defines the subplotspec these margins should be applied to 

350 """ 

351 

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) 

362 

363 def get_margins(self, todo, col): 

364 """Return the margin at this position""" 

365 return self.margin_vals[todo][col] 

366 

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) 

374 

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 

381 

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) 

389 

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 

405 

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) 

413 

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 

425 

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) 

433 

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 

443 

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) 

451 

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 

462 

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) 

470 

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 

480 

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) 

488 

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 

498 

499 def update_variables(self): 

500 """ 

501 Update the variables for the solver attached to this layoutgrid. 

502 """ 

503 self.solver.updateVariables() 

504 

505_layoutboxobjnum = itertools.count() 

506 

507 

508def seq_id(): 

509 """Generate a short sequential id for layoutbox objects.""" 

510 return '%06d' % next(_layoutboxobjnum) 

511 

512 

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)) 

533 

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)