Coverage for moptipy / utils / plot_utils.py: 75%

330 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-24 08:49 +0000

1"""Utilities for creating and storing figures.""" 

2import os.path 

3import statistics as st 

4import warnings 

5from math import isfinite, sqrt 

6from typing import Any, Callable, Final, Iterable, Sequence, cast 

7 

8import matplotlib.pyplot as plt # type: ignore 

9from matplotlib import rcParams # type: ignore 

10from matplotlib.artist import Artist # type: ignore 

11from matplotlib.axes import Axes # type: ignore 

12from matplotlib.backend_bases import RendererBase # type: ignore 

13from matplotlib.backends.backend_agg import RendererAgg # type: ignore 

14from matplotlib.figure import Figure, SubFigure # type: ignore 

15from pycommons.io.path import Path, directory_path 

16from pycommons.types import check_int_range, type_error, type_name_of 

17 

18import moptipy.utils.plot_defaults as pd 

19from moptipy.utils.lang import Lang 

20 

21# Ensure that matplotlib uses Type 1 fonts. 

22# Some scientific conferences, such as GECCO organized by ACM, require this. 

23# In the language utilities :meth:`~moptipy.utils.lang.Lang.font`, we do 

24# return acceptable fonts anyway, but it may be better to set this here 

25# explicitly to avoid any problem. 

26rcParams["pdf.fonttype"] = 42 

27rcParams["ps.fonttype"] = 42 

28 

29#: The golden ratio constant with value 1.618033988749895. 

30__GOLDEN_RATIO: Final[float] = 0.5 + (0.5 * sqrt(5)) 

31 

32 

33def create_figure(width: float | int | None = 8.6, 

34 height: float | int | None = None, 

35 dpi: float | int | None = 384.0, 

36 **kwargs) -> Figure: 

37 """ 

38 Create a matplotlib figure. 

39 

40 :param width: the optional width 

41 :param height: the optional height 

42 :param dpi: the dpi value 

43 :param kwargs: a set of optional arguments 

44 :return: the figure option 

45 """ 

46 # Check the size, i.e., width and height and use 

47 put_size: bool = True 

48 if width is None: 

49 if height is None: 

50 put_size = False 

51 else: 

52 width = __GOLDEN_RATIO * height 

53 elif height is None: 

54 height = width / __GOLDEN_RATIO 

55 

56 if put_size: 

57 if isinstance(height, int): 

58 height = float(height) 

59 if not isinstance(height, float): 

60 raise ValueError(f"Invalid height type {type(height)}.") 

61 if not (isfinite(height) and (0.1 < height < 10000.0)): 

62 raise ValueError(f"Invalid height {height}.") 

63 nheight = int(0.5 + (height * 72)) / 72.0 

64 if not (isfinite(nheight) and (0.1 < nheight < 10000.0)): 

65 raise ValueError(f"Invalid height {height} as it maps to " 

66 f"{nheight} after to-point-and-back conversion.") 

67 

68 if isinstance(width, int): 

69 width = float(width) 

70 if not isinstance(width, float): 

71 raise ValueError(f"Invalid width type {type(width)}.") 

72 if not (isfinite(width) and (0.1 < width < 10000.0)): 

73 raise ValueError(f"Invalid width {width}.") 

74 nwidth = int(0.5 + (width * 72)) / 72.0 

75 if not (isfinite(nwidth) and (0.1 < nwidth < 10000.0)): 

76 raise ValueError(f"Invalid width {width} as it maps to " 

77 f"{nwidth} after to-point-and-back conversion.") 

78 

79 kwargs["figsize"] = width, height 

80 

81 if dpi is not None: 

82 if isinstance(dpi, int): 

83 dpi = float(dpi) 

84 if not isinstance(dpi, float): 

85 raise ValueError(f"Invalid dpi type {type(dpi)}.") 

86 if not (isfinite(dpi) and (1.0 < dpi < 10000.0)): 

87 raise ValueError(f"Invalid dpi value {dpi}.") 

88 kwargs["dpi"] = dpi 

89 

90 if "frameon" not in kwargs: 

91 kwargs["frameon"] = False 

92 

93 if "constrained_layout" not in kwargs: 

94 kwargs["constrained_layout"] = False 

95 

