tesorotools-python 0.0.45__tar.gz → 0.0.47__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 (74) hide show
  1. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/.gitignore +3 -1
  2. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/PKG-INFO +1 -1
  3. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/__init__.py +9 -0
  4. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/artists/__init__.py +6 -0
  5. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/artists/_common.py +136 -5
  6. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/artists/line_plot.py +88 -15
  7. tesorotools_python-0.0.47/src/tesorotools/artists/matrix.py +302 -0
  8. tesorotools_python-0.0.47/src/tesorotools/artists/vector_plot.py +315 -0
  9. tesorotools_python-0.0.47/src/tesorotools/artists/waterfall.py +322 -0
  10. tesorotools_python-0.0.47/src/tesorotools/assets/plots.yaml +95 -0
  11. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/assets/tesoro.mplstyle +2 -2
  12. tesorotools_python-0.0.45/src/tesorotools/assets/plots.yaml +0 -44
  13. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/pyproject.toml +0 -0
  14. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/_build_context.py +0 -0
  15. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/_registry.py +0 -0
  16. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/artists/barh_plot.py +0 -0
  17. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/artists/stacked.py +0 -0
  18. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/artists/type_curve.py +0 -0
  19. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/assets/README.md +0 -0
  20. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
  21. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
  22. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
  23. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
  24. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
  25. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
  26. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
  27. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
  28. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/README.md +0 -0
  29. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/data_sources/__init__.py +0 -0
  30. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/data_sources/debug.py +0 -0
  31. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/database/__init__.py +0 -0
  32. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/database/local.py +0 -0
  33. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/database/push.py +0 -0
  34. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/database/shared.py +0 -0
  35. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/dependencies/__init__.py +0 -0
  36. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/dependencies/node.py +0 -0
  37. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/dependencies/resolution.py +0 -0
  38. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/driver.py +0 -0
  39. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/manifest.py +0 -0
  40. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/offsets/__init__.py +0 -0
  41. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/offsets/offsets.py +0 -0
  42. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/offsets/outliers.py +0 -0
  43. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/orchestration.py +0 -0
  44. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/pipeline/__init__.py +0 -0
  45. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/pipeline/diagnose.py +0 -0
  46. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/pipeline/engine.py +0 -0
  47. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/pipeline/rules.py +0 -0
  48. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/providers/__init__.py +0 -0
  49. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/providers/base.py +0 -0
  50. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/providers/bde.py +0 -0
  51. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/providers/ecb.py +0 -0
  52. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/providers/imf_irfcl.py +0 -0
  53. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/providers/lseg.py +0 -0
  54. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/py.typed +0 -0
  55. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/render/__init__.py +0 -0
  56. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/render/content/__init__.py +0 -0
  57. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/render/content/content.py +0 -0
  58. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/render/content/images.py +0 -0
  59. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/render/content/section.py +0 -0
  60. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/render/content/subtitle.py +0 -0
  61. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/render/content/table.py +0 -0
  62. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/render/content/text.py +0 -0
  63. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/render/content/title.py +0 -0
  64. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/render/report.py +0 -0
  65. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/testing/__init__.py +0 -0
  66. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/testing/compare.py +0 -0
  67. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/utils/__init__.py +0 -0
  68. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/utils/config.py +0 -0
  69. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/utils/format.py +0 -0
  70. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/utils/globals.py +0 -0
  71. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/utils/matplotlib.py +0 -0
  72. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/utils/series.py +0 -0
  73. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/utils/shortcuts.py +0 -0
  74. {tesorotools_python-0.0.45 → tesorotools_python-0.0.47}/src/tesorotools/utils/template.py +0 -0
@@ -24,4 +24,6 @@ test/
24
24
  .coverage
25
25
 
26
26
  # ruff
27
- .ruff_cache/
27
+ .ruff_cache/
28
+
29
+ demo_compacto/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tesorotools-python
3
- Version: 0.0.45
3
+ Version: 0.0.47
4
4
  Requires-Python: >=3.13
