tesorotools-python 0.0.51__tar.gz → 0.0.52__tar.gz

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.
Files changed (81) hide show
  1. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/.gitignore +5 -1
  2. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/PKG-INFO +1 -1
  3. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/artists/_common.py +237 -31
  4. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/artists/line_plot.py +126 -10
  5. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/artists/plotly_backend.py +6 -2
  6. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/assets/plots.yaml +22 -0
  7. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/pyproject.toml +0 -0
  8. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/__init__.py +0 -0
  9. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/_build_context.py +0 -0
  10. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/_registry.py +0 -0
  11. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/artists/__init__.py +0 -0
  12. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/artists/bar_line.py +0 -0
  13. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/artists/barh_plot.py +0 -0
  14. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/artists/box_plot.py +0 -0
  15. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/artists/compact.py +0 -0
  16. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/artists/intraday_plot.py +0 -0
  17. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/artists/matrix.py +0 -0
  18. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/artists/shock_plot.py +0 -0
  19. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/artists/stacked.py +0 -0
  20. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/artists/type_curve.py +0 -0
  21. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/artists/vector_plot.py +0 -0
  22. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/artists/waterfall.py +0 -0
  23. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/assets/README.md +0 -0
  24. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
  25. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
  26. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
  27. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
  28. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
  29. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
  30. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
  31. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
  32. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/README.md +0 -0
  33. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/assets/template.docx +0 -0
  34. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/assets/tesoro.mplstyle +0 -0
  35. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/data_sources/__init__.py +0 -0
  36. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/data_sources/debug.py +0 -0
  37. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/database/__init__.py +0 -0
  38. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/database/local.py +0 -0
  39. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/database/push.py +0 -0
  40. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/database/shared.py +0 -0
  41. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/dependencies/__init__.py +0 -0
  42. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/dependencies/node.py +0 -0
  43. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/dependencies/resolution.py +0 -0
  44. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/driver.py +0 -0
  45. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/manifest.py +0 -0
  46. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/offsets/__init__.py +0 -0
  47. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/offsets/offsets.py +0 -0
  48. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/offsets/outliers.py +0 -0
  49. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/orchestration.py +0 -0
  50. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/pipeline/__init__.py +0 -0
  51. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/pipeline/diagnose.py +0 -0
  52. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/pipeline/engine.py +0 -0
  53. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/pipeline/rules.py +0 -0
  54. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/providers/__init__.py +0 -0
  55. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/providers/base.py +0 -0
  56. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/providers/bde.py +0 -0
  57. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/providers/ecb.py +0 -0
  58. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/providers/imf_irfcl.py +0 -0
  59. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/providers/lseg.py +0 -0
  60. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/py.typed +0 -0
  61. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/render/__init__.py +0 -0
  62. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/render/content/__init__.py +0 -0
  63. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/render/content/content.py +0 -0
  64. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/render/content/images.py +0 -0
  65. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/render/content/section.py +0 -0
  66. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/render/content/subtitle.py +0 -0
  67. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/render/content/table.py +0 -0
  68. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/render/content/text.py +0 -0
  69. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/render/content/title.py +0 -0
  70. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/render/document.py +0 -0
  71. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/render/report.py +0 -0
  72. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/testing/__init__.py +0 -0
  73. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/testing/compare.py +0 -0
  74. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/utils/__init__.py +0 -0
  75. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/utils/config.py +0 -0
  76. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/utils/format.py +0 -0
  77. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/utils/globals.py +0 -0
  78. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/utils/matplotlib.py +0 -0
  79. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/utils/series.py +0 -0
  80. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/utils/shortcuts.py +0 -0
  81. {tesorotools_python-0.0.51 → tesorotools_python-0.0.52}/src/tesorotools/utils/template.py +0 -0
@@ -3,7 +3,11 @@
3
3
 
4
4
  __pycache__
5
5
 
