plotlet 0.4.0__tar.gz → 0.5.0__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 (141) hide show
  1. plotlet-0.5.0/PKG-INFO +82 -0
  2. plotlet-0.5.0/README.md +50 -0
  3. {plotlet-0.4.0 → plotlet-0.5.0}/pyproject.toml +9 -3
  4. {plotlet-0.4.0 → plotlet-0.5.0}/src/plotlet/__init__.py +17 -8
  5. plotlet-0.5.0/src/plotlet/_attachments.py +237 -0
  6. plotlet-0.5.0/src/plotlet/_datasets/penguins.csv +345 -0
  7. plotlet-0.4.0/src/plotlet/layout.py → plotlet-0.5.0/src/plotlet/_layout_engine.py +207 -194
  8. plotlet-0.5.0/src/plotlet/_spec.py +105 -0
  9. plotlet-0.5.0/src/plotlet/_splits.py +190 -0
  10. plotlet-0.5.0/src/plotlet/artists/__init__.py +52 -0
  11. plotlet-0.5.0/src/plotlet/artists/_shared.py +191 -0
  12. plotlet-0.5.0/src/plotlet/artists/bar.py +243 -0
  13. plotlet-0.5.0/src/plotlet/artists/boxplot.py +254 -0
  14. plotlet-0.5.0/src/plotlet/artists/contour.py +129 -0
  15. plotlet-0.5.0/src/plotlet/artists/dendrogram.py +230 -0
  16. plotlet-0.5.0/src/plotlet/artists/density_1d.py +139 -0
  17. plotlet-0.5.0/src/plotlet/artists/ecdf.py +118 -0
  18. plotlet-0.5.0/src/plotlet/artists/errorbar.py +183 -0
  19. plotlet-0.5.0/src/plotlet/artists/fills.py +235 -0
  20. plotlet-0.5.0/src/plotlet/artists/freqpoly.py +147 -0
  21. plotlet-0.5.0/src/plotlet/artists/heatmap.py +436 -0
  22. plotlet-0.5.0/src/plotlet/artists/hexbin.py +114 -0
  23. plotlet-0.5.0/src/plotlet/artists/hist.py +193 -0
  24. plotlet-0.5.0/src/plotlet/artists/imshow.py +239 -0
  25. plotlet-0.5.0/src/plotlet/artists/kde_2d.py +145 -0
  26. plotlet-0.5.0/src/plotlet/artists/line.py +152 -0
  27. plotlet-0.5.0/src/plotlet/artists/pointplot.py +150 -0
  28. plotlet-0.5.0/src/plotlet/artists/qq.py +83 -0
  29. plotlet-0.5.0/src/plotlet/artists/references.py +224 -0
  30. plotlet-0.5.0/src/plotlet/artists/regression.py +179 -0
  31. plotlet-0.5.0/src/plotlet/artists/ridge.py +98 -0
  32. plotlet-0.5.0/src/plotlet/artists/rug.py +108 -0
  33. plotlet-0.5.0/src/plotlet/artists/scatter.py +279 -0
  34. plotlet-0.5.0/src/plotlet/artists/shapes.py +126 -0
  35. plotlet-0.5.0/src/plotlet/artists/strip.py +168 -0
  36. plotlet-0.5.0/src/plotlet/artists/swarm.py +174 -0
  37. plotlet-0.5.0/src/plotlet/artists/text.py +267 -0
  38. plotlet-0.5.0/src/plotlet/artists/violin.py +226 -0
  39. plotlet-0.5.0/src/plotlet/chart.py +1060 -0
  40. plotlet-0.5.0/src/plotlet/cluster.py +461 -0
  41. plotlet-0.5.0/src/plotlet/core.py +1973 -0
  42. plotlet-0.5.0/src/plotlet/data.py +83 -0
  43. plotlet-0.5.0/src/plotlet/draw/__init__.py +33 -0
  44. {plotlet-0.4.0/src/plotlet → plotlet-0.5.0/src/plotlet/draw}/colormaps.py +2 -2
  45. plotlet-0.5.0/src/plotlet/draw/colors.py +41 -0
  46. plotlet-0.5.0/src/plotlet/draw/font.py +117 -0
  47. plotlet-0.5.0/src/plotlet/draw/linestyles.py +27 -0
  48. plotlet-0.5.0/src/plotlet/draw/primitives.py +509 -0
  49. plotlet-0.5.0/src/plotlet/extensions/__init__.py +22 -0
  50. plotlet-0.5.0/src/plotlet/extensions/_gallery.py +205 -0
  51. plotlet-0.5.0/src/plotlet/extensions/alluvial.py +202 -0
  52. plotlet-0.5.0/src/plotlet/extensions/annotation_strip.py +305 -0
  53. plotlet-0.5.0/src/plotlet/extensions/bland_altman.py +93 -0
  54. plotlet-0.5.0/src/plotlet/extensions/boxenplot.py +251 -0
  55. plotlet-0.5.0/src/plotlet/extensions/bubble_grid.py +106 -0
  56. plotlet-0.5.0/src/plotlet/extensions/bump.py +116 -0
  57. plotlet-0.5.0/src/plotlet/extensions/calendar_heatmap.py +130 -0
  58. plotlet-0.5.0/src/plotlet/extensions/calibration_plot.py +135 -0
  59. plotlet-0.5.0/src/plotlet/extensions/cleveland_dot.py +96 -0
  60. plotlet-0.5.0/src/plotlet/extensions/confusion_matrix.py +149 -0
  61. plotlet-0.5.0/src/plotlet/extensions/crossbar.py +98 -0
  62. plotlet-0.5.0/src/plotlet/extensions/curved_tree.py +227 -0
  63. plotlet-0.5.0/src/plotlet/extensions/diverging_bar.py +99 -0
  64. plotlet-0.5.0/src/plotlet/extensions/dumbbell.py +90 -0
  65. plotlet-0.5.0/src/plotlet/extensions/eventplot.py +114 -0
  66. plotlet-0.5.0/src/plotlet/extensions/forest_plot.py +127 -0
  67. plotlet-0.5.0/src/plotlet/extensions/funnel_plot.py +126 -0
  68. plotlet-0.5.0/src/plotlet/extensions/gene_arrow.py +109 -0
  69. plotlet-0.5.0/src/plotlet/extensions/horizon.py +132 -0
  70. plotlet-0.5.0/src/plotlet/extensions/horizontal_bar.py +80 -0
  71. plotlet-0.5.0/src/plotlet/extensions/jointplot.py +90 -0
  72. plotlet-0.5.0/src/plotlet/extensions/km_curve.py +206 -0
  73. plotlet-0.5.0/src/plotlet/extensions/labels_strip.py +246 -0
  74. plotlet-0.5.0/src/plotlet/extensions/loess.py +95 -0
  75. plotlet-0.5.0/src/plotlet/extensions/lollipop.py +95 -0
  76. plotlet-0.5.0/src/plotlet/extensions/ma_plot.py +113 -0
  77. plotlet-0.5.0/src/plotlet/extensions/manhattan.py +133 -0
  78. plotlet-0.5.0/src/plotlet/extensions/mosaic.py +143 -0
  79. plotlet-0.5.0/src/plotlet/extensions/numeric_bar.py +98 -0
  80. plotlet-0.5.0/src/plotlet/extensions/pair_plot.py +90 -0
  81. plotlet-0.5.0/src/plotlet/extensions/parallel_coordinates.py +126 -0
  82. plotlet-0.5.0/src/plotlet/extensions/pca_biplot.py +113 -0
  83. plotlet-0.5.0/src/plotlet/extensions/percentile_band.py +108 -0
  84. plotlet-0.5.0/src/plotlet/extensions/pr_curve.py +126 -0
  85. plotlet-0.5.0/src/plotlet/extensions/pyramid_plot.py +97 -0
  86. plotlet-0.5.0/src/plotlet/extensions/raincloud.py +309 -0
  87. plotlet-0.5.0/src/plotlet/extensions/residual_diagnostics.py +112 -0
  88. plotlet-0.5.0/src/plotlet/extensions/roc_curve.py +123 -0
  89. plotlet-0.5.0/src/plotlet/extensions/sales_funnel.py +86 -0
  90. plotlet-0.5.0/src/plotlet/extensions/sankey.py +257 -0
  91. plotlet-0.5.0/src/plotlet/extensions/significance_brackets.py +132 -0
  92. plotlet-0.5.0/src/plotlet/extensions/slope_chart.py +88 -0
  93. plotlet-0.5.0/src/plotlet/extensions/split_violin.py +177 -0
  94. plotlet-0.5.0/src/plotlet/extensions/text_label.py +78 -0
  95. plotlet-0.5.0/src/plotlet/extensions/upset_plot.py +152 -0
  96. plotlet-0.5.0/src/plotlet/extensions/volcano.py +133 -0
  97. plotlet-0.5.0/src/plotlet/extensions/waterfall.py +131 -0
  98. plotlet-0.5.0/src/plotlet/facet.py +163 -0
  99. {plotlet-0.4.0 → plotlet-0.5.0}/src/plotlet/legend.py +111 -64
  100. {plotlet-0.4.0 → plotlet-0.5.0}/src/plotlet/registry.py +18 -1
  101. plotlet-0.5.0/src/plotlet/scales.py +422 -0
  102. plotlet-0.5.0/src/plotlet/spec.json +109 -0
  103. plotlet-0.5.0/src/plotlet/themes/dark.json +24 -0
  104. plotlet-0.5.0/src/plotlet/themes/minimal.json +26 -0
  105. plotlet-0.5.0/src/plotlet/themes/void.json +15 -0
  106. plotlet-0.5.0/src/plotlet/themes.py +99 -0
  107. plotlet-0.5.0/src/plotlet/utils.py +319 -0
  108. plotlet-0.5.0/src/plotlet.egg-info/PKG-INFO +82 -0
  109. plotlet-0.5.0/src/plotlet.egg-info/SOURCES.txt +124 -0
  110. {plotlet-0.4.0 → plotlet-0.5.0}/src/plotlet.egg-info/requires.txt +3 -0
  111. plotlet-0.5.0/tests/test_attachments.py +193 -0
  112. plotlet-0.5.0/tests/test_chart.py +1844 -0
  113. plotlet-0.5.0/tests/test_extensions.py +74 -0
  114. {plotlet-0.4.0 → plotlet-0.5.0}/tests/test_layout_diagram.py +6 -4
  115. {plotlet-0.4.0 → plotlet-0.5.0}/tests/test_legend.py +11 -9
  116. {plotlet-0.4.0 → plotlet-0.5.0}/tests/test_subplots.py +85 -43
  117. plotlet-0.5.0/tests/test_themes.py +88 -0
  118. {plotlet-0.4.0 → plotlet-0.5.0}/tests/test_units.py +16 -13
  119. plotlet-0.4.0/PKG-INFO +0 -176
  120. plotlet-0.4.0/README.md +0 -146
  121. plotlet-0.4.0/src/plotlet/_spec.py +0 -23
  122. plotlet-0.4.0/src/plotlet/artists.py +0 -503
  123. plotlet-0.4.0/src/plotlet/builtin_artists.py +0 -570
  124. plotlet-0.4.0/src/plotlet/chart.py +0 -718
  125. plotlet-0.4.0/src/plotlet/colors.py +0 -20
  126. plotlet-0.4.0/src/plotlet/core.py +0 -1055
  127. plotlet-0.4.0/src/plotlet/dendrogram.py +0 -197
  128. plotlet-0.4.0/src/plotlet/font.py +0 -63
  129. plotlet-0.4.0/src/plotlet/scales.py +0 -123
  130. plotlet-0.4.0/src/plotlet/spec.json +0 -100
  131. plotlet-0.4.0/src/plotlet.egg-info/PKG-INFO +0 -176
  132. plotlet-0.4.0/src/plotlet.egg-info/SOURCES.txt +0 -32
  133. plotlet-0.4.0/tests/test_chart.py +0 -564
  134. {plotlet-0.4.0 → plotlet-0.5.0}/LICENSE +0 -0
  135. {plotlet-0.4.0 → plotlet-0.5.0}/setup.cfg +0 -0
  136. {plotlet-0.4.0/src/plotlet → plotlet-0.5.0/src/plotlet/draw}/_cm_data.py +0 -0
  137. {plotlet-0.4.0/src/plotlet → plotlet-0.5.0/src/plotlet/draw}/_png.py +0 -0
  138. {plotlet-0.4.0/src/plotlet → plotlet-0.5.0/src/plotlet/draw}/fonts/DejaVuSans.ttf +0 -0
  139. {plotlet-0.4.0 → plotlet-0.5.0}/src/plotlet/layout_diagram.py +0 -0
  140. {plotlet-0.4.0 → plotlet-0.5.0}/src/plotlet.egg-info/dependency_links.txt +0 -0
  141. {plotlet-0.4.0 → plotlet-0.5.0}/src/plotlet.egg-info/top_level.txt +0 -0
