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 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",
@@ -1,7 +1,8 @@
1
1
  """Image artists.
2
2
 
3
- Five chart classes, one shared helpers module, one
4
- matplotlib stylesheet side effect on import.
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.utils.globals import STYLE_SHEET
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
  ]
@@ -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.ticker import FuncFormatter
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
- load_fonts()
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
- fontsize_pt: float = FontProperties(
241
- size=plt.rcParams["legend.fontsize"]
242
- ).get_size_in_points()
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 = float(plt.rcParams["legend.handlelength"]) * fs_px
245
- pad_px: float = float(plt.rcParams["legend.handletextpad"]) * fs_px
246
- col_pad_px: float = float(plt.rcParams["legend.columnspacing"]) * fs_px
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
- The x-axis limit is extended by the widest label so
396
- the text is not clipped by the axes frame.
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
- placements: list[float] = []
441
- prev_y = -float("inf")
442
- for _, val, _, _ in entries:
443
- y_display: float = trans.transform((0, val))[1] # type: ignore[reportUnknownArgumentType]
444
- if y_display - prev_y < text_height:
445
- y_display = prev_y + text_height
446
- placements.append(y_display)
447
- prev_y = y_display
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 + 10, 0))[0] # type: ignore[reportUnknownArgumentType]
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
 
@@ -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