6
- .vscode/
6
+ # Ignorar ajustes personales de VS Code, pero versionar los compartidos
7
+ # (recomendación de extensiones y formato de markdown con prettier).
8
+ .vscode/*
9
+ !.vscode/extensions.json
10
+ !.vscode/settings.json
7
11
 
8
12
  data/
9
13
  config/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tesorotools-python
3
- Version: 0.0.51
3
+ Version: 0.0.52
4
4
  Requires-Python: >=3.13
5
5
  Requires-Dist: babel>=2.17
6
6
  Requires-Dist: matplotlib>=3.10
@@ -24,7 +24,7 @@ overrides only need to update ``plots.yaml``.
24
24
  from __future__ import annotations
25
25
 
26
26
  from pathlib import Path
27
- from typing import Any, Protocol, Self, cast, runtime_checkable
27
+ from typing import Any, Literal, Protocol, Self, cast, runtime_checkable
28
28
 
29
29
  import matplotlib.pyplot as plt
30
30
  import pandas as pd
@@ -58,10 +58,44 @@ apply_house_style()
58
58
  AX_CONFIG: dict[str, Any] = PLOT_CONFIG["ax"]
59
59
  FIG_CONFIG: dict[str, Any] = PLOT_CONFIG["figure"]
60
60
  COMPACT_CONFIG: dict[str, Any] = PLOT_CONFIG.get("compact", {})
61
+ LINE_CONFIG: dict[str, Any] = PLOT_CONFIG.get("line", {})
61
62
  SAVE_CONFIG: dict[str, Any] = PLOT_CONFIG.get("save", {})
62
63
 
63
64
  CM_TO_IN: float = 1.0 / 2.54
64
65
 
66
+ # Soft date-density levels -> AutoDateLocator maxticks. Fallbacks used when the
67
+ # matching ``plots.yaml`` block (``compact.date_maxticks`` / ``line.date_maxticks``)
68
+ # is missing a level; "default" reproduces the historical tick count of each mode
69
+ # (4 for compact, 6 for non-compact).
70
+ DATE_DENSITY_LEVELS: tuple[str, ...] = ("few", "default", "many")
71
+ _DATE_MAXTICKS_FALLBACK_COMPACT: dict[str, int] = {
72
+ "few": 3,
73
+ "default": 4,
74
+ "many": 7,
75
+ }
76
+ _DATE_MAXTICKS_FALLBACK_LINE: dict[str, int] = {
77
+ "few": 3,
78
+ "default": 6,
79
+ "many": 10,
80
+ }
81
+
82
+
83
+ def date_maxticks(density: str, *, compact: bool) -> int:
84
+ """Resolve a soft ``date_density`` level to an ``AutoDateLocator`` maxticks.
85
+
86
+ Reads the per-mode ``date_maxticks`` table from ``plots.yaml``
87
+ (``compact.date_maxticks`` when *compact*, else ``line.date_maxticks``),
88
+ falling back to the built-in table when the config (or a level) is absent.
89
+ """
90
+ cfg = COMPACT_CONFIG if compact else LINE_CONFIG
91
+ fallback = (
92
+ _DATE_MAXTICKS_FALLBACK_COMPACT
93
+ if compact
94
+ else _DATE_MAXTICKS_FALLBACK_LINE
95
+ )
96
+ table: dict[str, Any] = cfg.get("date_maxticks", fallback)
97
+ return int(table.get(density, fallback[density]))
98
+
65
99
 
66
100
  def dynamic_dpi(fig: Figure) -> int:
67
101
  """Save DPI adapted to the figure's physical size.
@@ -258,6 +292,51 @@ def adjust_figure_for_plot_size(
258
292
  fig.set_size_inches(new_w, new_h)
259
293
 
260
294
 
295
+ def grow_height_to_axes(
296
+ fig: Figure,
297
+ ax: Axes,
298
+ target_axes_h_in: float,
299
+ *,
300
+ max_iter: int = 3,
301
+ cap_factor: float = 2.0,
302
+ ) -> None:
303
+ """Grow the figure **height** (width fixed) until *ax* is *target* inches tall.
304
+
305
+ Under ``constrained`` layout an outside legend band and the x-tick label
306
+ strip keep a roughly **fixed absolute** height, so height added to the
307
+ figure flows almost entirely into the axes rectangle. Growing to the axes
308
+ height measured *before* the legend was added therefore gives the plot area
309
+ back the vertical space the legend (and any rotated date labels) stole,
310
+ instead of squashing it inside a fixed figure.
311
+
312
+ A feedback loop (grow -> draw -> re-measure the deficit) converges in a few
313
+ passes because the reserved bands are non-linear only at the margin; total
314
+ growth is capped at ``cap_factor`` times the original height so a huge
315
+ legend cannot run the figure away. Measures the axes rectangle via
316
+ ``get_window_extent`` (not ``get_tightbbox``, which includes the very tick
317
+ strip being budgeted for).
318
+ """
319
+ orig_w, orig_h = (float(v) for v in fig.get_size_inches())
320
+ max_h = orig_h * cap_factor
321
+ eps_in = 0.02
322
+ for _ in range(max_iter):
323
+ fig.canvas.draw() # type: ignore[reportUnknownMemberType]
324
+ renderer = fig.canvas.get_renderer() # type: ignore[reportUnknownMemberType]
325
+ ax_h_in = (
326
+ ax.get_window_extent(renderer).height # type: ignore[reportUnknownArgumentType]
327
+ / fig.dpi
328
+ )
329
+ deficit = target_axes_h_in - ax_h_in
330
+ if deficit <= eps_in:
331
+ break
332
+ current_h = float(fig.get_size_inches()[1])
333
+ new_h = min(max_h, current_h + deficit)
334
+ if new_h <= current_h: # capped out: no further progress possible
335
+ break
336
+ fig.set_size_inches(orig_w, new_h)
337
+ fig.canvas.draw() # type: ignore[reportUnknownMemberType]
338
+
339
+
261
340
  def auto_ncol(
262
341
  ax: Axes,
263
342
  labels: list[str],
@@ -496,25 +575,62 @@ class _StartLabeledConcise(ConciseDateFormatter):
496
575
  return labels
497
576
 
498
577
 
578
+ def _date_label_ha(
579
+ rotation: float | None,
580
+ ) -> Literal["left", "center", "right"]:
581
+ """Horizontal alignment for date tick labels at *rotation* degrees.
582
+
583
+ Horizontal labels centre under their tick; a positive (counter-clockwise,
584
+ up-to-the-right) slant reads best right-anchored so each label runs
585
+ down-left under its tick, and a negative slant left-anchored.
586
+ """
587
+ if rotation is None or rotation == 0:
588
+ return "center"
589
+ return "right" if rotation > 0 else "left"
590
+
591
+
499
592
  def compact_date_axis(
500
593
  ax: Axes,
501
594
  data_min: Any,
502
595
  data_max: Any,
503
596
  *,
504
597
  maxticks: int = 4,
598
+ rotation: float | None = None,
599
+ pin_start: bool | None = None,
600
+ auto_rotate: bool = False,
505
601
  ) -> None:
506
- """Compact date x-axis: few horizontal ticks, anchored to the data span.
602
+ """Compact date x-axis: few ticks, anchored to the data span.
507
603
 
508
604
  Ticks are computed over the **data span** ``[data_min,
509
605
  data_max]`` and pinned with a ``FixedLocator``, so none
510
606
  float in the right-hand margin that the value labels open
511
607
  up -- the last tick lands as close to the final data
512
- point as the tick step allows. When ``compact.date_show_start``
513
- is true (the default) the first data point is pinned as the
514
- leading tick, so the reader can see exactly when the series
515
- begins. Requires the series to have been plotted with
516
- ``x_compat=True`` (pandas' own date plotting ignores this
517
- formatter).
608
+ point as the tick step allows. When the start is pinned (see
609
+ ``pin_start``) the first data point becomes the leading tick, so
610
+ the reader sees exactly when the series begins. Requires the
611
+ series to have been plotted with ``x_compat=True`` (pandas' own
612
+ date plotting ignores this formatter).
613
+
614
+ ``rotation`` (degrees) tilts the tick labels so more dates fit;
615
+ ``None`` keeps them horizontal (the historical behaviour). The
616
+ rotation and its matching horizontal alignment are applied
617
+ **before** the a-priori de-overlap measures the label boxes, so
618
+ the collision sweep sees the real (slanted, anchored) geometry.
619
+
620
+ ``pin_start`` pins the first data point as the leading tick;
621
+ ``None`` (the default) reads ``compact.date_show_start``. Pass
622
+ ``False`` to prefer a **clean equally spaced calendar grid** over
623
+ the (off-grid, wide) start label -- the caller does this when the
624
+ user asks for a non-default density or a rotation, where an
625
+ even cadence reads better than an anchored-but-ragged one.
626
+
627
+ ``auto_rotate`` makes the requested density **actually count**: it
628
+ tries increasing tilts (0, 30, 45, 60, 90 degrees) and keeps the
629
+ smallest one at which the most (ideally all) of the requested date
630
+ ticks survive the de-overlap -- so choosing a denser level packs
631
+ more dates by leaning the labels only as far as needed, instead of
632
+ silently collapsing to the same few. Ignored when ``rotation`` is
633
+ given (an explicit angle wins).
518
634
  """
519
635
  lo: float = float(cast(Any, date2num(data_min)))
520
636
  hi: float = float(cast(Any, date2num(data_max)))
@@ -522,8 +638,13 @@ def compact_date_axis(
522
638
  candidates: list[float] = cast(
523
639
  "list[float]", locator.tick_values(data_min, data_max)
524
640
  )
641
+ show_start: bool = (
642
+ bool(COMPACT_CONFIG.get("date_show_start", True))
643
+ if pin_start is None
644
+ else pin_start
645
+ )
525
646
  interior: list[float] = [t for t in candidates if lo < t <= hi]
526
- if COMPACT_CONFIG.get("date_show_start", True):
647
+ if show_start:
527
648
  # pin the first data point as the leading tick so the reader sees
528
649
  # exactly when the series starts (the natural calendar tick often
529
650
  # falls just *before* the first datum and is clipped by margins=0).
@@ -533,7 +654,6 @@ def compact_date_axis(
533
654
  ticks: list[float] = [lo, *interior]
534
655
  else:
535
656
  ticks = [t for t in candidates if lo <= t <= hi] or [lo, hi]
536
- show_start = bool(COMPACT_CONFIG.get("date_show_start", True))
537
657
 
538
658
  def _set_ticks(values: list[float]) -> None:
539
659
  loc = FixedLocator(values)
@@ -544,18 +664,34 @@ def compact_date_axis(
544
664
  else ConciseDateFormatter(loc)
545
665
  )
546
666
 
547
- _set_ticks(ticks)
548
- ax.tick_params( # type: ignore[reportUnknownMemberType]
549
- axis="x", rotation=0
550
- )
551
-
552
- # A priori de-overlap: render the labels, measure their boxes, and drop
553
- # any tick whose label would collide with the previous kept one (the
554
- # start tick is always kept). Catches a wide start label (e.g. a January
555
- # start widened to a year, or "jul. 2020") crowding the next tick.
556
- fig = ax.get_figure()
557
- if fig is not None and len(ticks) > 1:
667
+ def _style_labels(rot: float) -> None:
668
+ # rotation goes in the axis tick kwargs; ha / anchor rotation-mode are
669
+ # per-label Text properties reapplied after every draw or locator swap.
670
+ ha = _date_label_ha(None if rot == 0 else rot)
671
+ ax.tick_params( # type: ignore[reportUnknownMemberType]
672
+ axis="x", rotation=rot
673
+ )
674
+ for label in ax.get_xticklabels():
675
+ label.set_horizontalalignment(ha)
676
+ if rot != 0:
677
+ label.set_rotation_mode("anchor")
678
+
679
+ def _decimate_at(rot: float) -> list[int]:
680
+ # Render all candidate ticks at this tilt, measure their label boxes,
681
+ # and thin to a UNIFORMLY spaced subset when they collide -- so the
682
+ # survivors stay equally spaced in time (a greedy left-to-right drop
683
+ # leaves ragged gaps). Returns the kept indices into ``ticks``.
684
+ _set_ticks(ticks)
685
+ chosen: list[int] = list(range(len(ticks)))
686
+ fig = ax.get_figure()
687
+ if fig is None or len(ticks) <= 1:
688
+ _style_labels(rot)
689
+ return chosen
558
690
  fig.canvas.draw() # type: ignore[reportUnknownMemberType]
691
+ # set ha AFTER the draw (labels now exist) but BEFORE measuring:
692
+ # get_window_extent recomputes from the current Text properties, so the
693
+ # boxes reflect both the rotation (from the draw) and the anchor/ha.
694
+ _style_labels(rot)
559
695
  renderer = fig.canvas.get_renderer() # type: ignore[reportUnknownMemberType]
560
696
  boxes = [
561
697
  lbl.get_window_extent(renderer) # type: ignore[reportUnknownArgumentType]
@@ -563,18 +699,88 @@ def compact_date_axis(
563
699
  ]
564
700
  if len(boxes) == len(ticks):
565
701
  pad_px = 2.0
566
- kept: list[float] = [ticks[0]]
567
- last_x1: float = boxes[0].x1
568
- for tick, box in zip(ticks[1:], boxes[1:]):
569
- if box.x0 <= last_x1 + pad_px:
570
- continue # collides with previous kept label -> drop
571
- kept.append(tick)
572
- last_x1 = box.x1
573
- if len(kept) < len(ticks):
574
- _set_ticks(kept)
702
+ n = len(ticks)
703
+
704
+ def _clears(idx: list[int]) -> bool:
705
+ return all(
706
+ boxes[b].x0 > boxes[a].x1 + pad_px
707
+ for a, b in zip(idx, idx[1:])
708
+ )
709
+
710
+ # decimate from the right (anchor the latest date) by a constant
711
+ # stride so the gaps are equal. Force-keep index 0 ONLY when it is
712
+ # the pinned start tick; otherwise the whole grid is uniform and
713
+ # forcing it would re-open a ragged left gap.
714
+ keep0: set[int] = {0} if show_start else set()
715
+ chosen = sorted(keep0 | {0, n - 1})
716
+ for stride in range(1, n):
717
+ idx = sorted(keep0 | set(range(n - 1, -1, -stride)))
718
+ if _clears(idx):
719
+ chosen = idx
720
+ break
721
+ return chosen
722
+
723
+ if auto_rotate and rotation is None:
724
+ # honour the requested density by leaning the labels only as far as
725
+ # needed: pick the smallest tilt keeping the most date ticks (ties ->
726
+ # flatter), stopping as soon as one keeps them all.
727
+ best_rot = 0.0
728
+ best_chosen = _decimate_at(0.0)
729
+ for cand in (30.0, 45.0, 60.0, 90.0):
730
+ if len(best_chosen) >= len(ticks):
731
+ break
732
+ cand_chosen = _decimate_at(cand)
733
+ if len(cand_chosen) > len(best_chosen):
734
+ best_rot, best_chosen = cand, cand_chosen
735
+ rot_final, chosen = best_rot, best_chosen
736
+ else:
737
+ rot_final = 0.0 if rotation is None else float(rotation)
738
+ chosen = _decimate_at(rot_final)
739
+
740
+ # apply the winning tick subset + tilt as the final state
741
+ if len(chosen) < len(ticks):
742
+ _set_ticks([ticks[i] for i in chosen])
743
+ fig = ax.get_figure()
744
+ if fig is not None:
745
+ fig.canvas.draw() # type: ignore[reportUnknownMemberType]
746
+ _style_labels(rot_final)
747
+
575
748
 
749
+ def apply_date_axis(
750
+ ax: Axes,
751
+ *,
752
+ maxticks: int,
753
+ rotation: float | None = None,
754
+ ) -> None:
755
+ """Native matplotlib date x-axis with a soft tick count and optional slant.
756
+
757
+ The non-compact counterpart to :func:`compact_date_axis`: it takes over the
758
+ x-axis (which must carry a **native** date axis, i.e. the series were
759
+ plotted with ``x_compat=True``) with an ``AutoDateLocator`` capped at
760
+ *maxticks* and a ``ConciseDateFormatter`` bound to the **same** locator
761
+ instance (the concise context logic misfires with a mismatched one).
762
+ ``rotation`` (degrees) tilts the labels; ``None`` keeps them horizontal.
763
+
764
+ Unlike the compact path this changes the label **text style** too (concise
765
+ hierarchical labels instead of pandas' own date formatting), so callers use
766
+ it only when the user explicitly asks for a non-default density or rotation.
767
+ """
768
+ locator = AutoDateLocator(minticks=2, maxticks=maxticks)
769
+ ax.xaxis.set_major_locator(locator)
770
+ ax.xaxis.set_major_formatter(ConciseDateFormatter(locator))
771
+ if rotation is None or rotation == 0:
772
+ return
773
+ rot = float(rotation)
774
+ ha = _date_label_ha(rotation)
775
+ ax.tick_params( # type: ignore[reportUnknownMemberType]
776
+ axis="x", rotation=rot
777
+ )
778
+ fig = ax.get_figure()
779
+ if fig is not None:
780
+ fig.canvas.draw() # type: ignore[reportUnknownMemberType]
576
781
  for label in ax.get_xticklabels():
577
- label.set_horizontalalignment("center")
782
+ label.set_horizontalalignment(ha)
783
+ label.set_rotation_mode("anchor")
578
784
 
579
785
 
580
786
  def compact_y_axis(ax: Axes) -> None:
@@ -23,18 +23,22 @@ from yaml.nodes import MappingNode
23
23
 
24
24
  from tesorotools.artists._common import (
25
25
  AX_CONFIG,
26
+ DATE_DENSITY_LEVELS,
26
27
  Format,
27
28
  Legend,
28
29
  resolve_data,
29
30
  adjust_figure_for_plot_size,
30
31
  annotate_last_values,
32
+ apply_date_axis,
31
33
  compact_date_axis,
32
34
  compact_figure_kwargs,
33
35
  compact_font_pt,
34
36
  compact_headroom_pad_pt,
35
37
  compact_y_axis,
38
+ date_maxticks,
36
39
  draw_vlines,
37
40
  dynamic_dpi,
41
+ grow_height_to_axes,
38
42
  place_legend,
39
43
  style_baseline,
40
44
  style_compact_axes,
@@ -108,6 +112,35 @@ class LinePlot:
108
112
  with the axis at 2 (``1,14``) but the annotation at 4
109
113
  (``1,1419``); a dollar-index chart with the axis at 0
110
114
  (``101``) but the annotation at 1 (``101,1``).
115
+ * ``axis_units`` / ``annotate_units`` -- whether ``fmt.units``
116
+ is shown on the y-tick numbers and on the last-value
117
+ annotations, **independently**. Both default ``True`` (the
118
+ historical behaviour). Set ``axis_units: false`` to keep the
119
+ unit only on the value labels (a common house look: ``mM€``
120
+ on the annotations, bare numbers on the axis), or the reverse.
121
+ * ``date_density`` -- soft target for how many date ticks the
122
+ x-axis shows: ``"few"`` / ``"default"`` / ``"many"``, mapped
123
+ to a locator ``maxticks`` per mode in ``plots.yaml``
124
+ (``compact.date_maxticks`` / ``line.date_maxticks``).
125
+ ``"default"`` reproduces the current tick count. The surviving
126
+ ticks are kept **equally spaced in time**. In compact mode a
127
+ denser level with no explicit ``date_rotation`` **auto-tilts**
128
+ the labels only as far as needed to actually fit the extra
129
+ dates (a narrow chart cannot fit many horizontal 4-digit years),
130
+ so choosing ``"many"`` reliably shows more.
131
+ * ``date_rotation`` -- tilt the date tick labels this many
132
+ degrees so more fit; ``None`` keeps them horizontal (and lets
133
+ the compact auto-tilt above pick an angle). A positive angle
134
+ slants up-to-the-right (right-anchored); setting it explicitly
135
+ disables the auto-tilt. In non-compact mode, setting a
136
+ non-default ``date_density`` or any ``date_rotation`` switches
137
+ the x-axis to matplotlib's concise date formatting (different
138
+ label *style* from pandas' default); leaving both at their
139
+ defaults keeps the pandas path.
140
+ * ``adapt_height`` -- when true (the default), grow the figure
141
+ **height** (keeping the width) so the plot area is not
142
+ squashed by a large legend or rotated date labels. Skipped
143
+ when ``plot_size`` is set (that already fixes the axes size).
111
144
 
112
145
  Render lifecycle: ``build()`` returns ``(Figure, Axes)``
113
146
  in memory; ``save(fig)`` persists to ``out_path``;
@@ -140,6 +173,11 @@ class LinePlot:
140
173
  compact: bool = False,
141
174
  font_pt: float | None = None,
142
175
  axis_decimals: int | None = None,
176
+ axis_units: bool = True,
177
+ annotate_units: bool = True,
178
+ date_density: str = "default",
179
+ date_rotation: float | None = None,
180
+ adapt_height: bool = True,
143
181
  ref_lines: list[dict[str, Any]] | None = None,
144
182
  show_means: bool = False,
145
183
  means_window: tuple[Any, Any] | None = None,
@@ -152,6 +190,11 @@ class LinePlot:
152
190
  "either rebased multiplicatively (index) or subtractively "
153
191
  "(change-since), not both"
154
192
  )
193
+ if date_density not in DATE_DENSITY_LEVELS:
194
+ raise ValueError(
195
+ f"date_density must be one of {DATE_DENSITY_LEVELS}: "
196
+ f"{date_density!r}"
197
+ )
155
198
  self.out_path = out_path
156
199
  self.data = resolve_data(data, data_path)
157
200
  self.series = series
@@ -175,10 +218,27 @@ class LinePlot:
175
218
  self.compact = compact
176
219
  self.font_pt = font_pt
177
220
  self.axis_decimals = axis_decimals
221
+ self.axis_units = axis_units
222
+ self.annotate_units = annotate_units
223
+ self.date_density = date_density
224
+ self.date_rotation = date_rotation
225
+ self.adapt_height = adapt_height
178
226
  self.ref_lines = ref_lines or []
179
227
  self.show_means = show_means
180
228
  self.means_window = means_window
181
229
 
230
+ @property
231
+ def _custom_date_axis(self) -> bool:
232
+ """Whether the non-compact chart takes over the x-axis for dates.
233
+
234
+ Only when the user asks for a non-default density or a rotation; else
235
+ the historical pandas date plotting is kept untouched. Compact mode has
236
+ its own date axis (:func:`compact_date_axis`) and never uses this path.
237
+ """
238
+ return not self.compact and (
239
+ self.date_density != "default" or self.date_rotation is not None
240
+ )
241
+
182
242
  @classmethod
183
243
  def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
184
244
  """Build a :class:`LinePlot` from the ``!line_plot`` YAML tag.
@@ -202,8 +262,9 @@ class LinePlot:
202
262
  ``baseline``, ``fmt`` (``!format``), ``legend``
203
263
  (``!legend``), ``figsize``, ``series_styles``,
204
264
  ``plot_size``, ``vlines``, ``compact``, ``font_pt``,
205
- ``axis_decimals``, ``ref_lines``, ``show_means``,
206
- ``means_window``.
265
+ ``axis_decimals``, ``axis_units``, ``annotate_units``,
266
+ ``date_density``, ``date_rotation``, ``adapt_height``,
267
+ ``ref_lines``, ``show_means``, ``means_window``.
207
268
 
208
269
  Example
209
270
  -------
@@ -314,15 +375,17 @@ class LinePlot:
314
375
  font_pt = compact_font_pt(self.font_pt) if self.compact else None
315
376
 
316
377
  ax = fig.add_subplot()
378
+ # compact mode -- and the non-compact custom date axis -- need
379
+ # matplotlib's native date axis so the AutoDateLocator /
380
+ # ConciseDateFormatter take effect (x_compat disables pandas' own date
381
+ # plotting machinery). The default non-compact path keeps x_compat off.
382
+ native_date_axis = self.compact or self._custom_date_axis
317
383
  for col in plot_data.columns:
318
384
  style = self.series_styles.get(col, {})
319
- # compact mode needs matplotlib's native date axis so the
320
- # ConciseDateFormatter takes effect (x_compat disables pandas'
321
- # own date plotting machinery).
322
385
  plot_data[col].plot(
323
386
  ax=ax,
324
387
  label=self.series[col],
325
- x_compat=self.compact,
388
+ x_compat=native_date_axis,
326
389
  **style,
327
390
  )
328
391
 
@@ -334,12 +397,17 @@ class LinePlot:
334
397
  if self.vlines:
335
398
  draw_vlines(ax, self.vlines)
336
399
 
400
+ # units are shown on the y-tick numbers and on the last-value
401
+ # annotations independently; compute each once here.
402
+ axis_units = self.fmt.units if self.axis_units else ""
403
+ annotate_units = self.fmt.units if self.annotate_units else ""
404
+
337
405
  if self.annotate:
338
406
  annotate_last_values(
339
407
  ax,
340
408
  plot_data,
341
409
  decimals=self.fmt.decimals,
342
- units=self.fmt.units,
410
+ units=annotate_units,
343
411
  labels=self.series,
344
412
  series_styles=self.series_styles,
345
413
  annotate_color=self.annotate_color,
@@ -363,7 +431,7 @@ class LinePlot:
363
431
  style_spines(
364
432
  ax,
365
433
  decimals=self.fmt.decimals,
366
- units=self.fmt.units,
434
+ units=axis_units,
367
435
  **AX_CONFIG["spines"],
368
436
  )
369
437
  # Decouple tick precision from annotation precision: style_spines
@@ -373,7 +441,7 @@ class LinePlot:
373
441
  # the annotations at fmt.decimals.
374
442
  if self.axis_decimals is not None:
375
443
  axis_decimals = self.axis_decimals
376
- units = self.fmt.units
444
+ units = axis_units
377
445
 
378
446
  def _fmt_axis_tick(y: float, _pos: int) -> str:
379
447
  return format_annotation(y, axis_decimals, units)
@@ -425,11 +493,46 @@ class LinePlot:
425
493
  zorder=1,
426
494
  )
427
495
 
496
+ # Anchor for the height adaptation: the axes height BEFORE the date
497
+ # axis is thinned/rotated and BEFORE the legend is added, so growing
498
+ # back to it returns the vertical space that both the legend and any
499
+ # rotated date labels steal. Skipped when plot_size fixes the axes
500
+ # size itself. (The tiny tick-font difference vs the final compact
501
+ # styling is immaterial.)
502
+ target_axes_h_in: float | None = None
503
+ if self.adapt_height and self.plot_size is None:
504
+ fig.canvas.draw() # type: ignore[reportUnknownMemberType]
505
+ renderer = fig.canvas.get_renderer() # type: ignore[reportUnknownMemberType]
506
+ target_axes_h_in = (
507
+ ax.get_window_extent(renderer).height # type: ignore[reportUnknownArgumentType]
508
+ / fig.dpi
509
+ )
510
+
428
511
  if self.compact:
429
512
  # pin the date ticks to the data span (after annotate widened the
430
513
  # x-limit) so the last tick sits next to the final data point and
431
514
  # none float in the value-label margin; match Word's body font.
432
- compact_date_axis(ax, plot_data.index.min(), plot_data.index.max())
515
+ # date_density -> maxticks; date_rotation tilts the labels.
516
+ # An explicit density or rotation asks for a specific cadence, so
517
+ # prefer a clean equally spaced calendar grid over the pinned
518
+ # (off-grid, wide) start label; otherwise keep the house default.
519
+ custom_dates = (
520
+ self.date_density != "default" or self.date_rotation is not None
521
+ )
522
+ compact_date_axis(
523
+ ax,
524
+ plot_data.index.min(),
525
+ plot_data.index.max(),
526
+ maxticks=date_maxticks(self.date_density, compact=True),
527
+ rotation=self.date_rotation,
528
+ pin_start=False if custom_dates else None,
529
+ # a denser level with no explicit angle leans the labels only
530
+ # as far as needed so the extra dates actually appear.
531
+ auto_rotate=(
532
+ self.date_density != "default"
533
+ and self.date_rotation is None
534
+ ),
535
+ )
433
536
  # y-axis side: keep the house RIGHT side (matching every other
434
537
  # chart in a two-per-row row) by default, and move to the LEFT
435
538
  # only when annotate is on -- there the right edge is needed for
@@ -440,6 +543,15 @@ class LinePlot:
440
543
  # denser y-numbers (e.g. every 20 instead of every 50) so the
441
544
  # small chart still has enough reference gridlines.
442
545
  compact_y_axis(ax)
546
+ elif self._custom_date_axis:
547
+ # non-compact, but the user asked for a non-default density or a
548
+ # rotation: take over the (native) date axis with a concise
549
+ # formatter thinned to date_maxticks and tilt the labels.
550
+ apply_date_axis(
551
+ ax,
552
+ maxticks=date_maxticks(self.date_density, compact=False),
553
+ rotation=self.date_rotation,
554
+ )
443
555
 
444
556
  if self.legend is not None:
445
557
  # Draw from the actual labelled artists (so in-legend reference
@@ -454,6 +566,10 @@ class LinePlot:
454
566
  # so the axes-sizing pass would fight it; skip it there.
455
567
  if self.plot_size is not None and not self.compact:
456
568
  adjust_figure_for_plot_size(fig, ax, self.plot_size)
569
+ elif target_axes_h_in is not None:
570
+ # keep the width, grow the height so the legend / rotated dates do
571
+ # not squash the plot area below its pre-legend height.
572
+ grow_height_to_axes(fig, ax, target_axes_h_in)
457
573
 
458
574
  return fig, ax
459
575
 
@@ -262,7 +262,9 @@ def line_plot_to_figure(chart: LinePlot) -> go.Figure:
262
262
  x=series.index[-1],
263
263
  y=float(series.iloc[-1]),
264
264
  text=format_annotation(
265
- float(series.iloc[-1]), chart.fmt.decimals, chart.fmt.units
265
+ float(series.iloc[-1]),
266
+ chart.fmt.decimals,
267
+ chart.fmt.units if chart.annotate_units else "",
266
268
  ),
267
269
  showarrow=False,
268
270
  xanchor="left",
@@ -270,7 +272,9 @@ def line_plot_to_figure(chart: LinePlot) -> go.Figure:
270
272
  font={"color": _color(color)},
271
273
  )
272
274
 
273
- if chart.fmt.units:
275
+ # units on the y-tick numbers and on the annotations are independent
276
+ # toggles (mirrors the matplotlib artist).
277
+ if chart.fmt.units and chart.axis_units:
274
278
  fig.update_yaxes(ticksuffix=chart.fmt.units)
275
279
  return fig
276
280
 
@@ -89,6 +89,16 @@ waterfall:
89
89
 
90
90
  line:
91
91
  ncol: 5
92
+ # Densidad "soft" de fechas del eje X (no-compact): cada nivel de
93
+ # date_density se traduce en un maxticks para el AutoDateLocator (que es
94
+ # conservador: elige el intervalo bonito mas fino cuyo nº de ticks <=
95
+ # maxticks, asi que "many" va holgado). Solo se aplica cuando date_density
96
+ # != "default" o hay date_rotation (si no, se mantiene el ploteo de fechas
97
+ # de pandas por defecto).
98
+ date_maxticks:
99
+ few: 3
100
+ default: 6
101
+ many: 14
92
102
 
93
103
  # Combo barras + línea (!bar_line): la línea es la serie protagonista
94
104
  # (marcador circular por defecto, configurable por serie con line_styles),
@@ -150,6 +160,18 @@ compact:
150
160
  annotate_headroom_pad_pt: 4 # hueco por encima de la etiqueta de valor más alta
151
161
  date_show_start: true # fija la fecha del primer dato como primer tick X
152
162
  y_max_ticks: 10 # nº máx. de números en el eje Y (más referencias)
163
+ # Densidad "soft" de fechas del eje X: cada nivel de date_density se traduce
164
+ # en el maxticks del locator de fechas. El nivel "default" (4) es el
165
+ # comportamiento actual. Nota: AutoDateLocator es conservador (elige el
166
+ # intervalo "bonito" mas fino cuyo nº de ticks <= maxticks contando los del
167
+ # borde), asi que "many" necesita un valor holgado para ofrecer intervalos
168
+ # finos; luego la de-solapamiento recorta a lo que de verdad cabe (mas aun
169
+ # con anotaciones de ultimo valor, que comprimen el eje). Con rotacion caben
170
+ # mas.
171
+ date_maxticks:
172
+ few: 3
173
+ default: 4
174
+ many: 12
153
175
  # Gráficos de barras horizontales (barh / grouped_barh): el alto fijo de
154
176
  # compact aplastaría las etiquetas de un gráfico con muchas categorías, así
155
177
  # que en compact se mantiene el ANCHO fijo y el ALTO crece con el nº de