5
5
  Requires-Dist: babel>=2.17
6
6
  Requires-Dist: matplotlib>=3.10
@@ -49,9 +49,12 @@ from tesorotools.artists import (
49
49
  HorizontalBarChart,
50
50
  Legend,
51
51
  LinePlot,
52
+ MatrixChart,
52
53
  StackedAreaPlot,
53
54
  StackedBarPlot,
54
55
  TypeCurve,
56
+ VectorPlot,
57
+ Waterfall,
55
58
  )
56
59
  from tesorotools.orchestration import CompositeRegistry, iter_contexts
57
60
  from tesorotools.providers.base import (
@@ -79,6 +82,9 @@ def _register_builtins() -> None:
79
82
  register_artist("barh", HorizontalBarChart)
80
83
  register_artist("grouped_barh", GroupedBarChart)
81
84
  register_artist("type_curve", TypeCurve)
85
+ register_artist("vector_plot", VectorPlot)
86
+ register_artist("matrix", MatrixChart)
87
+ register_artist("waterfall", Waterfall)
82
88
 
83
89
  register_tag("format", Format)
84
90
  register_tag("legend", Legend)
@@ -111,6 +117,7 @@ __all__ = [
111
117
  "Legend",
112
118
  "LinePlot",
113
119
  "LSEGProvider",
120
+ "MatrixChart",
114
121
  "RegistryProtocol",
115
122
  "Report",
116
123
  "Section",
@@ -121,6 +128,8 @@ __all__ = [
121
128
  "Text",
122
129
  "Title",
123
130
  "TypeCurve",
131
+ "VectorPlot",
132
+ "Waterfall",
124
133
  "YamlConstructor",
125
134
  "all_artists",
126
135
  "all_providers",
@@ -29,8 +29,11 @@ import matplotlib.style
29
29
  from tesorotools.artists._common import Artist, Format, Legend
30
30
  from tesorotools.artists.barh_plot import GroupedBarChart, HorizontalBarChart
31
31
  from tesorotools.artists.line_plot import LinePlot
32
+ from tesorotools.artists.matrix import MatrixChart
32
33
  from tesorotools.artists.stacked import StackedAreaPlot, StackedBarPlot
33
34
  from tesorotools.artists.type_curve import TypeCurve
35
+ from tesorotools.artists.vector_plot import VectorPlot
36
+ from tesorotools.artists.waterfall import Waterfall
34
37
  from tesorotools.utils.globals import STYLE_SHEET
35
38
 
36
39
  matplotlib.style.use(STYLE_SHEET)
@@ -42,7 +45,10 @@ __all__ = [
42
45
  "HorizontalBarChart",
43
46
  "Legend",
44
47
  "LinePlot",
48
+ "MatrixChart",
45
49
  "StackedAreaPlot",
46
50
  "StackedBarPlot",
47
51
  "TypeCurve",
52
+ "VectorPlot",
53
+ "Waterfall",
48
54
  ]
@@ -29,9 +29,14 @@ 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
+ )
32
36
  from matplotlib.figure import Figure
33
37
  from matplotlib.font_manager import FontProperties
34
- from matplotlib.ticker import FuncFormatter
38
+ from matplotlib.legend import Legend as MplLegend
39
+ from matplotlib.ticker import FixedLocator, FuncFormatter
35
40
  from yaml.nodes import MappingNode
36
41
 
37
42
  from tesorotools.utils.matplotlib import (
@@ -45,6 +50,9 @@ load_fonts()
45
50
 
46
51
  AX_CONFIG: dict[str, Any] = PLOT_CONFIG["ax"]
47
52
  FIG_CONFIG: dict[str, Any] = PLOT_CONFIG["figure"]
53
+ COMPACT_CONFIG: dict[str, Any] = PLOT_CONFIG.get("compact", {})
54
+
55
+ CM_TO_IN: float = 1.0 / 2.54
48
56
 
49
57
 
50
58
  # ----------------------------------------------------------------------
@@ -295,6 +303,117 @@ def export_legend(
295
303
  legend.remove()
296
304
 
297
305
 
306
+ # ----------------------------------------------------------------------
307
+ # Compact mode (charts that live inside a Word table, two per row)
308
+ # ----------------------------------------------------------------------
309
+
310
+
311
+ def compact_figsize() -> tuple[float, float]:
312
+ """Return the compact figure size ``(width, height)`` in inches.
313
+
314
+ The width is the **total** figure width (chart + legend),
315
+ read from the ``compact`` section of ``plots.yaml`` in
316
+ centimetres and converted to inches. Defaults to
317
+ 7.4 cm x 5.5 cm when the config is absent.
318
+ """
319
+ width_cm: float = float(COMPACT_CONFIG.get("width_cm", 7.4))
320
+ height_cm: float = float(COMPACT_CONFIG.get("height_cm", 5.5))
321
+ return (width_cm * CM_TO_IN, height_cm * CM_TO_IN)
322
+
323
+
324
+ def compact_font_pt() -> float | None:
325
+ """Font size (points) for compact charts, or ``None`` to keep the style.
326
+
327
+ Because the compact figure is rendered at its true
328
+ physical size (7.4 cm) and embedded without rescaling,
329
+ these points equal the points the reader sees in Word --
330
+ set ``compact.font_pt`` in ``plots.yaml`` to the document
331
+ body-text size so chart text matches running text.
332
+ """
333
+ raw: Any = COMPACT_CONFIG.get("font_pt")
334
+ return None if raw is None else float(raw)
335
+
336
+
337
+ def compact_date_axis(
338
+ ax: Axes,
339
+ data_min: Any,
340
+ data_max: Any,
341
+ *,
342
+ maxticks: int = 4,
343
+ ) -> None:
344
+ """Compact date x-axis: few horizontal ticks, last one near the data end.
345
+
346
+ Ticks are computed over the **data span** ``[data_min,
347
+ data_max]`` and pinned with a ``FixedLocator``, so none
348
+ float in the right-hand margin that the value labels open
349
+ up -- the last tick lands as close to the final data
350
+ point as the tick step allows. Requires the series to
351
+ have been plotted with ``x_compat=True`` (pandas' own
352
+ date plotting ignores this formatter).
353
+ """
354
+ lo: float = float(cast(Any, date2num(data_min)))
355
+ hi: float = float(cast(Any, date2num(data_max)))
356
+ locator = AutoDateLocator(minticks=2, maxticks=maxticks)
357
+ candidates: list[float] = cast(
358
+ "list[float]", locator.tick_values(data_min, data_max)
359
+ )
360
+ ticks: list[float] = [t for t in candidates if lo <= t <= hi]
361
+ if not ticks:
362
+ ticks = [lo, hi]
363
+ fixed = FixedLocator(ticks)
364
+ ax.xaxis.set_major_locator(fixed)
365
+ ax.xaxis.set_major_formatter(ConciseDateFormatter(fixed))
366
+ ax.tick_params( # type: ignore[reportUnknownMemberType]
367
+ axis="x", rotation=0
368
+ )
369
+ for label in ax.get_xticklabels():
370
+ label.set_horizontalalignment("center")
371
+
372
+
373
+ def legend_above(
374
+ ax: Axes,
375
+ handles: list[Any],
376
+ labels: list[str],
377
+ *,
378
+ ncol: int,
379
+ fontsize: float | None = None,
380
+ ) -> MplLegend:
381
+ """Place the legend **above** the axes, expanded to its full width.
382
+
383
+ Uses ``mode="expand"`` so the legend spans exactly the
384
+ plot width (never wider) and ``bbox_to_anchor`` from the
385
+ ``compact`` config (default ``(0, 1.06, 1, 0.12)`` -- the
386
+ ``y0 > 1`` lifts it clear of the axes). The legend
387
+ becomes a child of the axes, so constrained layout
388
+ reserves room for it inside the fixed compact figure.
389
+ """
390
+ bbox: Any = COMPACT_CONFIG.get("legend_bbox", [0.0, 1.06, 1.0, 0.12])
391
+ return ax.legend( # type: ignore[reportUnknownMemberType]
392
+ handles,
393
+ labels,
394
+ loc="lower left",
395
+ bbox_to_anchor=tuple(bbox),
396
+ mode="expand",
397
+ borderaxespad=0.0,
398
+ ncol=ncol,
399
+ fontsize=fontsize,
400
+ frameon=False, # no box: less clutter in the tight compact figure
401
+ )
402
+
403
+
404
+ def declutter_spines(ax: Axes) -> None:
405
+ """Hide the spines that carry no ticks (top and right).
406
+
407
+ Compact line charts move the y-ticks to the **left** (so
408
+ the right edge is free for the end-of-series value labels)
409
+ and keep x-ticks on the bottom, leaving the top and right
410
+ spines as pure clutter -- drop them to free space and
411
+ reduce visual noise.
412
+ """
413
+ ax.spines["top"].set_visible(False)
414
+ ax.spines["right"].set_visible(False)
415
+
416
+
298
417
  # ----------------------------------------------------------------------
299
418
  # Cartesian (vertical-y) styling
300
419
  # ----------------------------------------------------------------------
@@ -378,6 +497,9 @@ def annotate_last_values(
378
497
  labels: dict[str, str] | None = None,
379
498
  series_styles: dict[str, dict[str, Any]] | None = None,
380
499
  annotate_color: str | None = None,
500
+ weight: str | None = None,
501
+ fontsize: float | None = None,
502
+ right_pad_px: float = 10.0,
381
503
  ) -> None:
382
504
  """Label the last non-NaN value of each column on the right.
383
505
 
@@ -392,8 +514,12 @@ def annotate_last_values(
392
514
 
393
515
  Labels are packed vertically in display space so that
394
516
  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.
517
+ They sit just right of the last point and the x-axis
518
+ limit is extended by the widest label plus ``right_pad_px``
519
+ so the text is not clipped by the axes frame; raise
520
+ ``right_pad_px`` (e.g. on compact charts) to open more
521
+ blank space on the right and keep the labels clear of both
522
+ the data lines and the right-hand y-tick labels.
397
523
  """
398
524
  fig = ax.get_figure()
399
525
  if fig is None:
@@ -428,7 +554,7 @@ def annotate_last_values(
428
554
  trans = ax.transData
429
555
 
430
556
  sample = ax.text( # type: ignore[reportUnknownMemberType]
431
- 0, 0, "0"
557
+ 0, 0, "0", fontsize=fontsize
432
558
  )
433
559
  text_height = (
434
560
  sample.get_window_extent(renderer).height # type: ignore[reportUnknownArgumentType]
@@ -459,13 +585,18 @@ def annotate_last_values(
459
585
  color=color,
460
586
  va="center",
461
587
  ha="left",
588
+ fontweight=weight if weight is not None else "normal",
589
+ fontsize=fontsize,
462
590
  )
463
591
  width = t.get_window_extent(renderer).width # type: ignore[reportUnknownArgumentType]
464
592
  max_label_width = max(max_label_width, width)
465
593
 
594
+ # labels spill right of the data, so widen the x-limit by the widest
595
+ # label plus right_pad_px to keep them unclipped (and, with a larger
596
+ # pad, clear of the right-hand y-tick labels).
466
597
  inv = trans.inverted()
467
598
  x0: float = inv.transform((0, 0))[0] # type: ignore[reportUnknownArgumentType]
468
- x1: float = inv.transform((max_label_width + 10, 0))[0] # type: ignore[reportUnknownArgumentType]
599
+ x1: float = inv.transform((max_label_width + right_pad_px, 0))[0] # type: ignore[reportUnknownArgumentType]
469
600
  xmin, xmax = ax.get_xlim()
470
601
  ax.set_xlim(xmin, xmax + (x1 - x0))
471
602
 
@@ -29,7 +29,12 @@ from tesorotools.artists._common import (
29
29
  adjust_figure_for_plot_size,
30
30
  annotate_last_values,
31
31
  auto_ncol,
32
+ compact_date_axis,
33
+ compact_figsize,
34
+ compact_font_pt,
35
+ declutter_spines,
32
36
  draw_vlines,
37
+ legend_above,
33
38
  style_baseline,
34
39
  style_spines,
35
40
  )
@@ -60,6 +65,12 @@ class LinePlot:
60
65
  (color, linestyle, ...).
61
66
  * ``baseline`` -- draw an axhline at 0 (or 100 when
62
67
  ``base_100``).
68
+ * ``compact`` -- render at a fixed physical size (7.4 cm
69
+ wide *including* the legend, from ``plots.yaml``) for
70
+ placing two charts per row inside a Word table. Moves
71
+ the legend above the plot expanded to its width, uses a
72
+ ``ConciseDateFormatter`` date axis and bold end-of-series
73
+ labels.
63
74
 
64
75
  Render lifecycle: ``build()`` returns ``(Figure, Axes)``
65
76
  in memory; ``save(fig)`` persists to ``out_path``;
@@ -87,6 +98,7 @@ class LinePlot:
87
98
  series_styles: dict[str, dict[str, Any]] | None = None,
88
99
  plot_size: tuple[float, float] | None = None,
89
100
  vlines: list[dict[str, Any]] | None = None,
101
+ compact: bool = False,
90
102
  ) -> None:
91
103
  if out_path.suffix != ".png":
92
104
  raise ValueError(f"out_path must be .png: {out_path}")
@@ -108,6 +120,7 @@ class LinePlot:
108
120
  self.series_styles = series_styles or {}
109
121
  self.plot_size = plot_size
110
122
  self.vlines = vlines or []
123
+ self.compact = compact
111
124
 
112
125
  @classmethod
113
126
  def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
@@ -130,7 +143,7 @@ class LinePlot:
130
143
  ``base_100_date``, ``annotate``, ``annotate_color``,
131
144
  ``baseline``, ``fmt`` (``!format``), ``legend``
132
145
  (``!legend``), ``figsize``, ``series_styles``,
133
- ``plot_size``, ``vlines``.
146
+ ``plot_size``, ``vlines``, ``compact``.
134
147
 
135
148
  Example
136
149
  -------
@@ -199,13 +212,30 @@ class LinePlot:
199
212
  fig_kw = dict(FIG_CONFIG)
200
213
  if self.figsize is not None:
201
214
  fig_kw["figsize"] = self.figsize
215
+ elif self.compact:
216
+ fig_kw["figsize"] = compact_figsize()
202
217
  fig: Figure = plt.figure( # type: ignore[reportUnknownMemberType]
203
218
  **fig_kw
204
219
  )
220
+ font_pt = compact_font_pt() if self.compact else None
221
+
205
222
  ax = fig.add_subplot()
206
223
  for col in plot_data.columns:
207
224
  style = self.series_styles.get(col, {})
208
- plot_data[col].plot(ax=ax, label=self.series[col], **style)
225
+ # compact mode needs matplotlib's native date axis so the
226
+ # ConciseDateFormatter takes effect (x_compat disables pandas'
227
+ # own date plotting machinery).
228
+ plot_data[col].plot(
229
+ ax=ax,
230
+ label=self.series[col],
231
+ x_compat=self.compact,
232
+ **style,
233
+ )
234
+
235
+ if self.compact:
236
+ # no left/right padding: the line starts exactly at the first
237
+ # data point. annotate_last_values reopens room on the right.
238
+ ax.margins(x=0)
209
239
 
210
240
  if self.vlines:
211
241
  draw_vlines(ax, self.vlines)
@@ -219,6 +249,18 @@ class LinePlot:
219
249
  labels=self.series,
220
250
  series_styles=self.series_styles,
221
251
  annotate_color=self.annotate_color,
252
+ weight="bold" if self.compact else None,
253
+ fontsize=font_pt,
254
+ # blank space (dynamic, scales with font + dpi) opened past
255
+ # the label. compact has the y-axis on the LEFT, so half an em
256
+ # of clearance is enough; normal keeps the y-axis on the RIGHT,
257
+ # so leave a wider gap to separate the value labels from the
258
+ # y-tick numbers they would otherwise butt against.
259
+ right_pad_px=(
260
+ 0.5 * (font_pt or 12.0) * fig.dpi / 72.0
261
+ if self.compact
262
+ else 1.6 * plt.rcParams["font.size"] * fig.dpi / 72.0
263
+ ),
222
264
  )
223
265
 
224
266
  style_spines(
@@ -231,23 +273,54 @@ class LinePlot:
231
273
  reference = 100 if self.base_100 else 0
232
274
  style_baseline(ax, reference, **AX_CONFIG["baseline"])
233
275
 
276
+ if self.compact:
277
+ # pin the date ticks to the data span (after annotate widened the
278
+ # x-limit) so the last tick sits next to the final data point and
279
+ # none float in the value-label margin; match Word's body font.
280
+ compact_date_axis(ax, plot_data.index.min(), plot_data.index.max())
281
+ # compact only: y-numbers on the LEFT so the right edge is free
282
+ # for the value labels, and drop the now-tickless top/right
283
+ # spines. Normal mode keeps the house right-hand y-axis and box.
284
+ ax.yaxis.tick_left()
285
+ declutter_spines(ax)
286
+ if font_pt is not None:
287
+ ax.tick_params( # type: ignore[reportUnknownMemberType]
288
+ axis="both", labelsize=font_pt
289
+ )
290
+
234
291
  if self.legend is not None:
235
292
  labels = [self.series[c] for c in plot_data.columns]
236
293
  handles, label_strs = ax.get_legend_handles_labels()
237
- fig_width_px: float = fig.get_size_inches()[0] * fig.dpi
238
- ncol = (
239
- self.legend.ncol
240
- if self.legend.ncol is not None
241
- else auto_ncol(ax, labels, available_width_px=fig_width_px)
242
- )
243
- fig.legend( # type: ignore[reportUnknownMemberType]
244
- handles,
245
- label_strs,
246
- loc="outside lower center",
247
- ncol=ncol,
248
- )
294
+ if self.compact:
295
+ # legend ABOVE the plot, expanded to the plot width. ncol is
296
+ # auto-fitted to the (narrow) axes width so labels never
297
+ # overlap; long labels stack into rows. Override the column
298
+ # count via !legend {ncol: N} when you know they fit.
299
+ ncol = (
300
+ self.legend.ncol
301
+ if self.legend.ncol is not None
302
+ else auto_ncol(ax, labels)
303
+ )
304
+ legend_above(
305
+ ax, handles, label_strs, ncol=ncol, fontsize=font_pt
306
+ )
307
+ else:
308
+ fig_width_px: float = fig.get_size_inches()[0] * fig.dpi
309
+ ncol = (
310
+ self.legend.ncol
311
+ if self.legend.ncol is not None
312
+ else auto_ncol(ax, labels, available_width_px=fig_width_px)
313
+ )
314
+ fig.legend( # type: ignore[reportUnknownMemberType]
315
+ handles,
316
+ label_strs,
317
+ loc="outside lower center",
318
+ ncol=ncol,
319
+ )
249
320
 
250
- if self.plot_size is not None:
321
+ # compact mode fixes the *figure* size (width includes the legend),
322
+ # so the axes-sizing pass would fight it; skip it there.
323
+ if self.plot_size is not None and not self.compact:
251
324
  adjust_figure_for_plot_size(fig, ax, self.plot_size)
252
325
 
253
326
  return fig, ax