tesorotools-python 0.0.46__py3-none-any.whl → 0.0.48__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tesorotools/__init__.py +3 -0
- tesorotools/artists/__init__.py +5 -7
- tesorotools/artists/_common.py +413 -27
- tesorotools/artists/barh_plot.py +3 -2
- tesorotools/artists/line_plot.py +176 -24
- tesorotools/artists/matrix.py +7 -2
- tesorotools/artists/stacked.py +3 -2
- tesorotools/artists/type_curve.py +2 -1
- tesorotools/artists/vector_plot.py +2 -1
- tesorotools/artists/waterfall.py +326 -0
- tesorotools/assets/plots.yaml +46 -1
- tesorotools/assets/tesoro.mplstyle +2 -2
- tesorotools/utils/matplotlib.py +19 -1
- {tesorotools_python-0.0.46.dist-info → tesorotools_python-0.0.48.dist-info}/METADATA +1 -1
- {tesorotools_python-0.0.46.dist-info → tesorotools_python-0.0.48.dist-info}/RECORD +16 -15
- {tesorotools_python-0.0.46.dist-info → tesorotools_python-0.0.48.dist-info}/WHEEL +0 -0
tesorotools/__init__.py
CHANGED
|
@@ -54,6 +54,7 @@ from tesorotools.artists import (
|
|
|
54
54
|
StackedBarPlot,
|
|
55
55
|
TypeCurve,
|
|
56
56
|
VectorPlot,
|
|
57
|
+
Waterfall,
|
|
57
58
|
)
|
|
58
59
|
from tesorotools.orchestration import CompositeRegistry, iter_contexts
|
|
59
60
|
from tesorotools.providers.base import (
|
|
@@ -83,6 +84,7 @@ def _register_builtins() -> None:
|
|
|
83
84
|
register_artist("type_curve", TypeCurve)
|
|
84
85
|
register_artist("vector_plot", VectorPlot)
|
|
85
86
|
register_artist("matrix", MatrixChart)
|
|
87
|
+
register_artist("waterfall", Waterfall)
|
|
86
88
|
|
|
87
89
|
register_tag("format", Format)
|
|
88
90
|
register_tag("legend", Legend)
|
|
@@ -127,6 +129,7 @@ __all__ = [
|
|
|
127
129
|
"Title",
|
|
128
130
|
"TypeCurve",
|
|
129
131
|
"VectorPlot",
|
|
132
|
+
"Waterfall",
|
|
130
133
|
"YamlConstructor",
|
|
131
134
|
"all_artists",
|
|
132
135
|
"all_providers",
|
tesorotools/artists/__init__.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""Image artists.
|
|
2
2
|
|
|
3
|
-
Five chart classes
|
|
4
|
-
|
|
3
|
+
Five chart classes and one shared helpers module. Importing
|
|
4
|
+
``_common`` applies the house style (palette + fonts) as a side
|
|
5
|
+
effect, so the cycle is active no matter which artist is imported.
|
|
5
6
|
|
|
6
7
|
Each class follows the same shape:
|
|
7
8
|
|
|
@@ -24,8 +25,6 @@ live under :mod:`tesorotools.render`; this package is
|
|
|
24
25
|
strictly image output.
|
|
25
26
|
"""
|
|
26
27
|
|
|
27
|
-
import matplotlib.style
|
|
28
|
-
|
|
29
28
|
from tesorotools.artists._common import Artist, Format, Legend
|
|
30
29
|
from tesorotools.artists.barh_plot import GroupedBarChart, HorizontalBarChart
|
|
31
30
|
from tesorotools.artists.line_plot import LinePlot
|
|
@@ -33,9 +32,7 @@ from tesorotools.artists.matrix import MatrixChart
|
|
|
33
32
|
from tesorotools.artists.stacked import StackedAreaPlot, StackedBarPlot
|
|
34
33
|
from tesorotools.artists.type_curve import TypeCurve
|
|
35
34
|
from tesorotools.artists.vector_plot import VectorPlot
|
|
36
|
-
from tesorotools.
|
|
37
|
-
|
|
38
|
-
matplotlib.style.use(STYLE_SHEET)
|
|
35
|
+
from tesorotools.artists.waterfall import Waterfall
|
|
39
36
|
|
|
40
37
|
__all__ = [
|
|
41
38
|
"Artist",
|
|
@@ -49,4 +46,5 @@ __all__ = [
|
|
|
49
46
|
"StackedBarPlot",
|
|
50
47
|
"TypeCurve",
|
|
51
48
|
"VectorPlot",
|
|
49
|
+
"Waterfall",
|
|
52
50
|
]
|
tesorotools/artists/_common.py
CHANGED
|
@@ -29,22 +29,62 @@ from typing import Any, Protocol, Self, cast, runtime_checkable
|
|
|
29
29
|
import matplotlib.pyplot as plt
|
|
30
30
|
import pandas as pd
|
|
31
31
|
from matplotlib.axes import Axes
|
|
32
|
+
from matplotlib.dates import AutoDateLocator, ConciseDateFormatter
|
|
33
|
+
from matplotlib.dates import (
|
|
34
|
+
date2num, # type: ignore[reportUnknownVariableType]
|
|
35
|
+
)
|
|
36
|
+
from matplotlib.dates import (
|
|
37
|
+
num2date, # type: ignore[reportUnknownVariableType]
|
|
38
|
+
)
|
|
32
39
|
from matplotlib.figure import Figure
|
|
33
40
|
from matplotlib.font_manager import FontProperties
|
|
34
|
-
from matplotlib.
|
|
41
|
+
from matplotlib.legend import Legend as MplLegend
|
|
42
|
+
from matplotlib.ticker import FixedLocator, FuncFormatter, MaxNLocator
|
|
35
43
|
from yaml.nodes import MappingNode
|
|
36
44
|
|
|
37
45
|
from tesorotools.utils.matplotlib import (
|
|
38
46
|
PLOT_CONFIG,
|
|
47
|
+
apply_house_style,
|
|
39
48
|
format_annotation,
|
|
40
|
-
load_fonts,
|
|
41
49
|
)
|
|
42
50
|
from tesorotools.utils.template import TemplateLoader
|
|
43
51
|
|
|
44
|
-
|
|
52
|
+
# Apply the full house look (colour cycle + grid + tick sizes from the
|
|
53
|
+
# stylesheet, then the bundled fonts) once, here at import: every image
|
|
54
|
+
# artist imports this module, so the cycle is active no matter which
|
|
55
|
+
# artist (or caller) triggers the import first.
|
|
56
|
+
apply_house_style()
|
|
45
57
|
|
|
46
58
|
AX_CONFIG: dict[str, Any] = PLOT_CONFIG["ax"]
|
|
47
59
|
FIG_CONFIG: dict[str, Any] = PLOT_CONFIG["figure"]
|
|
60
|
+
COMPACT_CONFIG: dict[str, Any] = PLOT_CONFIG.get("compact", {})
|
|
61
|
+
SAVE_CONFIG: dict[str, Any] = PLOT_CONFIG.get("save", {})
|
|
62
|
+
|
|
63
|
+
CM_TO_IN: float = 1.0 / 2.54
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def dynamic_dpi(fig: Figure) -> int:
|
|
67
|
+
"""Save DPI adapted to the figure's physical size.
|
|
68
|
+
|
|
69
|
+
The figure DPI (``figure.dpi`` in ``plots.yaml``) drives *layout* and
|
|
70
|
+
is deliberately high; saving every PNG at it wastes memory on large
|
|
71
|
+
figures (a 16 cm chart at 500 dpi is ~3150 px wide). Instead the save
|
|
72
|
+
DPI scales **inversely with size** so the long side lands near
|
|
73
|
+
``save.target_px`` pixels: big figures save at fewer dpi (lighter
|
|
74
|
+
files), small ones at more. It is clamped to ``[save.min_dpi,
|
|
75
|
+
figure.dpi]`` -- never below ``save.min_dpi`` (so the result is always
|
|
76
|
+
print-sharp, never pixelated) and never above the layout dpi (above
|
|
77
|
+
which extra pixels are pure waste).
|
|
78
|
+
"""
|
|
79
|
+
target_px: float = float(SAVE_CONFIG.get("target_px", 2000))
|
|
80
|
+
min_dpi: float = float(SAVE_CONFIG.get("min_dpi", 300))
|
|
81
|
+
max_dpi: float = float(FIG_CONFIG.get("dpi", 500))
|
|
82
|
+
width_in, height_in = (float(v) for v in fig.get_size_inches())
|
|
83
|
+
long_in = max(width_in, height_in)
|
|
84
|
+
if long_in <= 0: # pragma: no cover - defensive: a real figure
|
|
85
|
+
# never has a zero/negative side; guards against a 0/0 division.
|
|
86
|
+
return int(max_dpi)
|
|
87
|
+
return int(round(min(max_dpi, max(min_dpi, target_px / long_in))))
|
|
48
88
|
|
|
49
89
|
|
|
50
90
|
# ----------------------------------------------------------------------
|
|
@@ -216,6 +256,11 @@ def auto_ncol(
|
|
|
216
256
|
ax: Axes,
|
|
217
257
|
labels: list[str],
|
|
218
258
|
available_width_px: float | None = None,
|
|
259
|
+
*,
|
|
260
|
+
fontsize: float | None = None,
|
|
261
|
+
handlelength: float | None = None,
|
|
262
|
+
handletextpad: float | None = None,
|
|
263
|
+
columnspacing: float | None = None,
|
|
219
264
|
) -> int:
|
|
220
265
|
"""Choose the maximum legend ``ncol`` that fits the axes.
|
|
221
266
|
|
|
@@ -228,6 +273,12 @@ def auto_ncol(
|
|
|
228
273
|
Pass ``available_width_px`` when the legend will live in
|
|
229
274
|
``fig.legend`` (figure-wide space) instead of
|
|
230
275
|
``ax.legend``; defaults to the axes width.
|
|
276
|
+
|
|
277
|
+
The keyword overrides (``fontsize`` / ``handlelength`` /
|
|
278
|
+
``handletextpad`` / ``columnspacing``) let a caller measure
|
|
279
|
+
against a legend styled differently from ``rcParams`` -- the
|
|
280
|
+
compact mode passes its smaller legend font and shorter
|
|
281
|
+
handles here so the column count matches what is drawn.
|
|
231
282
|
"""
|
|
232
283
|
fig = ax.get_figure()
|
|
233
284
|
if fig is None:
|
|
@@ -237,18 +288,35 @@ def auto_ncol(
|
|
|
237
288
|
if available_width_px is None:
|
|
238
289
|
available_width_px = ax.get_window_extent(renderer).width # type: ignore[reportUnknownArgumentType]
|
|
239
290
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
)
|
|
291
|
+
legend_fs: Any = (
|
|
292
|
+
fontsize if fontsize is not None else plt.rcParams["legend.fontsize"]
|
|
293
|
+
)
|
|
294
|
+
hl: float = (
|
|
295
|
+
handlelength
|
|
296
|
+
if handlelength is not None
|
|
297
|
+
else float(plt.rcParams["legend.handlelength"])
|
|
298
|
+
)
|
|
299
|
+
htp: float = (
|
|
300
|
+
handletextpad
|
|
301
|
+
if handletextpad is not None
|
|
302
|
+
else float(plt.rcParams["legend.handletextpad"])
|
|
303
|
+
)
|
|
304
|
+
cs: float = (
|
|
305
|
+
columnspacing
|
|
306
|
+
if columnspacing is not None
|
|
307
|
+
else float(plt.rcParams["legend.columnspacing"])
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
fontsize_pt: float = FontProperties(size=legend_fs).get_size_in_points()
|
|
243
311
|
fs_px: float = fontsize_pt * fig.dpi / 72.0
|
|
244
|
-
handle_px: float =
|
|
245
|
-
pad_px: float =
|
|
246
|
-
col_pad_px: float =
|
|
312
|
+
handle_px: float = hl * fs_px
|
|
313
|
+
pad_px: float = htp * fs_px
|
|
314
|
+
col_pad_px: float = cs * fs_px
|
|
247
315
|
|
|
248
316
|
max_label_px = 0.0
|
|
249
317
|
for label in labels:
|
|
250
318
|
t = ax.text( # type: ignore[reportUnknownMemberType]
|
|
251
|
-
0, 0, label
|
|
319
|
+
0, 0, label, fontsize=legend_fs
|
|
252
320
|
)
|
|
253
321
|
bbox = t.get_window_extent(renderer) # type: ignore[reportUnknownArgumentType]
|
|
254
322
|
max_label_px = max(max_label_px, bbox.width)
|
|
@@ -295,6 +363,276 @@ def export_legend(
|
|
|
295
363
|
legend.remove()
|
|
296
364
|
|
|
297
365
|
|
|
366
|
+
# ----------------------------------------------------------------------
|
|
367
|
+
# Compact mode (charts that live inside a Word table, two per row)
|
|
368
|
+
# ----------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def compact_figsize() -> tuple[float, float]:
|
|
372
|
+
"""Return the compact figure size ``(width, height)`` in inches.
|
|
373
|
+
|
|
374
|
+
The width is the **total** figure width (chart + legend),
|
|
375
|
+
read from the ``compact`` section of ``plots.yaml`` in
|
|
376
|
+
centimetres and converted to inches. Defaults to
|
|
377
|
+
7.4 cm x 5.5 cm when the config is absent.
|
|
378
|
+
"""
|
|
379
|
+
width_cm: float = float(COMPACT_CONFIG.get("width_cm", 7.4))
|
|
380
|
+
height_cm: float = float(COMPACT_CONFIG.get("height_cm", 5.5))
|
|
381
|
+
return (width_cm * CM_TO_IN, height_cm * CM_TO_IN)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def compact_font_pt() -> float | None:
|
|
385
|
+
"""Font size (points) for compact charts, or ``None`` to keep the style.
|
|
386
|
+
|
|
387
|
+
Because the compact figure is rendered at its true
|
|
388
|
+
physical size (7.4 cm) and embedded without rescaling,
|
|
389
|
+
these points equal the points the reader sees in Word --
|
|
390
|
+
set ``compact.font_pt`` in ``plots.yaml`` to the document
|
|
391
|
+
body-text size so chart text matches running text.
|
|
392
|
+
"""
|
|
393
|
+
raw: Any = COMPACT_CONFIG.get("font_pt")
|
|
394
|
+
return None if raw is None else float(raw)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def compact_legend_font_pt() -> float:
|
|
398
|
+
"""Font size (points) for the compact legend -- never ``None``.
|
|
399
|
+
|
|
400
|
+
Falls back ``legend_font_pt`` -> ``font_pt`` -> ``9`` so the legend
|
|
401
|
+
never silently reverts to the stylesheet's body size (the original
|
|
402
|
+
"11 pt legend" bug): the legend must be measured and drawn at the
|
|
403
|
+
same, smaller size for the column count to be right.
|
|
404
|
+
"""
|
|
405
|
+
return float(
|
|
406
|
+
COMPACT_CONFIG.get("legend_font_pt", COMPACT_CONFIG.get("font_pt", 9))
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def compact_legend_kwargs() -> dict[str, Any]:
|
|
411
|
+
"""Legend styling for compact charts, from the ``compact`` config.
|
|
412
|
+
|
|
413
|
+
Returns ``fontsize`` / ``handlelength`` / ``handletextpad`` /
|
|
414
|
+
``columnspacing`` as concrete values (never ``None``), so the metrics
|
|
415
|
+
used to *measure* the column count in :func:`auto_ncol` match exactly
|
|
416
|
+
the metrics used to *draw* the legend in :func:`legend_above` -- the
|
|
417
|
+
invariant that prevents an over-counted ``ncol``. The smaller font
|
|
418
|
+
and shorter handles let several wide labels share one legend row.
|
|
419
|
+
"""
|
|
420
|
+
return {
|
|
421
|
+
"fontsize": compact_legend_font_pt(),
|
|
422
|
+
"handlelength": float(COMPACT_CONFIG.get("legend_handlelength", 1.2)),
|
|
423
|
+
"handletextpad": float(COMPACT_CONFIG.get("legend_handletextpad", 0.4)),
|
|
424
|
+
"columnspacing": float(COMPACT_CONFIG.get("legend_columnspacing", 0.8)),
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def compact_legend_max_ncol() -> int | None:
|
|
429
|
+
"""Hard cap on the compact legend column count (``None`` if unset)."""
|
|
430
|
+
raw: Any = COMPACT_CONFIG.get("legend_max_ncol")
|
|
431
|
+
return None if raw is None else int(raw)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def compact_headroom_pad_pt() -> float:
|
|
435
|
+
"""Points of clearance to leave above the top value label (0 if unset)."""
|
|
436
|
+
return float(COMPACT_CONFIG.get("annotate_headroom_pad_pt", 0))
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class _StartLabeledConcise(ConciseDateFormatter):
|
|
440
|
+
"""``ConciseDateFormatter`` that spells out the first (start) tick.
|
|
441
|
+
|
|
442
|
+
When the compact date axis pins the first datum as the leading tick
|
|
443
|
+
(see :func:`compact_date_axis`) that date is rarely a clean month
|
|
444
|
+
boundary, so the concise formatter would label it with a bare day
|
|
445
|
+
number (e.g. ``31``). This subclass relabels just that tick so the
|
|
446
|
+
chart's origin is unambiguous: a January start shows the **year only**
|
|
447
|
+
(``2020`` -- narrow, like the other year ticks), any other month shows
|
|
448
|
+
``month year`` (``jul. 2020``). Every other tick keeps the concise
|
|
449
|
+
label.
|
|
450
|
+
"""
|
|
451
|
+
|
|
452
|
+
def __init__(self, locator: Any, start_num: float) -> None:
|
|
453
|
+
# show_offset=False: don't append the trailing "year-month" offset
|
|
454
|
+
# text (it duplicated the month of the last tick, e.g. "jul." plus
|
|
455
|
+
# "2026-jul."). With it off, ConciseDateFormatter shows the year
|
|
456
|
+
# inline at year-boundary ticks instead.
|
|
457
|
+
super().__init__( # type: ignore[reportUnknownMemberType]
|
|
458
|
+
locator, show_offset=False
|
|
459
|
+
)
|
|
460
|
+
self._start_num = start_num
|
|
461
|
+
|
|
462
|
+
def format_ticks(self, values: Any) -> Any:
|
|
463
|
+
labels: list[str] = list(super().format_ticks(values))
|
|
464
|
+
for i, value in enumerate(values):
|
|
465
|
+
moment = num2date(value)
|
|
466
|
+
if abs(float(value) - self._start_num) < 0.5: # within half a day
|
|
467
|
+
if moment.month == 1:
|
|
468
|
+
# January start: the year alone is unambiguous and narrow,
|
|
469
|
+
# so it won't collide with the next (year) tick.
|
|
470
|
+
labels[i] = str(moment.year)
|
|
471
|
+
else:
|
|
472
|
+
# locale's %b may already carry a trailing dot ("ene.");
|
|
473
|
+
# strip it so we don't end up with "jul.. 2020".
|
|
474
|
+
month = moment.strftime("%b").lower().rstrip(".")
|
|
475
|
+
labels[i] = f"{month}. {moment.year}"
|
|
476
|
+
elif moment.month == 1:
|
|
477
|
+
# year boundary: show the year ("2026") instead of "ene." so
|
|
478
|
+
# the reader sees the year change without a trailing offset.
|
|
479
|
+
labels[i] = str(moment.year)
|
|
480
|
+
return labels
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def compact_date_axis(
|
|
484
|
+
ax: Axes,
|
|
485
|
+
data_min: Any,
|
|
486
|
+
data_max: Any,
|
|
487
|
+
*,
|
|
488
|
+
maxticks: int = 4,
|
|
489
|
+
) -> None:
|
|
490
|
+
"""Compact date x-axis: few horizontal ticks, anchored to the data span.
|
|
491
|
+
|
|
492
|
+
Ticks are computed over the **data span** ``[data_min,
|
|
493
|
+
data_max]`` and pinned with a ``FixedLocator``, so none
|
|
494
|
+
float in the right-hand margin that the value labels open
|
|
495
|
+
up -- the last tick lands as close to the final data
|
|
496
|
+
point as the tick step allows. When ``compact.date_show_start``
|
|
497
|
+
is true (the default) the first data point is pinned as the
|
|
498
|
+
leading tick, so the reader can see exactly when the series
|
|
499
|
+
begins. Requires the series to have been plotted with
|
|
500
|
+
``x_compat=True`` (pandas' own date plotting ignores this
|
|
501
|
+
formatter).
|
|
502
|
+
"""
|
|
503
|
+
lo: float = float(cast(Any, date2num(data_min)))
|
|
504
|
+
hi: float = float(cast(Any, date2num(data_max)))
|
|
505
|
+
locator = AutoDateLocator(minticks=2, maxticks=maxticks)
|
|
506
|
+
candidates: list[float] = cast(
|
|
507
|
+
"list[float]", locator.tick_values(data_min, data_max)
|
|
508
|
+
)
|
|
509
|
+
interior: list[float] = [t for t in candidates if lo < t <= hi]
|
|
510
|
+
if COMPACT_CONFIG.get("date_show_start", True):
|
|
511
|
+
# pin the first data point as the leading tick so the reader sees
|
|
512
|
+
# exactly when the series starts (the natural calendar tick often
|
|
513
|
+
# falls just *before* the first datum and is clipped by margins=0).
|
|
514
|
+
# Drop any auto tick that would crowd the start label.
|
|
515
|
+
min_gap: float = (hi - lo) * 0.10
|
|
516
|
+
interior = [t for t in interior if t - lo >= min_gap]
|
|
517
|
+
ticks: list[float] = [lo, *interior]
|
|
518
|
+
else:
|
|
519
|
+
ticks = [t for t in candidates if lo <= t <= hi] or [lo, hi]
|
|
520
|
+
show_start = bool(COMPACT_CONFIG.get("date_show_start", True))
|
|
521
|
+
|
|
522
|
+
def _set_ticks(values: list[float]) -> None:
|
|
523
|
+
loc = FixedLocator(values)
|
|
524
|
+
ax.xaxis.set_major_locator(loc)
|
|
525
|
+
ax.xaxis.set_major_formatter(
|
|
526
|
+
_StartLabeledConcise(loc, lo)
|
|
527
|
+
if show_start
|
|
528
|
+
else ConciseDateFormatter(loc)
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
_set_ticks(ticks)
|
|
532
|
+
ax.tick_params( # type: ignore[reportUnknownMemberType]
|
|
533
|
+
axis="x", rotation=0
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
# A priori de-overlap: render the labels, measure their boxes, and drop
|
|
537
|
+
# any tick whose label would collide with the previous kept one (the
|
|
538
|
+
# start tick is always kept). Catches a wide start label (e.g. a January
|
|
539
|
+
# start widened to a year, or "jul. 2020") crowding the next tick.
|
|
540
|
+
fig = ax.get_figure()
|
|
541
|
+
if fig is not None and len(ticks) > 1:
|
|
542
|
+
fig.canvas.draw() # type: ignore[reportUnknownMemberType]
|
|
543
|
+
renderer = fig.canvas.get_renderer() # type: ignore[reportUnknownMemberType]
|
|
544
|
+
boxes = [
|
|
545
|
+
lbl.get_window_extent(renderer) # type: ignore[reportUnknownArgumentType]
|
|
546
|
+
for lbl in ax.get_xticklabels()
|
|
547
|
+
]
|
|
548
|
+
if len(boxes) == len(ticks):
|
|
549
|
+
pad_px = 2.0
|
|
550
|
+
kept: list[float] = [ticks[0]]
|
|
551
|
+
last_x1: float = boxes[0].x1
|
|
552
|
+
for tick, box in zip(ticks[1:], boxes[1:]):
|
|
553
|
+
if box.x0 <= last_x1 + pad_px:
|
|
554
|
+
continue # collides with previous kept label -> drop
|
|
555
|
+
kept.append(tick)
|
|
556
|
+
last_x1 = box.x1
|
|
557
|
+
if len(kept) < len(ticks):
|
|
558
|
+
_set_ticks(kept)
|
|
559
|
+
|
|
560
|
+
for label in ax.get_xticklabels():
|
|
561
|
+
label.set_horizontalalignment("center")
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def compact_y_axis(ax: Axes) -> None:
|
|
565
|
+
"""Denser y-ticks for compact charts (more reference numbers).
|
|
566
|
+
|
|
567
|
+
Compact charts are read at body-text size, so Matplotlib's default
|
|
568
|
+
of a couple of y-numbers on a short axis is too sparse. Bump the
|
|
569
|
+
count to ``compact.y_max_ticks`` "nice" steps (1/2/2.5/5/10 decades)
|
|
570
|
+
so the reader gets more gridline references -- e.g. every 20 instead
|
|
571
|
+
of every 50. Leaves the default locator untouched when the knob is
|
|
572
|
+
unset, and never changes the y-tick *formatter* set by
|
|
573
|
+
:func:`style_spines`.
|
|
574
|
+
"""
|
|
575
|
+
raw: Any = COMPACT_CONFIG.get("y_max_ticks")
|
|
576
|
+
if raw is None:
|
|
577
|
+
return
|
|
578
|
+
ax.yaxis.set_major_locator(
|
|
579
|
+
MaxNLocator(nbins=int(raw), steps=[1, 2, 2.5, 5, 10])
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def legend_above(
|
|
584
|
+
ax: Axes,
|
|
585
|
+
handles: list[Any],
|
|
586
|
+
labels: list[str],
|
|
587
|
+
*,
|
|
588
|
+
ncol: int,
|
|
589
|
+
fontsize: float | None = None,
|
|
590
|
+
handlelength: float | None = None,
|
|
591
|
+
handletextpad: float | None = None,
|
|
592
|
+
columnspacing: float | None = None,
|
|
593
|
+
) -> MplLegend:
|
|
594
|
+
"""Place the legend in a band **above the axes, spanning the figure**.
|
|
595
|
+
|
|
596
|
+
Uses ``loc="outside upper center"`` so constrained layout reserves a
|
|
597
|
+
strip at the top of the *figure* and the legend may use the whole
|
|
598
|
+
figure width -- wider than the narrow compact axes, whose left edge
|
|
599
|
+
is eaten by the y-tick numbers and whose right edge is eaten by the
|
|
600
|
+
end-of-series value labels. Spanning the figure lets the maximum
|
|
601
|
+
number of columns share one row, which **minimises the vertical
|
|
602
|
+
space the legend steals from the plot** (fewer rows = taller chart).
|
|
603
|
+
|
|
604
|
+
``fontsize`` / ``handlelength`` / ``handletextpad`` /
|
|
605
|
+
``columnspacing`` style the legend (``None`` keeps the
|
|
606
|
+
``rcParams`` default); the compact mode passes a smaller
|
|
607
|
+
font and shorter handles so more labels fit per row.
|
|
608
|
+
"""
|
|
609
|
+
fig = ax.get_figure()
|
|
610
|
+
return fig.legend( # type: ignore[reportUnknownMemberType, reportOptionalMemberAccess]
|
|
611
|
+
handles,
|
|
612
|
+
labels,
|
|
613
|
+
loc="outside upper center",
|
|
614
|
+
ncol=ncol,
|
|
615
|
+
fontsize=fontsize,
|
|
616
|
+
handlelength=handlelength,
|
|
617
|
+
handletextpad=handletextpad,
|
|
618
|
+
columnspacing=columnspacing,
|
|
619
|
+
frameon=False, # no box: less clutter in the tight compact figure
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def declutter_spines(ax: Axes) -> None:
|
|
624
|
+
"""Hide the spines that carry no ticks (top and right).
|
|
625
|
+
|
|
626
|
+
Compact line charts move the y-ticks to the **left** (so
|
|
627
|
+
the right edge is free for the end-of-series value labels)
|
|
628
|
+
and keep x-ticks on the bottom, leaving the top and right
|
|
629
|
+
spines as pure clutter -- drop them to free space and
|
|
630
|
+
reduce visual noise.
|
|
631
|
+
"""
|
|
632
|
+
ax.spines["top"].set_visible(False)
|
|
633
|
+
ax.spines["right"].set_visible(False)
|
|
634
|
+
|
|
635
|
+
|
|
298
636
|
# ----------------------------------------------------------------------
|
|
299
637
|
# Cartesian (vertical-y) styling
|
|
300
638
|
# ----------------------------------------------------------------------
|
|
@@ -333,13 +671,15 @@ def style_spines(
|
|
|
333
671
|
ax.tick_params( # type: ignore[reportUnknownMemberType]
|
|
334
672
|
which="minor", size=0, width=0
|
|
335
673
|
)
|
|
674
|
+
# Set the tick-mark colour via tick_params (not by recolouring each
|
|
675
|
+
# Line2D): this stores it in the axis' tick kwargs, so ANY tick
|
|
676
|
+
# regenerated after this call inherits it. Compact mode swaps in a
|
|
677
|
+
# denser y-locator later, which rebuilds the tick objects; recolouring
|
|
678
|
+
# the lines directly here would leave those new ticks the default black
|
|
679
|
+
# (only the relocated ones would stay grey -- hence a lone black tick).
|
|
336
680
|
ax.tick_params( # type: ignore[reportUnknownMemberType]
|
|
337
|
-
axis="both", which="major"
|
|
681
|
+
axis="both", which="major", color=color
|
|
338
682
|
)
|
|
339
|
-
for tick in ax.get_xticklines():
|
|
340
|
-
tick.set_markeredgecolor(color)
|
|
341
|
-
for tick in ax.get_yticklines():
|
|
342
|
-
tick.set_markeredgecolor(color)
|
|
343
683
|
|
|
344
684
|
|
|
345
685
|
def style_baseline(
|
|
@@ -378,6 +718,10 @@ def annotate_last_values(
|
|
|
378
718
|
labels: dict[str, str] | None = None,
|
|
379
719
|
series_styles: dict[str, dict[str, Any]] | None = None,
|
|
380
720
|
annotate_color: str | None = None,
|
|
721
|
+
weight: str | None = None,
|
|
722
|
+
fontsize: float | None = None,
|
|
723
|
+
right_pad_px: float = 10.0,
|
|
724
|
+
headroom_pad_pt: float = 0.0,
|
|
381
725
|
) -> None:
|
|
382
726
|
"""Label the last non-NaN value of each column on the right.
|
|
383
727
|
|
|
@@ -392,8 +736,19 @@ def annotate_last_values(
|
|
|
392
736
|
|
|
393
737
|
Labels are packed vertically in display space so that
|
|
394
738
|
series ending at nearly the same value do not overlap.
|
|
395
|
-
|
|
396
|
-
|
|
739
|
+
They sit just right of the last point and the x-axis
|
|
740
|
+
limit is extended by the widest label plus ``right_pad_px``
|
|
741
|
+
so the text is not clipped by the axes frame; raise
|
|
742
|
+
``right_pad_px`` (e.g. on compact charts) to open more
|
|
743
|
+
blank space on the right and keep the labels clear of both
|
|
744
|
+
the data lines and the right-hand y-tick labels.
|
|
745
|
+
|
|
746
|
+
``headroom_pad_pt`` is the clearance kept above the top
|
|
747
|
+
label: if the packed stack would reach past the top
|
|
748
|
+
y-limit, that limit grows enough to keep this many points
|
|
749
|
+
free and the labels are re-packed at the new scale, so a
|
|
750
|
+
tall stack never spills into a legend above the axes (nor
|
|
751
|
+
is clipped at the top).
|
|
397
752
|
"""
|
|
398
753
|
fig = ax.get_figure()
|
|
399
754
|
if fig is None:
|
|
@@ -428,7 +783,7 @@ def annotate_last_values(
|
|
|
428
783
|
trans = ax.transData
|
|
429
784
|
|
|
430
785
|
sample = ax.text( # type: ignore[reportUnknownMemberType]
|
|
431
|
-
0, 0, "0"
|
|
786
|
+
0, 0, "0", fontsize=fontsize
|
|
432
787
|
)
|
|
433
788
|
text_height = (
|
|
434
789
|
sample.get_window_extent(renderer).height # type: ignore[reportUnknownArgumentType]
|
|
@@ -437,14 +792,39 @@ def annotate_last_values(
|
|
|
437
792
|
sample.remove()
|
|
438
793
|
|
|
439
794
|
entries.sort(key=lambda e: e[1])
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
795
|
+
|
|
796
|
+
def _pack(top_data: float) -> list[float]:
|
|
797
|
+
# Set the final y-scale BEFORE packing. Spacing is enforced in display
|
|
798
|
+
# space, so it must be measured at the scale the labels are drawn at;
|
|
799
|
+
# growing the y-limit *after* packing (the old approach) rescaled the
|
|
800
|
+
# data points and quietly reopened the overlaps packing should remove.
|
|
801
|
+
ax.set_ylim(top=top_data)
|
|
802
|
+
fig.canvas.draw() # type: ignore[reportUnknownMemberType]
|
|
803
|
+
packed: list[float] = []
|
|
804
|
+
prev = -float("inf")
|
|
805
|
+
for _, value, _, _ in entries:
|
|
806
|
+
y_disp: float = trans.transform((0, value))[1] # type: ignore[reportUnknownArgumentType]
|
|
807
|
+
if y_disp - prev < text_height:
|
|
808
|
+
y_disp = prev + text_height
|
|
809
|
+
packed.append(y_disp)
|
|
810
|
+
prev = y_disp
|
|
811
|
+
return packed
|
|
812
|
+
|
|
813
|
+
_, ymax = ax.get_ylim()
|
|
814
|
+
placements = _pack(ymax)
|
|
815
|
+
|
|
816
|
+
# The stack packs upward; if it reaches past the top y-limit it renders
|
|
817
|
+
# over the legend (compact, legend above) or is clipped (normal). Grow the
|
|
818
|
+
# y-limit so the top label keeps headroom_pad_pt of clearance, then RE-PACK
|
|
819
|
+
# so the spacing is exact at the new (final) scale.
|
|
820
|
+
pad_px = headroom_pad_pt * fig.dpi / 72.0
|
|
821
|
+
top_axes_px: float = trans.transform((0, ymax))[1] # type: ignore[reportUnknownArgumentType]
|
|
822
|
+
needed_px = placements[-1] + text_height / 2 + pad_px
|
|
823
|
+
if needed_px > top_axes_px:
|
|
824
|
+
new_top: float = trans.inverted().transform( # type: ignore[reportUnknownArgumentType]
|
|
825
|
+
(0, needed_px)
|
|
826
|
+
)[1]
|
|
827
|
+
placements = _pack(new_top)
|
|
448
828
|
|
|
449
829
|
max_label_width = 0.0
|
|
450
830
|
for (date, val, _, color), packed_y in zip(entries, placements):
|
|
@@ -459,13 +839,19 @@ def annotate_last_values(
|
|
|
459
839
|
color=color,
|
|
460
840
|
va="center",
|
|
461
841
|
ha="left",
|
|
842
|
+
fontweight=weight if weight is not None else "normal",
|
|
843
|
+
fontsize=fontsize,
|
|
462
844
|
)
|
|
463
845
|
width = t.get_window_extent(renderer).width # type: ignore[reportUnknownArgumentType]
|
|
464
846
|
max_label_width = max(max_label_width, width)
|
|
465
847
|
|
|
848
|
+
# labels spill right of the data, so widen the x-limit by the widest
|
|
849
|
+
# label plus right_pad_px to keep them unclipped (and, with a larger
|
|
850
|
+
# pad, clear of the right-hand y-tick labels). x is independent of the
|
|
851
|
+
# y-limit set above, so this stays correct.
|
|
466
852
|
inv = trans.inverted()
|
|
467
853
|
x0: float = inv.transform((0, 0))[0] # type: ignore[reportUnknownArgumentType]
|
|
468
|
-
x1: float = inv.transform((max_label_width +
|
|
854
|
+
x1: float = inv.transform((max_label_width + right_pad_px, 0))[0] # type: ignore[reportUnknownArgumentType]
|
|
469
855
|
xmin, xmax = ax.get_xlim()
|
|
470
856
|
ax.set_xlim(xmin, xmax + (x1 - x0))
|
|
471
857
|
|
tesorotools/artists/barh_plot.py
CHANGED
|
@@ -36,6 +36,7 @@ from tesorotools.artists._common import (
|
|
|
36
36
|
Legend,
|
|
37
37
|
adjust_figure_for_plot_size,
|
|
38
38
|
auto_ncol,
|
|
39
|
+
dynamic_dpi,
|
|
39
40
|
resolve_data,
|
|
40
41
|
)
|
|
41
42
|
from tesorotools.utils.matplotlib import PLOT_CONFIG, format_annotation
|
|
@@ -370,7 +371,7 @@ class HorizontalBarChart:
|
|
|
370
371
|
_style_zero_baseline(ax, **AX_CONFIG["baseline"])
|
|
371
372
|
|
|
372
373
|
fig.savefig( # type: ignore[reportUnknownMemberType]
|
|
373
|
-
self.out_path
|
|
374
|
+
self.out_path, dpi=dynamic_dpi(fig)
|
|
374
375
|
)
|
|
375
376
|
plt.close(fig)
|
|
376
377
|
return ax
|
|
@@ -572,7 +573,7 @@ class GroupedBarChart:
|
|
|
572
573
|
adjust_figure_for_plot_size(fig, ax, self.plot_size)
|
|
573
574
|
|
|
574
575
|
fig.savefig( # type: ignore[reportUnknownMemberType]
|
|
575
|
-
self.out_path
|
|
576
|
+
self.out_path, dpi=dynamic_dpi(fig)
|
|
576
577
|
)
|
|
577
578
|
plt.close(fig)
|
|
578
579
|
return ax
|