tesorotools-python 0.0.50__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.
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/.gitignore +5 -1
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/PKG-INFO +3 -1
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/pyproject.toml +4 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/__init__.py +9 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/artists/__init__.py +6 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/artists/_common.py +238 -34
- tesorotools_python-0.0.52/src/tesorotools/artists/bar_line.py +362 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/artists/barh_plot.py +20 -0
- tesorotools_python-0.0.52/src/tesorotools/artists/box_plot.py +517 -0
- tesorotools_python-0.0.52/src/tesorotools/artists/intraday_plot.py +309 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/artists/line_plot.py +161 -16
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/artists/matrix.py +10 -0
- tesorotools_python-0.0.52/src/tesorotools/artists/plotly_backend.py +1033 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/artists/shock_plot.py +25 -3
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/artists/stacked.py +26 -3
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/artists/type_curve.py +10 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/artists/vector_plot.py +10 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/artists/waterfall.py +10 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/plots.yaml +57 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/render/content/images.py +18 -9
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/_build_context.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/_registry.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/artists/compact.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/README.md +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/fonts/README.md +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/template.docx +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/assets/tesoro.mplstyle +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/data_sources/__init__.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/data_sources/debug.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/database/__init__.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/database/local.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/database/push.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/database/shared.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/dependencies/__init__.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/dependencies/node.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/dependencies/resolution.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/driver.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/manifest.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/offsets/__init__.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/offsets/offsets.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/offsets/outliers.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/orchestration.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/pipeline/__init__.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/pipeline/diagnose.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/pipeline/engine.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/pipeline/rules.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/providers/__init__.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/providers/base.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/providers/bde.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/providers/ecb.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/providers/imf_irfcl.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/providers/lseg.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/py.typed +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/render/__init__.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/render/content/__init__.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/render/content/content.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/render/content/section.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/render/content/subtitle.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/render/content/table.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/render/content/text.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/render/content/title.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/render/document.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/render/report.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/testing/__init__.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/testing/compare.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/utils/__init__.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/utils/config.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/utils/format.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/utils/globals.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/utils/matplotlib.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/utils/series.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/utils/shortcuts.py +0 -0
- {tesorotools_python-0.0.50 → tesorotools_python-0.0.52}/src/tesorotools/utils/template.py +0 -0
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
__pycache__
|
|
5
5
|
|
|
6
|
-
|
|
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.
|
|
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
|
|
@@ -19,5 +19,7 @@ Requires-Dist: requests>=2.31; extra == 'ecb'
|
|
|
19
19
|
Provides-Extra: imf
|
|
20
20
|
Requires-Dist: requests>=2.31; extra == 'imf'
|
|
21
21
|
Requires-Dist: truststore>=0.10; extra == 'imf'
|
|
22
|
+
Provides-Extra: interactive-plots
|
|
23
|
+
Requires-Dist: plotly>=5.0; extra == 'interactive-plots'
|
|
22
24
|
Provides-Extra: lseg
|
|
23
25
|
Requires-Dist: lseg-data>=2.1; extra == 'lseg'
|
|
@@ -29,6 +29,7 @@ bde = ["requests>=2.31"]
|
|
|
29
29
|
ecb = ["requests>=2.31"]
|
|
30
30
|
imf = ["requests>=2.31", "truststore>=0.10"]
|
|
31
31
|
lseg = ["lseg-data>=2.1"]
|
|
32
|
+
interactive_plots = ["plotly>=5.0"]
|
|
32
33
|
|
|
33
34
|
[dependency-groups]
|
|
34
35
|
dev = [
|
|
@@ -37,6 +38,9 @@ dev = [
|
|
|
37
38
|
"pre-commit>=4.0",
|
|
38
39
|
"pandas-stubs>=2.2",
|
|
39
40
|
"coverage>=7.0",
|
|
41
|
+
# the interactive Plotly backend is an optional extra, but the test
|
|
42
|
+
# suite exercises it (and the 100% coverage gate requires it present).
|
|
43
|
+
"plotly>=5.0",
|
|
40
44
|
]
|
|
41
45
|
|
|
42
46
|
[build-system]
|
|
@@ -53,9 +53,12 @@ from tesorotools._registry import (
|
|
|
53
53
|
register_tags,
|
|
54
54
|
)
|
|
55
55
|
from tesorotools.artists import (
|
|
56
|
+
BarLinePlot,
|
|
57
|
+
BoxPlot,
|
|
56
58
|
Format,
|
|
57
59
|
GroupedBarChart,
|
|
58
60
|
HorizontalBarChart,
|
|
61
|
+
IntradayPlot,
|
|
59
62
|
Legend,
|
|
60
63
|
LinePlot,
|
|
61
64
|
MatrixChart,
|
|
@@ -88,6 +91,8 @@ from tesorotools.render import (
|
|
|
88
91
|
|
|
89
92
|
def _register_builtins() -> None:
|
|
90
93
|
register_artist("line_plot", LinePlot)
|
|
94
|
+
register_artist("bar_line", BarLinePlot)
|
|
95
|
+
register_artist("box_plot", BoxPlot)
|
|
91
96
|
register_artist("stacked_area", StackedAreaPlot)
|
|
92
97
|
register_artist("stacked_bar", StackedBarPlot)
|
|
93
98
|
register_artist("barh", HorizontalBarChart)
|
|
@@ -96,6 +101,7 @@ def _register_builtins() -> None:
|
|
|
96
101
|
register_artist("vector_plot", VectorPlot)
|
|
97
102
|
register_artist("matrix", MatrixChart)
|
|
98
103
|
register_artist("shock_plot", ShockChart)
|
|
104
|
+
register_artist("intraday", IntradayPlot)
|
|
99
105
|
register_artist("waterfall", Waterfall)
|
|
100
106
|
|
|
101
107
|
register_tag("format", Format)
|
|
@@ -116,6 +122,8 @@ _register_builtins()
|
|
|
116
122
|
__all__ = [
|
|
117
123
|
"__version__",
|
|
118
124
|
"Artist",
|
|
125
|
+
"BarLinePlot",
|
|
126
|
+
"BoxPlot",
|
|
119
127
|
"BdeProvider",
|
|
120
128
|
"BuildContext",
|
|
121
129
|
"CompositeRegistry",
|
|
@@ -127,6 +135,7 @@ __all__ = [
|
|
|
127
135
|
"HorizontalBarChart",
|
|
128
136
|
"Image",
|
|
129
137
|
"Images",
|
|
138
|
+
"IntradayPlot",
|
|
130
139
|
"Legend",
|
|
131
140
|
"LinePlot",
|
|
132
141
|
"LSEGProvider",
|
|
@@ -26,7 +26,10 @@ strictly image output.
|
|
|
26
26
|
"""
|
|
27
27
|
|
|
28
28
|
from tesorotools.artists._common import Artist, Format, Legend
|
|
29
|
+
from tesorotools.artists.bar_line import BarLinePlot
|
|
29
30
|
from tesorotools.artists.barh_plot import GroupedBarChart, HorizontalBarChart
|
|
31
|
+
from tesorotools.artists.box_plot import BoxPlot
|
|
32
|
+
from tesorotools.artists.intraday_plot import IntradayPlot
|
|
30
33
|
from tesorotools.artists.line_plot import LinePlot
|
|
31
34
|
from tesorotools.artists.matrix import MatrixChart
|
|
32
35
|
from tesorotools.artists.shock_plot import ShockChart
|
|
@@ -37,9 +40,12 @@ from tesorotools.artists.waterfall import Waterfall
|
|
|
37
40
|
|
|
38
41
|
__all__ = [
|
|
39
42
|
"Artist",
|
|
43
|
+
"BarLinePlot",
|
|
44
|
+
"BoxPlot",
|
|
40
45
|
"Format",
|
|
41
46
|
"GroupedBarChart",
|
|
42
47
|
"HorizontalBarChart",
|
|
48
|
+
"IntradayPlot",
|
|
43
49
|
"Legend",
|
|
44
50
|
"LinePlot",
|
|
45
51
|
"MatrixChart",
|
|
@@ -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.
|
|
@@ -96,9 +130,7 @@ def dynamic_dpi(fig: Figure) -> int:
|
|
|
96
130
|
class Artist(Protocol):
|
|
97
131
|
"""Structural contract every image artist satisfies.
|
|
98
132
|
|
|
99
|
-
The
|
|
100
|
-
``StackedAreaPlot``, ``StackedBarPlot``, ``TypeCurve``,
|
|
101
|
-
``HorizontalBarChart``) and any third-party artist
|
|
133
|
+
The built-in artists and any third-party artist
|
|
102
134
|
registered via :func:`tesorotools.register_artist`
|
|
103
135
|
expose the same three-piece surface:
|
|
104
136
|
|
|
@@ -260,6 +292,51 @@ def adjust_figure_for_plot_size(
|
|
|
260
292
|
fig.set_size_inches(new_w, new_h)
|
|
261
293
|
|
|
262
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
|
+
|
|
263
340
|
def auto_ncol(
|
|
264
341
|
ax: Axes,
|
|
265
342
|
labels: list[str],
|
|
@@ -498,25 +575,62 @@ class _StartLabeledConcise(ConciseDateFormatter):
|
|
|
498
575
|
return labels
|
|
499
576
|
|
|
500
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
|
+
|
|
501
592
|
def compact_date_axis(
|
|
502
593
|
ax: Axes,
|
|
503
594
|
data_min: Any,
|
|
504
595
|
data_max: Any,
|
|
505
596
|
*,
|
|
506
597
|
maxticks: int = 4,
|
|
598
|
+
rotation: float | None = None,
|
|
599
|
+
pin_start: bool | None = None,
|
|
600
|
+
auto_rotate: bool = False,
|
|
507
601
|
) -> None:
|
|
508
|
-
"""Compact date x-axis: few
|
|
602
|
+
"""Compact date x-axis: few ticks, anchored to the data span.
|
|
509
603
|
|
|
510
604
|
Ticks are computed over the **data span** ``[data_min,
|
|
511
605
|
data_max]`` and pinned with a ``FixedLocator``, so none
|
|
512
606
|
float in the right-hand margin that the value labels open
|
|
513
607
|
up -- the last tick lands as close to the final data
|
|
514
|
-
point as the tick step allows. When
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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).
|
|
520
634
|
"""
|
|
521
635
|
lo: float = float(cast(Any, date2num(data_min)))
|
|
522
636
|
hi: float = float(cast(Any, date2num(data_max)))
|
|
@@ -524,8 +638,13 @@ def compact_date_axis(
|
|
|
524
638
|
candidates: list[float] = cast(
|
|
525
639
|
"list[float]", locator.tick_values(data_min, data_max)
|
|
526
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
|
+
)
|
|
527
646
|
interior: list[float] = [t for t in candidates if lo < t <= hi]
|
|
528
|
-
if
|
|
647
|
+
if show_start:
|
|
529
648
|
# pin the first data point as the leading tick so the reader sees
|
|
530
649
|
# exactly when the series starts (the natural calendar tick often
|
|
531
650
|
# falls just *before* the first datum and is clipped by margins=0).
|
|
@@ -535,7 +654,6 @@ def compact_date_axis(
|
|
|
535
654
|
ticks: list[float] = [lo, *interior]
|
|
536
655
|
else:
|
|
537
656
|
ticks = [t for t in candidates if lo <= t <= hi] or [lo, hi]
|
|
538
|
-
show_start = bool(COMPACT_CONFIG.get("date_show_start", True))
|
|
539
657
|
|
|
540
658
|
def _set_ticks(values: list[float]) -> None:
|
|
541
659
|
loc = FixedLocator(values)
|
|
@@ -546,18 +664,34 @@ def compact_date_axis(
|
|
|
546
664
|
else ConciseDateFormatter(loc)
|
|
547
665
|
)
|
|
548
666
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
|
560
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)
|
|
561
695
|
renderer = fig.canvas.get_renderer() # type: ignore[reportUnknownMemberType]
|
|
562
696
|
boxes = [
|
|
563
697
|
lbl.get_window_extent(renderer) # type: ignore[reportUnknownArgumentType]
|
|
@@ -565,18 +699,88 @@ def compact_date_axis(
|
|
|
565
699
|
]
|
|
566
700
|
if len(boxes) == len(ticks):
|
|
567
701
|
pad_px = 2.0
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
+
|
|
577
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]
|
|
578
781
|
for label in ax.get_xticklabels():
|
|
579
|
-
label.set_horizontalalignment(
|
|
782
|
+
label.set_horizontalalignment(ha)
|
|
783
|
+
label.set_rotation_mode("anchor")
|
|
580
784
|
|
|
581
785
|
|
|
582
786
|
def compact_y_axis(ax: Axes) -> None:
|