plotlet-0.5.0/PKG-INFO ADDED
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: plotlet
3
+ Version: 0.5.0
4
+ Summary: Python library for SVG plots, with multi-panel composition and an extension API for custom plot types.
5
+ Author: gitbamboo42
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/gitbamboo42/plotlet
8
+ Project-URL: Repository, https://github.com/gitbamboo42/plotlet
9
+ Project-URL: Issues, https://github.com/gitbamboo42/plotlet/issues
10
+ Keywords: plot,svg,scientific,visualization,jupyter,reproducible
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering :: Visualization
20
+ Classifier: Topic :: Multimedia :: Graphics
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: fonttools>=4.0
25
+ Requires-Dist: scipy>=1.10
26
+ Provides-Extra: dev
27
+ Requires-Dist: jupyter; extra == "dev"
28
+ Requires-Dist: nbconvert; extra == "dev"
29
+ Provides-Extra: test
30
+ Requires-Dist: pytest>=7; extra == "test"
31
+ Dynamic: license-file
32
+
33
+ # plotlet
34
+
35
+ plotlet is a Python library for SVG plots. It provides reproducible, byte-identical multi-panel scientific figures with a standard plotting vocabulary and a first-class extension story for custom plot types.
36
+
37
+ ## Documentation
38
+
39
+ Hands-on tour with executable examples:
40
+
41
+ - [notebooks/00_introduction.ipynb](notebooks/00_introduction.ipynb) — long-form data, aesthetic inheritance, layering, composition
42
+
43
+ Reference docs in [`docs/`](docs/):
44
+
45
+ - [API reference](docs/API.md) — mark methods, frame options, scales, tick overrides
46
+ - [Philosophy](docs/PHILOSOPHY.md) — what's in core, what's not, and why
47
+ - [Subplots](docs/SUBPLOTS.md) — multi-panel composition, shared scales
48
+ - [Extending](docs/EXTENDING.md) — write your own plot type
49
+ - [Themes](docs/THEMES.md) — visual presets
50
+ - [AI attributes](docs/AI_ATTRS.md) — `data-plotlet-*` schema for automation
51
+
52
+ Reference plot types beyond the standard vocabulary live in [`src/plotlet/extensions/`](src/plotlet/extensions/) (single-file) and [`cookbook/`](cookbook/) (multi-file projects).
53
+
54
+ ## Dependencies
55
+
56
+ plotlet supports Python 3.10+.
57
+
58
+ Required: `fonttools`, `scipy` (used by regression, qq, pointplot, dendrogram). numpy / pandas / polars inputs work transparently.
59
+
60
+ Optional: `cairosvg` for PNG / PDF export.
61
+
62
+ ## Installation
63
+
64
+ ```bash
65
+ pip install plotlet
66
+ ```
67
+
68
+ ## Testing
69
+
70
+ ```bash
71
+ pip install -e ".[test]"
72
+ pytest tests/ # check vs. committed baselines
73
+ pytest tests/ --update # regenerate (review the diff!)
74
+ ```
75
+
76
+ ## Development
77
+
78
+ Development takes place on GitHub. Please submit bugs to the issue tracker with a reproducible example.
79
+
80
+ ## License
81
+
82
+ MIT
@@ -0,0 +1,50 @@
1
+ # plotlet
2
+
3
+ plotlet is a Python library for SVG plots. It provides reproducible, byte-identical multi-panel scientific figures with a standard plotting vocabulary and a first-class extension story for custom plot types.
4
+
5
+ ## Documentation
6
+
7
+ Hands-on tour with executable examples:
8
+
9
+ - [notebooks/00_introduction.ipynb](notebooks/00_introduction.ipynb) — long-form data, aesthetic inheritance, layering, composition
10
+
11
+ Reference docs in [`docs/`](docs/):
12
+
13
+ - [API reference](docs/API.md) — mark methods, frame options, scales, tick overrides
14
+ - [Philosophy](docs/PHILOSOPHY.md) — what's in core, what's not, and why
15
+ - [Subplots](docs/SUBPLOTS.md) — multi-panel composition, shared scales
16
+ - [Extending](docs/EXTENDING.md) — write your own plot type
17
+ - [Themes](docs/THEMES.md) — visual presets
18
+ - [AI attributes](docs/AI_ATTRS.md) — `data-plotlet-*` schema for automation
19
+
20
+ Reference plot types beyond the standard vocabulary live in [`src/plotlet/extensions/`](src/plotlet/extensions/) (single-file) and [`cookbook/`](cookbook/) (multi-file projects).
21
+
22
+ ## Dependencies
23
+
24
+ plotlet supports Python 3.10+.
25
+
26
+ Required: `fonttools`, `scipy` (used by regression, qq, pointplot, dendrogram). numpy / pandas / polars inputs work transparently.
27
+
28
+ Optional: `cairosvg` for PNG / PDF export.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install plotlet
34
+ ```
35
+
36
+ ## Testing
37
+
38
+ ```bash
39
+ pip install -e ".[test]"
40
+ pytest tests/ # check vs. committed baselines
41
+ pytest tests/ --update # regenerate (review the diff!)
42
+ ```
43
+
44
+ ## Development
45
+
46
+ Development takes place on GitHub. Please submit bugs to the issue tracker with a reproducible example.
47
+
48
+ ## License
49
+
50
+ MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "plotlet"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Python library for SVG plots, with multi-panel composition and an extension API for custom plot types."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -16,7 +16,6 @@ authors = [
16
16
  keywords = [
17
17
  "plot",
18
18
  "svg",
19
- "matplotlib",
20
19
  "scientific",
21
20
  "visualization",
22
21
  "jupyter",
@@ -44,6 +43,13 @@ dev = [
44
43
  "jupyter",
45
44
  "nbconvert",
46
45
  ]
46
+ test = [
47
+ "pytest>=7",
48
+ ]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
52
+ addopts = "-ra"
47
53
 
48
54
  [project.urls]
49
55
  Homepage = "https://github.com/gitbamboo42/plotlet"
@@ -54,4 +60,4 @@ Issues = "https://github.com/gitbamboo42/plotlet/issues"
54
60
  where = ["src"]
55
61
 
56
62
  [tool.setuptools.package-data]
57
- plotlet = ["spec.json", "fonts/*.ttf"]
63
+ plotlet = ["spec.json", "themes/*.json", "draw/fonts/*.ttf", "_datasets/*.csv"]
@@ -1,8 +1,8 @@
1
- """plotlet — pure-Python SVG renderer, matplotlib-flavored.
1
+ """plotlet — pure-Python deferred-rendering SVG plot library.
2
2
 
3
3
  import plotlet as pt
4
4
  c = pt.chart(df, title="...", xlabel="x", ylabel="y", legend=True, grid=True)
5
- c.line(x="time", y="value", hue="series")
5
+ c.line(x="time", y="value", color="series")
6
6
  c # auto-renders in Jupyter
7
7
 
8
8
  For non-tabular use, the chained form works directly on the same Chart:
@@ -13,17 +13,26 @@ For non-tabular use, the chained form works directly on the same Chart:
13
13
  c
14
14
  """
15
15
  from ._spec import SPEC
16
- from .colors import TAB10, colors
17
- from .colormaps import colormap, list_colormaps
18
- from .chart import Chart, chart
19
- from .layout import grid
16
+ from .draw import TAB10, colors
17
+ from .draw import colormap, list_colormaps
18
+ from .chart import Chart, Layout, chart, grid
20
19
  from .legend import legend
21
20
  from .registry import ArtistSpec, add_artist
22
21
  from .layout_diagram import layout_diagram
22
+ from .themes import load_theme, available_themes, register_theme
23
+ from .facet import facet, FacetGrid
24
+ from .data import load, list_datasets
25
+ from .cluster import cluster, cluster_split, SplitTree
26
+ from . import draw, utils
23
27
 
24
- __all__ = ["chart", "Chart", "SPEC", "TAB10", "colors",
28
+ __all__ = ["chart", "Chart", "Layout", "SPEC", "TAB10", "colors",
25
29
  "colormap", "list_colormaps", "grid", "legend",
26
- "ArtistSpec", "add_artist", "layout_diagram"]
30
+ "ArtistSpec", "add_artist", "layout_diagram",
31
+ "load_theme", "available_themes", "register_theme",
32
+ "facet", "FacetGrid",
33
+ "load", "list_datasets",
34
+ "cluster", "cluster_split", "SplitTree",
35
+ "draw", "utils"]
27
36
 
28
37
  # Single source of truth: pyproject.toml. importlib.metadata reads it at
29
38
  # runtime from the installed package metadata (works for `pip install` and
@@ -0,0 +1,237 @@
1
+ """Helpers for chart-level attachments — sub-charts placed in the host
2
+ chart's margin space, like extended axis decorations.
3
+
4
+ The public API lives on `Chart`: `attach_left/right/above/below`. This
5
+ module owns the layout-time concerns those methods imply:
6
+
7
+ * **Size discovery** — how much extra horizontal/vertical space the
8
+ figure needs beyond the host's own canvas to fit the attachments.
9
+ * **Allocation** — positioning each attachment so its data area lines
10
+ up with the host's data area on the shared axis.
11
+ * **Joined-pair hiding** — suppressing duplicated tick / axis labels
12
+ on each host-attachment seam (and along chained attachments).
13
+
14
+ The render engine in `_layout_engine.py` calls into here from a few
15
+ small hook points (`_iter_leaves`, `_measure`, `_allocate`, the
16
+ panel-opts pre-pass). Engine internals that this module needs
17
+ (`_allocate`, `_mark_joined_pair`) are lazy-imported inside the
18
+ functions to keep the import graph one-way: engine imports
19
+ attachments, attachments imports engine only at call time.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from .chart import Chart
24
+ from .core import _PanelOpts
25
+
26
+
27
+ _DEFAULT_M = {"left": 0, "right": 0, "top": 0, "bottom": 0}
28
+
29
+
30
+ def _M_eff(leaf: Chart) -> dict:
31
+ """Per-side margin for a leaf, defaulting to zeros before the
32
+ pre-pass has run. Returns a fresh dict-shaped value so callers can
33
+ treat the four sides uniformly."""
34
+ return leaf._last_M_eff or _DEFAULT_M
35
+
36
+
37
+ def has_attachments(chart: Chart) -> bool:
38
+ """True if `chart` is a host with any side attachments."""
39
+ return bool(chart._attached_left or chart._attached_right
40
+ or chart._attached_above or chart._attached_below)
41
+
42
+
43
+ def all_attachments(chart: Chart) -> list[Chart]:
44
+ return (chart._attached_left + chart._attached_right
45
+ + chart._attached_above + chart._attached_below)
46
+
47
+
48
+ def _gap(c: Chart) -> float:
49
+ """Per-attachment gap to inward neighbor (host or previous attachment).
50
+ Returns 0 for unattached charts (defensive — placement only iterates
51
+ over already-attached lists)."""
52
+ return getattr(c, "_attachment_gap", 0.0)
53
+
54
+
55
+ def attached_size_h(host: Chart) -> tuple[float, float]:
56
+ """Extra horizontal space the figure needs beyond `host`'s own canvas
57
+ to fit left/right attachments AND any perpendicular-margin overflow
58
+ from above/below attachments. Left/right attachments occupy the
59
+ host's collapsed left/right margin first (their innermost right edge
60
+ sits at the host's data-area left edge minus its `gap`), so they
61
+ only grow the figure once cumulative width-plus-gaps exceeds it.
62
+ Above/below attachments keep their own left/right margins; if a top
63
+ track's left margin exceeds the host's, its labels need extra figure
64
+ space unless a left attachment already provides it.
65
+ Returns (left_extra, right_extra)."""
66
+ M = _M_eff(host)
67
+ sum_left = sum(c._canvas_width + _gap(c) for c in host._attached_left)
68
+ sum_right = sum(c._canvas_width + _gap(c) for c in host._attached_right)
69
+ stack = host._attached_above + host._attached_below
70
+ max_left_overflow = max((_M_eff(c)["left"] - M["left"]
71
+ for c in stack), default=0.0)
72
+ max_right_overflow = max((_M_eff(c)["right"] - M["right"]
73
+ for c in stack), default=0.0)
74
+ return (max(sum_left - M["left"], max_left_overflow, 0.0),
75
+ max(sum_right - M["right"], max_right_overflow, 0.0))
76
+
77
+
78
+ def attached_size_v(host: Chart) -> tuple[float, float]:
79
+ """Extra vertical space beyond `host`'s canvas needed for above/below
80
+ attachments AND for left/right attachments whose own top/bottom
81
+ margins overflow the host's. Returns (above_extra, below_extra)."""
82
+ M = _M_eff(host)
83
+ sum_above = sum(c._canvas_height + _gap(c) for c in host._attached_above)
84
+ sum_below = sum(c._canvas_height + _gap(c) for c in host._attached_below)
85
+ side = host._attached_left + host._attached_right
86
+ max_top_overflow = max((_M_eff(c)["top"] - M["top"]
87
+ for c in side), default=0.0)
88
+ max_bottom_overflow = max((_M_eff(c)["bottom"] - M["bottom"]
89
+ for c in side), default=0.0)
90
+ return (max(sum_above - M["top"], max_top_overflow, 0.0),
91
+ max(sum_below - M["bottom"], max_bottom_overflow, 0.0))
92
+
93
+
94
+ def allocate(host: Chart, host_x: float, host_y: float,
95
+ host_w: float, host_h: float, out: list) -> None:
96
+ """Place each attachment so its DATA area aligns with the host's data
97
+ area on the shared axis; the attachment's own margins are independent
98
+ and do not push the host's margins out. Canvases may extend beyond
99
+ the host's canvas on the perpendicular axis — those overflows visually
100
+ sit in the left/right attachment columns (above/below) or top/bottom
101
+ rows (left/right), at a different position on the other axis, so they
102
+ don't visually collide with sibling attachments' content."""
103
+ from ._layout_engine import _allocate
104
+
105
+ host_M = _M_eff(host)
106
+ host_data_x = host_x + host_M["left"]
107
+ host_data_y = host_y + host_M["top"]
108
+
109
+ # Each side places the attachment so its DATA area edge is flush against
110
+ # the host's data area edge. The attachment's inner-facing margin floor
111
+ # (cM["right"] for left, cM["bottom"] for above, etc.) overlaps into the
112
+ # host's collapsed inner margin floor — both are blank floor regions,
113
+ # so no visible collision; this matches the symmetric right/below path.
114
+
115
+ # Each side advances a cursor inward-to-outward. The cursor holds the
116
+ # next "data edge to align to" — initially the host's data edge, then
117
+ # each placed attachment's outward data edge. Per-attachment `gap` is
118
+ # applied by stepping the cursor outward by `gap` BEFORE placing
119
+ # (zero gap → flush join; positive gap → visible separation).
120
+
121
+ # Left: walk outward (decreasing x). Data y/h locks to host (share_y);
122
+ # canvas y is offset so the attachment's data area starts at host_data_y.
123
+ cx_right = host_x + host_M["left"]
124
+ for c in host._attached_left:
125
+ cx_right -= _gap(c)
126
+ cM = _M_eff(c)
127
+ cw = c._data_width + cM["left"] + cM["right"]
128
+ ch = c._data_height + cM["top"] + cM["bottom"]
129
+ c_canvas_x = cx_right - cM["left"] - c._data_width
130
+ c_canvas_y = host_data_y - cM["top"]
131
+ _allocate(c, c_canvas_x, c_canvas_y, cw, ch, out)
132
+ cx_right = c_canvas_x + cM["left"] # c's data-left becomes next reference
133
+ # Right: walk outward (increasing x).
134
+ cx_left = host_x + host_w - host_M["right"]
135
+ for c in host._attached_right:
136
+ cx_left += _gap(c)
137
+ cM = _M_eff(c)
138
+ cw = c._data_width + cM["left"] + cM["right"]
139
+ ch = c._data_height + cM["top"] + cM["bottom"]
140
+ c_canvas_x = cx_left - cM["left"]
141
+ c_canvas_y = host_data_y - cM["top"]
142
+ _allocate(c, c_canvas_x, c_canvas_y, cw, ch, out)
143
+ cx_left = c_canvas_x + cM["left"] + c._data_width # c's data-right
144
+ # Above: walk outward (decreasing y). Data x/w locks to host (share_x).
145
+ cy_bottom = host_y + host_M["top"]
146
+ for c in host._attached_above:
147
+ cy_bottom -= _gap(c)
148
+ cM = _M_eff(c)
149
+ cw = c._data_width + cM["left"] + cM["right"]
150
+ ch = c._data_height + cM["top"] + cM["bottom"]
151
+ c_canvas_x = host_data_x - cM["left"]
152
+ c_canvas_y = cy_bottom - cM["top"] - c._data_height
153
+ _allocate(c, c_canvas_x, c_canvas_y, cw, ch, out)
154
+ cy_bottom = c_canvas_y + cM["top"] # c's data-top
155
+ # Below: walk outward (increasing y).
156
+ cy_top = host_y + host_h - host_M["bottom"]
157
+ for c in host._attached_below:
158
+ cy_top += _gap(c)
159
+ cM = _M_eff(c)
160
+ cw = c._data_width + cM["left"] + cM["right"]
161
+ ch = c._data_height + cM["top"] + cM["bottom"]
162
+ c_canvas_x = host_data_x - cM["left"]
163
+ c_canvas_y = cy_top - cM["top"]
164
+ _allocate(c, c_canvas_x, c_canvas_y, cw, ch, out)
165
+ cy_top = c_canvas_y + cM["top"] + c._data_height # c's data-bottom
166
+
167
+
168
+ def annotate_joined_pairs(leaves: list[Chart],
169
+ panel_opts: dict[int, _PanelOpts]) -> None:
170
+ """For each host with attachments, mark the inner-facing edge of each
171
+ host-attachment pair (and each adjacent pair along a chain of
172
+ same-side attachments) as a joined-pair side — duplicated tick
173
+ labels and axis labels suppress on the host-facing edge. Reuses
174
+ `_layout_engine._mark_joined_pair`; the share auto-wired by
175
+ `attach_*` satisfies its share-equivalence precondition. The
176
+ per-leaf `_share_hide_labels_*` flag (set when an attachment opts
177
+ out via `hide_labels=False`) lets either side cancel the
178
+ suppression."""
179
+ from ._layout_engine import _mark_joined_pair
180
+
181
+ for host in leaves:
182
+ if not has_attachments(host):
183
+ continue
184
+ if host._attached_left:
185
+ _mark_joined_pair(host._attached_left[0], host,
186
+ axis="h", out=panel_opts)
187
+ if host._attached_right:
188
+ _mark_joined_pair(host, host._attached_right[0],
189
+ axis="h", out=panel_opts)
190
+ if host._attached_above:
191
+ _mark_joined_pair(host._attached_above[0], host,
192
+ axis="v", out=panel_opts)
193
+ if host._attached_below:
194
+ _mark_joined_pair(host, host._attached_below[0],
195
+ axis="v", out=panel_opts)
196
+ # Chained attachments on the same side: each adjacent pair is a
197
+ # joint. Index 0 is innermost; higher indices extend outward.
198
+ for chain, axis, outward in (
199
+ (host._attached_left, "h", "left"),
200
+ (host._attached_right, "h", "right"),
201
+ (host._attached_above, "v", "above"),
202
+ (host._attached_below, "v", "below"),
203
+ ):
204
+ for i in range(len(chain) - 1):
205
+ inner, outer = chain[i], chain[i + 1]
206
+ if outward in ("left", "above"):
207
+ _mark_joined_pair(outer, inner, axis=axis, out=panel_opts)
208
+ else:
209
+ _mark_joined_pair(inner, outer, axis=axis, out=panel_opts)
210
+
211
+
212
+ def promote_titles(leaves: list[Chart], states: dict[int, dict]) -> None:
213
+ """`c.title("...")` is a figure-title gesture: it should render above
214
+ everything stacked on top of `c`, not buried inside `c`'s own title
215
+ margin under the attached panels. When `c` has `_attached_above` and
216
+ sets a title, move the title's render state to the outermost
217
+ attached_above chart so the layout reserves margin in the right
218
+ panel. The host's own state has the title cleared so the renderer
219
+ doesn't draw it twice.
220
+
221
+ Only the top side is promoted — title is conventionally above the
222
+ data. xlabel/ylabel are not symmetric concepts (axis labels belong
223
+ to the axis they describe, not to the figure), so they stay put.
224
+ """
225
+ for leaf in leaves:
226
+ if not leaf._attached_above:
227
+ continue
228
+ host_st = states.get(id(leaf))
229
+ if host_st is None or not host_st.get("title"):
230
+ continue
231
+ # Index 0 = innermost, last = outermost — title goes to the very top.
232
+ outermost = leaf._attached_above[-1]
233
+ outer_st = states.get(id(outermost))
234
+ if outer_st is None:
235
+ continue
236
+ outer_st["title"] = host_st["title"]
237
+ host_st["title"] = ""