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
« 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
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
18import moptipy.utils.plot_defaults as pd
19from moptipy.utils.lang import Lang
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
29#: The golden ratio constant with value 1.618033988749895.
30__GOLDEN_RATIO: Final[float] = 0.5 + (0.5 * sqrt(5))
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.
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
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.")
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.")
79 kwargs["figsize"] = width, height
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
90 if "frameon" not in kwargs:
91 kwargs["frameon"] = False
93 if "constrained_layout" not in kwargs:
94 kwargs["constrained_layout"] = False
96 return Figure(**kwargs)
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.
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
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.")
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
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.
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}")
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))
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])
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
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)
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
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}.")
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), )
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)
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.
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)
410 size = fig.get_size_inches()
411 orientation: Final[str] = \
412 "landscape" if size[0] >= size[1] else "portrait"
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)
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)
438 fig.clf(False)
439 plt.close(fig)
440 del fig
442 if len(files) <= 0:
443 raise ValueError("No formats were specified.")
445 return files
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.
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
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"
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
513 if may_rotate_text and (len(text) > 2):
514 args["rotation"] = 90
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
523 axes.annotate(**args) # type: ignore
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.
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)
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)
578def get_axes(figure: Axes | Figure) -> Axes:
579 """
580 Obtain the axes from a figure or axes object.
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)}.")
611def get_renderer(figure: Axes | Figure | SubFigure) -> RendererBase:
612 """
613 Get a renderer that can be used for determining figure element sizes.
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())
632def cm_to_inch(cm: int | float) -> float:
633 """
634 Convert cm to inch.
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
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"))
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.
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)
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)
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
704 return [__get_color(aa) for aa in handles]