96 return Figure(**kwargs) 

97 

98 

99def __divide_evenly(items: int, chunks: int, reverse: bool) -> list[int]: 

100 """ 

101 Divide `n` items into `k` chunks, trying to create equally-sized chunks. 

102 

103 :param items: the number of items to divide 

104 :param chunks: the number of chunks 

105 :param reverse: should we put the additional items at the end (True) 

106 or front (False) 

107 :returns: the list of items 

108 

109 >>> print(__divide_evenly(9, 3, reverse=True)) 

110 [3, 3, 3] 

111 >>> print(__divide_evenly(10, 3, reverse=True)) 

112 [3, 3, 4] 

113 >>> print(__divide_evenly(10, 3, reverse=False)) 

114 [4, 3, 3] 

115 >>> print(__divide_evenly(11, 3, reverse=True)) 

116 [3, 4, 4] 

117 >>> print(__divide_evenly(11, 3, reverse=False)) 

118 [4, 4, 3] 

119 >>> print(__divide_evenly(12, 3, reverse=False)) 

120 [4, 4, 4] 

121 """ 

122 # validate that we do not have more chunks than items or otherwise invalid 

123 # parameters 

124 if (items <= 0) or (chunks <= 0) or (chunks > items): 

125 raise ValueError(f"cannot divide {items} items into {chunks} chunks.") 

126 

127 # First we compute the minimum number of items per chunk. 

128 # Our basic solution is to put exactly this many items into each chunk. 

129 # For example, if items=10 and chunks=3, this will fill 3 items into each 

130 # chunk. 

131 result: list[int] = [items // chunks] * chunks 

132 # We then fill the remaining items into the chunks at the front 

133 # (reverse=False) or end (reverse=True) of the list. 

134 # We do this by putting exactly one such item into each chunk, starting 

135 # from the end. 

136 # Since items modulo chunks must be in [0..chunks), this is possible. 

137 # This setup will yield the lowest possible standard deviation of items 

138 # per chunk. 

139 # In case of items=10 and chunks=3, items % chunks = 1 and the chunks will 

140 # become [3, 3, 4]. If items=3, no items need to be distributed and we get 

141 # [3, 3, 3]. 

142 if reverse: 

143 for i in range(1, (items % chunks) + 1): 

144 result[-i] += 1 

145 else: 

146 for i in range(items % chunks): 

147 result[i] += 1 

148 return result 

149 

150 

151def create_figure_with_subplots( 

152 items: int, 

153 max_items_per_plot: int = 3, 

154 max_rows: int = 3, 

155 max_cols: int = 1, 

156 min_rows: int = 1, 

157 min_cols: int = 1, 

158 default_width_per_col: float | int | None = 8.6, 

159 max_width: float | int | None = 8.6, 

160 default_height_per_row: float | int | None = 8.6 / __GOLDEN_RATIO, 

161 max_height: float | int | None = 9, 

162 dpi: float | int | None = 384.0, 

163 plot_config: dict[str, Any] | None = None, 

164 **kwargs) \ 

165 -> tuple[Figure, tuple[tuple[Axes | Figure, 

166 int, int, int, int, int], ...]]: 

167 """ 

168 Divide a figure into nrows*ncols sub-plots. 

169 

170 :param items: the number of items to divide 

171 :param max_items_per_plot: the maximum number of items per plot 

172 :param max_rows: the maximum number of rows 

173 :param max_cols: the maximum number of columns 

174 :param min_rows: the minimum number of rows 

175 :param min_cols: the minimum number of cols 

176 :param default_width_per_col: the optional default width of a column 

177 :param default_height_per_row: the optional default height per row 

178 :param max_height: the maximum height 

179 :param max_width: the maximum width 

180 :param dpi: the dpi value 

181 :param kwargs: a set of optional arguments 

182 :param plot_config: the configuration to be applied to all sub-plots 

183 :returns: a tuple with the figure, followed by a series of tuples with 

184 each sub-figure, the index of the first item assigned to it, the 

185 index of the last item assigned to it + 1, their row, their column, 

186 and their overall index 

187 """ 

188 # First, we do a lot of sanity checks 

189 check_int_range(items, "items", 1, 1_000_000) 

190 check_int_range(max_items_per_plot, "max_items_per_plot", 1, 1_000_000) 

191 check_int_range(max_rows, "max_rows", 1, 100) 

192 check_int_range(min_rows, "min_rows", 1, max_rows) 

193 check_int_range(max_cols, "max_cols", 1, 100) 

194 check_int_range(min_cols, "min_cols", 1, max_cols) 

195 if (max_cols * max_rows * max_items_per_plot) < items: 

196 raise ValueError( 

197 f"Cannot distribute {items} items into at most {max_rows} rows " 

198 f"and {max_cols} cols with at most {max_items_per_plot} per " 

199 "plot.") 

200 if default_width_per_col is not None: 

201 default_width_per_col = float(default_width_per_col) 

202 if (not isfinite(default_width_per_col)) \ 

203 or (default_width_per_col <= 0.1) \ 

204 or (default_width_per_col >= 1000): 

205 raise ValueError( 

206 f"invalid default_width_per_col {default_width_per_col}") 

207 if max_width is not None: 

208 max_width = float(max_width) 

209 if (not isfinite(max_width)) \ 

210 or (max_width <= 0.1) or (max_width >= 10000): 

211 raise ValueError(f"invalid max_width {max_width}") 

212 if (default_width_per_col is not None) \ 

213 and (default_width_per_col > max_width): 

214 raise ValueError(f"default_width_per_col {default_width_per_col} " 

215 f"> max_width {max_width}") 

216 if default_height_per_row is not None: 

217 default_height_per_row = float(default_height_per_row) 

218 if (not isfinite(default_height_per_row)) \ 

219 or (default_height_per_row <= 0.1) \ 

220 or (default_height_per_row >= 10000): 

221 raise ValueError( 

222 f"invalid default_height_per_row {default_height_per_row}") 

223 if max_height is not None: 

224 max_height = float(max_height) 

225 if (not isfinite(max_height)) \ 

226 or (max_height <= 0.1) or (max_height >= 10000): 

227 raise ValueError(f"invalid max_width {max_height}") 

228 if (default_height_per_row is not None) \ 

229 and (max_height < default_height_per_row): 

230 raise ValueError( 

231 f"max_height {max_height} < " 

232 f"default_height_per_row {default_height_per_row}") 

233 

234 # setting up the default dimensions 

235 if default_height_per_row is None: 

236 default_height_per_row = 1.95 

237 if max_height is not None: 

238 default_height_per_row = min(default_height_per_row, max_height) 

239 if max_height is None: 

240 max_height = max_rows * default_height_per_row 

241 if default_width_per_col is None: 

242 default_width_per_col = 8.6 

243 if max_width is not None: 

244 default_width_per_col = min(default_width_per_col, max_width) 

245 if max_width is None: 

246 max_width = default_width_per_col 

247 if (plot_config is not None) and not isinstance(plot_config, dict): 

248 raise type_error(plot_config, "plot_config", (dict, None)) 

249 

250 # How many plots do we need at least? Well, if we can put 

251 # max_items_per_plot items into each plot and have items many items, then 

252 # we need ceil(items / max_items_per_plot) many. This is what we compute 

253 # here. 

254 min_plots: Final[int] = (items // max_items_per_plot) \ 

255 + min(1, items % max_items_per_plot) 

256 # The maximum conceivable number of plots would be items, so this we do 

257 # not need to compute. 

258 plots_i: Final[int] = 6 

259 rows_i: Final[int] = plots_i + 1 

260 cols_i: Final[int] = rows_i + 1 

261 height_i: Final[int] = cols_i + 1 

262 width_i: Final[int] = height_i + 1 

263 plots_per_row_i: Final[int] = width_i + 1 

264 chunks_i: Final[int] = plots_per_row_i + 1 

265 best: tuple[float, # the plots-per-row std 

266 float, # the items-per-plot std 

267 float, # the overall area of the figure 

268 float, # the std deviation of (w, h*golden_ratio), 

269 float, # -plot area 

270 float, # the std deviation of (w, h*golden_ratio) per plot 

271 int, # the number of plots 

272 int, # the number of rows 

273 int, # the number of cols 

274 float, # the figure height 

275 float, # the figure width 

276 list[int], # the plots per row 

277 list[int]] = \ 

278 (1000000.0, 1000000.0, 1000000.0, 1000000.0, 1000000.0, 1000000.0, 

279 1 << 62, 1 << 62, 1 << 62, 1000000.0, 1000000.0, [0], [0]) 

280 

281 # Now we simply try all valid combinations. 

282 for rows in range(min_rows, max_rows + 1): 

283 if rows > items: 

284 continue # more rows than items 

285 for cols in range(min_cols, max_cols + 1): 

286 # We need cols plots per row, except for the last row, where we 

287 # could only put a single plot if we wanted. 

288 min_plots_for_config: int = 1 + ((rows - 1) * cols) 

289 if min_plots_for_config > items: 

290 continue # if this exceeds the maximum number of plots, skip 

291 # compute the maximum plots we can get in this config 

292 max_plots_per_config: int = rows * cols 

293 if max_plots_per_config < min_plots: 

294 continue # not enough plots possible 

295 

296 # now we can iterate over the number of plots we can provide 

297 for plots in range(max(min_plots_for_config, min_plots), 

298 min(max_plots_per_config, items) + 1): 

299 # We compute the figure width. If we have multiple columns, 

300 # we try to add 2% plus 0.02" as breathing space per column. 

301 fig_width = min(max_width, ((1.02 * default_width_per_col) 

302 + 0.02) * cols) 

303 # The width of a single plot is then computed as follows: 

304 plot_width = fig_width / cols 

305 if plot_width < 0.9: 

306 continue # the single plots would be too small 

307 # We compute the overall figure height. Again we add breathing 

308 # space if we have multiple rows. 

309 fig_height = min(max_height, ((1.02 * default_height_per_row) 

310 + 0.02) * rows) 

311 plot_height = fig_height / rows 

312 if plot_height < 0.9: 

313 continue # the single plots would be too small 

314 # So dimension-wise, the plot sizes are OK. 

315 # How about the distribution of items? We put the rows with 

316 # plots at the top and the plots with more items at the end. 

317 plot_distr = __divide_evenly(plots, rows, reverse=False) 

318 item_distr = __divide_evenly(items, plots, reverse=True) 

319 

320 current: tuple[float, float, float, float, float, float, 

321 int, int, int, float, float, 

322 list[int], list[int]] = ( 

323 st.stdev(plot_distr) if rows > 1 else 0, 

324 st.stdev(item_distr) if plots > 1 else 0, 

325 fig_height * fig_width, # the area of the figure 

326 st.stdev([fig_width, fig_height * __GOLDEN_RATIO]), 

327 -plot_width * plot_height, # -plot area 

328 st.stdev([plot_width, plot_height * __GOLDEN_RATIO]), 

329 plots, # the number of plots 

330 rows, # the number of rows 

331 cols, # the number of cols 

332 fig_height, # the figure height 

333 fig_width, # the figure width 

334 plot_distr, # the plots per row 

335 item_distr) # the data chunks per plot 

336 if ((current[plots_i] + 1) # type: ignore 

337 < best[plots_i]) or (( # type: ignore 

338 current[plots_i] # type: ignore 

339 <= (best[plots_i] + 1)) # type: ignore 

340 and (current < best)): # type: ignore 

341 best = current 

342 

343 n_plots: Final[int] = best[plots_i] # type: ignore 

344 if n_plots > items: 

345 raise ValueError( 

346 f"Could not place {items} in {min_cols}..{max_cols} columns " 

347 f"at {min_rows}..{max_rows} rows with at most " 

348 f"{max_items_per_plot} items per plot for a max_width={max_width}" 

349 f" and max_height={max_height}.") 

350 

351 # create the figure of the computed dimensions 

352 figure: Final[Figure] = create_figure( 

353 width=best[width_i], height=best[height_i], # type: ignore 

354 dpi=dpi, **kwargs) 

355 if (n_plots <= 1) and (plot_config is None): 

356 # if there is only one plot, we are done here 

357 return figure, ((figure, 0, items, 0, 0, 0), ) 

358 

359 # if there are multiple plots, we need to generate them 

360 allfigs: list[tuple[Axes | Figure, 

361 int, int, int, int, int]] = [] 

362 index: int = 0 

363 chunk_start: int = 0 

364 nrows: Final[int] = best[rows_i] # type: ignore 

365 ncols: Final[int] = best[cols_i] # type: ignore 

366 chunks: Final[list[int]] = best[chunks_i] # type: ignore 

367 plots_per_row: Final[list[int]] = best[plots_per_row_i] # type: ignore 

368 for i in range(nrows): 

369 for j in range(plots_per_row[i]): 

370 chunk_next = chunk_start + chunks[index] 

371 if plot_config is None: 

372 sp = figure.add_subplot(nrows, ncols, (i * ncols) + j + 1) 

373 else: 

374 sp = figure.add_subplot(nrows, ncols, (i * ncols) + j + 1, 

375 **plot_config) 

376 allfigs.append((sp, chunk_start, chunk_next, i, j, index)) 

377 chunk_start = chunk_next 

378 index += 1 

379 return figure, tuple(allfigs) 

380 

381 

382def save_figure(fig: Figure, 

383 file_name: str = "figure", 

384 dir_name: str = ".", 

385 formats: str | Iterable[str] = "svg") -> list[Path]: 

386 """ 

387 Store the given figure in files of the given formats and dispose it. 

388 

389 :param fig: the figure to save 

390 :param file_name: the basic file name 

391 :param dir_name: the directory name 

392 :param formats: the file format(s) 

393 :return: a list of files 

394 """ 

395 if not isinstance(fig, Figure): 

396 raise type_error(fig, "figure", Figure) 

397 if not isinstance(file_name, str): 

398 raise type_error(file_name, "file_name", str) 

399 if len(file_name) <= 0: 

400 raise ValueError(f"Invalid filename {file_name!r}.") 

401 if not isinstance(dir_name, str): 

402 raise type_error(dir_name, "dir_name", str) 

403 if len(dir_name) <= 0: 

404 raise ValueError(f"Invalid dirname {dir_name!r}.") 

405 if isinstance(formats, str): 

406 formats = [formats] 

407 if not isinstance(formats, Iterable): 

408 raise type_error(formats, "formats", Iterable) 

409 

410 size = fig.get_size_inches() 

411 orientation: Final[str] = \ 

412 "landscape" if size[0] >= size[1] else "portrait" 

413 

414 # set minimal margins to the axes to avoid wasting space 

415 for ax in fig.axes: # consider the dimension of the axes 

416 margins: list[int] = [0] * len(ax.margins()) 

417 ax.margins(*margins) 

418 

419 use_dir = directory_path(dir_name) 

420 files = [] 

421 for fmt in formats: 

422 if not isinstance(fmt, str): 

423 raise type_error(fmt, "element of formats", str) 

424 dest_file = Path(os.path.join(use_dir, f"{file_name}.{fmt}")) 

425 dest_file.ensure_file_exists() 

426 with warnings.catch_warnings(): 

427 warnings.simplefilter("ignore") 

428 # UserWarning: There are no gridspecs with layoutgrids. 

429 # Possibly did not call parent GridSpec with the "figure" keyword 

430 fig.savefig(dest_file, transparent=True, format=fmt, 

431 orientation=orientation, 

432 dpi="figure", 

433 bbox_inches="tight", 

434 pad_inches=1.0 / 72.0) 

435 dest_file.enforce_file() 

436 files.append(dest_file) 

437 

438 fig.clf(False) 

439 plt.close(fig) 

440 del fig 

441 

442 if len(files) <= 0: 

443 raise ValueError("No formats were specified.") 

444 

445 return files 

446 

447 

448def label_box(axes: Axes, 

449 text: str, 

450 x: float | None = None, 

451 y: float | None = None, 

452 font_size: float = pd.importance_to_font_size(0), 

453 may_rotate_text: bool = False, 

454 z_order: float | None = None, 

455 font: str | Callable | None = 

456 lambda: Lang.current().font()) -> None: 

457 """ 

458 Put a label text box near an axis. 

459 

460 :param axes: the axes to add the label to 

461 :param text: the text to place 

462 :param x: the location along the x-axis: `0` means left, 

463 `0.5` means centered, `1` means right 

464 :param y: the location along the x-axis: `0` means bottom, 

465 `0.5` means centered, `1` means top 

466 :param font_size: the font size 

467 :param may_rotate_text: should we rotate the text by 90° if that 

468 makes sense (`True`) or always keep it horizontally (`False`) 

469 :param z_order: an optional z-order value 

470 :param font: the font to use 

471 """ 

472 if x is None: 

473 if y is None: 

474 raise ValueError("At least one of x or y must not be None.") 

475 x = 0 

476 elif y is None: 

477 y = 0 

478 

479 spacing: Final[float] = max(4.0, font_size / 2.0) 

480 xtext: float = 0.0 

481 ytext: float = 0.0 

482 xalign: str = "center" 

483 yalign: str = "center" 

484 if x >= 0.85: 

485 xtext = -spacing 

486 xalign = "right" 

487 elif x <= 0.15: 

488 xtext = spacing 

489 xalign = "left" 

490 if y >= 0.85: 

491 ytext = -spacing 

492 yalign = "top" 

493 elif y <= 0.15: 

494 ytext = spacing 

495 yalign = "bottom" 

496 

497 args = {"text": text, 

498 "xy": (x, y), 

499 "xytext": (xtext, ytext), 

500 "verticalalignment": yalign, 

501 "horizontalalignment": xalign, 

502 "xycoords": "axes fraction", 

503 "textcoords": "offset points", 

504 "fontsize": font_size, 

505 "bbox": {"boxstyle": "round", 

506 "color": "white", 

507 "fill": True, 

508 "linewidth": 0, 

509 "alpha": 0.9}} 

510 if z_order is not None: 

511 args["zorder"] = z_order 

512 

513 if may_rotate_text and (len(text) > 2): 

514 args["rotation"] = 90 

515 

516 if callable(font): 

517 font = font() 

518 if font is not None: 

519 if not isinstance(font, str): 

520 raise type_error(font, "font", str) 

521 args["fontname"] = font 

522 

523 axes.annotate(**args) # type: ignore 

524 

525 

526def label_axes(axes: Axes, 

527 x_label: str | None = None, 

528 x_label_inside: bool = True, 

529 x_label_location: float = 0.5, 

530 y_label: str | None = None, 

531 y_label_inside: bool = True, 

532 y_label_location: float = 1, 

533 font_size: float = pd.importance_to_font_size(0), 

534 z_order: float | None = None) -> None: 

535 """ 

536 Put labels on a figure. 

537 

538 :param axes: the axes to add the label to 

539 :param x_label: a callable returning the label for 

540 the x-axis, a label string, or `None` if no label should be put 

541 :param x_label_inside: put the x-axis label inside the plot (so that 

542 it does not consume additional vertical space) 

543 :param x_label_location: the location of the x-axis label if it is 

544 placed inside the plot area 

545 :param y_label: a callable returning the label for 

546 the y-axis, a label string, or `None` if no label should be put 

547 :param y_label_inside: put the xyaxis label inside the plot (so that 

548 it does not consume additional horizontal space)nal vertical space) 

549 :param y_label_location: the location of the y-axis label if it is 

550 placed inside the plot area 

551 :param font_size: the font size to use 

552 :param z_order: an optional z-order value 

553 """ 

554 # put the label on the x-axis, if any 

555 if x_label is not None: 

556 if not isinstance(x_label, str): 

557 raise type_error(x_label, "x_label", str) 

558 if len(x_label) > 0: 

559 if x_label_inside: 

560 label_box(axes, text=x_label, x=x_label_location, y=0, 

561 font_size=font_size, z_order=z_order) 

562 else: 

563 axes.set_xlabel(x_label, fontsize=font_size) 

564 

565 # put the label on the y-axis, if any 

566 if y_label is not None: 

567 if not isinstance(y_label, str): 

568 raise type_error(y_label, "y_label", str) 

569 if len(y_label) > 0: 

570 if y_label_inside: 

571 label_box(axes, text=y_label, x=0, y=y_label_location, 

572 font_size=font_size, may_rotate_text=True, 

573 z_order=z_order) 

574 else: 

575 axes.set_ylabel(y_label, fontsize=font_size) 

576 

577 

578def get_axes(figure: Axes | Figure) -> Axes: 

579 """ 

580 Obtain the axes from a figure or axes object. 

581 

582 :param figure: the figure 

583 :return: the Axes 

584 """ 

585 if isinstance(figure, Figure): 

586 return figure.add_axes((0.005, 0.005, 0.99, 0.99)) 

587 if hasattr(figure, "axes") \ 

588 or isinstance(getattr(type(figure), "axes", None), property): 

589 try: 

590 if isinstance(figure.axes, Axes): 

591 return cast("Axes", figure.axes) 

592 if isinstance(figure.axes, Sequence): 

593 ax = figure.axes[0] 

594 if isinstance(ax, Axes): 

595 return cast("Axes", ax) 

596 elif isinstance(figure.axes, Iterable): 

597 for k in figure.axes: 

598 if isinstance(k, Axes): 

599 return cast("Axes", k) 

600 break 

601 except TypeError: 

602 pass 

603 except IndexError: 

604 pass 

605 if isinstance(figure, Axes): 

606 return cast("Axes", figure) 

607 raise TypeError( 

608 f"Cannot get Axes of object of type {type_name_of(figure)}.") 

609 

610 

611def get_renderer(figure: Axes | Figure | SubFigure) -> RendererBase: 

612 """ 

613 Get a renderer that can be used for determining figure element sizes. 

614 

615 :param figure: the figure element 

616 :return: the renderer 

617 """ 

618 if isinstance(figure, Axes): 

619 figure = figure.figure 

620 if not isinstance(figure, Figure): 

621 raise type_error(figure, "figure", Figure) 

622 canvas = figure.canvas 

623 if hasattr(canvas, "renderer"): 

624 return canvas.renderer 

625 if hasattr(canvas, "get_renderer"): 

626 return canvas.get_renderer() 

627 return RendererAgg(width=figure.get_figwidth(), 

628 height=figure.get_figheight(), 

629 dpi=figure.get_dpi()) 

630 

631 

632def cm_to_inch(cm: int | float) -> float: 

633 """ 

634 Convert cm to inch. 

635 

636 :param cm: the cm value 

637 :return: the value in inch 

638 """ 

639 if not isinstance(cm, int): 

640 if not isinstance(cm, float): 

641 raise type_error(cm, "cm", (int, float)) 

642 if not isfinite(cm): 

643 raise ValueError(f"cm must be finite, but is {cm}.") 

644 res: float = cm / 2.54 

645 if not isfinite(res): 

646 raise ValueError(f"Conversation {cm} cm to inch " 

647 f"must be finite, but is {res}.") 

648 return res 

649 

650 

651#: the color attributes 

652__COLOR_ATTRS: Final[tuple[tuple[bool, bool, str], ...]] = \ 

653 ((True, True, "get_label"), (False, True, "label"), 

654 (False, True, "_label"), (True, False, "get_color"), 

655 (False, False, "color"), (False, False, "_color"), 

656 (False, False, "edgecolor"), (False, False, "_edgecolor"), 

657 (False, False, "markeredgecolor"), (False, False, "_markeredgecolor")) 

658 

659 

660def get_label_colors( 

661 handles: Iterable[Artist], 

662 color_map: dict[str, tuple[float, ...] | str] | None = None, 

663 default_color: tuple[float, ...] | str = pd.COLOR_BLACK) \ 

664 -> list[tuple[float, ...] | str]: 

665 """ 

666 Get a list with label colors from a set of artists. 

667 

668 :param handles: the handles 

669 :param color_map: an optional color decode 

670 :param default_color: the default color 

671 :returns: a list of label colors 

672 """ 

673 if not isinstance(handles, Iterable): 

674 raise type_error(handles, "handles", Iterable) 

675 

676 def __get_color(a: Artist, colmap=color_map, 

677 defcol=default_color) -> tuple[float, ...] | str: 

678 if not isinstance(a, Artist): 

679 raise type_error(a, "artist", Artist) 

680 

681 for acall, astr, aname in __COLOR_ATTRS: 

682 if hasattr(a, aname): 

683 val = getattr(a, aname) 

684 if val is None: 

685 continue 

686 if acall: 

687 val = val() 

688 if val is None: 

689 continue 

690 if astr: 

691 if colmap: 

692 if val in colmap: 

693 val = colmap[val] 

694 else: 

695 continue 

696 else: 

697 continue 

698 if val is None: 

699 continue 

700 if val != defcol: 

701 return val 

702 return defcol 

703 

704 return [__get_color(aa) for aa in handles]