ggh4x-python 0.3.1.9000__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 (65) hide show
  1. ggh4x_python-0.3.1.9000/.gitignore +35 -0
  2. ggh4x_python-0.3.1.9000/LICENSE +3 -0
  3. ggh4x_python-0.3.1.9000/PKG-INFO +40 -0
  4. ggh4x_python-0.3.1.9000/README.md +11 -0
  5. ggh4x_python-0.3.1.9000/ggh4x/__init__.py +140 -0
  6. ggh4x_python-0.3.1.9000/ggh4x/_aimed_text_grob.py +432 -0
  7. ggh4x_python-0.3.1.9000/ggh4x/_borrowed_ggplot2.py +273 -0
  8. ggh4x_python-0.3.1.9000/ggh4x/_cli.py +84 -0
  9. ggh4x_python-0.3.1.9000/ggh4x/_datasets.py +106 -0
  10. ggh4x_python-0.3.1.9000/ggh4x/_download.py +111 -0
  11. ggh4x_python-0.3.1.9000/ggh4x/_facet_helpers.py +313 -0
  12. ggh4x_python-0.3.1.9000/ggh4x/_facet_utils.py +649 -0
  13. ggh4x_python-0.3.1.9000/ggh4x/_gap_grobs.py +606 -0
  14. ggh4x_python-0.3.1.9000/ggh4x/_registry.py +10 -0
  15. ggh4x_python-0.3.1.9000/ggh4x/_rlang.py +93 -0
  16. ggh4x_python-0.3.1.9000/ggh4x/_utils.py +150 -0
  17. ggh4x_python-0.3.1.9000/ggh4x/_vctrs.py +233 -0
  18. ggh4x_python-0.3.1.9000/ggh4x/conveniences.py +601 -0
  19. ggh4x_python-0.3.1.9000/ggh4x/coord_axes_inside.py +380 -0
  20. ggh4x_python-0.3.1.9000/ggh4x/element_part_rect.py +545 -0
  21. ggh4x_python-0.3.1.9000/ggh4x/facet_grid2.py +1018 -0
  22. ggh4x_python-0.3.1.9000/ggh4x/facet_manual.py +901 -0
  23. ggh4x_python-0.3.1.9000/ggh4x/facet_nested.py +776 -0
  24. ggh4x_python-0.3.1.9000/ggh4x/facet_nested_wrap.py +193 -0
  25. ggh4x_python-0.3.1.9000/ggh4x/facet_wrap2.py +896 -0
  26. ggh4x_python-0.3.1.9000/ggh4x/geom_box.py +536 -0
  27. ggh4x_python-0.3.1.9000/ggh4x/geom_outline_point.py +444 -0
  28. ggh4x_python-0.3.1.9000/ggh4x/geom_pointpath.py +259 -0
  29. ggh4x_python-0.3.1.9000/ggh4x/geom_polygonraster.py +252 -0
  30. ggh4x_python-0.3.1.9000/ggh4x/geom_rectrug.py +489 -0
  31. ggh4x_python-0.3.1.9000/ggh4x/geom_text_aimed.py +279 -0
  32. ggh4x_python-0.3.1.9000/ggh4x/guide_stringlegend.py +354 -0
  33. ggh4x_python-0.3.1.9000/ggh4x/help_secondary.py +549 -0
  34. ggh4x_python-0.3.1.9000/ggh4x/multiscale/__init__.py +51 -0
  35. ggh4x_python-0.3.1.9000/ggh4x/multiscale/_multiscale_add.py +207 -0
  36. ggh4x_python-0.3.1.9000/ggh4x/multiscale/scale_listed.py +167 -0
  37. ggh4x_python-0.3.1.9000/ggh4x/multiscale/scale_manual.py +478 -0
  38. ggh4x_python-0.3.1.9000/ggh4x/multiscale/scale_multi.py +393 -0
  39. ggh4x_python-0.3.1.9000/ggh4x/panel_scales/__init__.py +58 -0
  40. ggh4x_python-0.3.1.9000/ggh4x/panel_scales/at_panel.py +115 -0
  41. ggh4x_python-0.3.1.9000/ggh4x/panel_scales/facetted_pos_scales.py +647 -0
  42. ggh4x_python-0.3.1.9000/ggh4x/panel_scales/force_panelsize.py +411 -0
  43. ggh4x_python-0.3.1.9000/ggh4x/panel_scales/scale_facet.py +222 -0
  44. ggh4x_python-0.3.1.9000/ggh4x/position_disjoint_ranges.py +229 -0
  45. ggh4x_python-0.3.1.9000/ggh4x/position_lineartrans.py +242 -0
  46. ggh4x_python-0.3.1.9000/ggh4x/py.typed +0 -0
  47. ggh4x_python-0.3.1.9000/ggh4x/resources/faithful.csv +273 -0
  48. ggh4x_python-0.3.1.9000/ggh4x/resources/iris.csv +151 -0
  49. ggh4x_python-0.3.1.9000/ggh4x/resources/mtcars.csv +33 -0
  50. ggh4x_python-0.3.1.9000/ggh4x/resources/pressure.csv +20 -0
  51. ggh4x_python-0.3.1.9000/ggh4x/resources/volcano.csv +87 -0
  52. ggh4x_python-0.3.1.9000/ggh4x/save.py +255 -0
  53. ggh4x_python-0.3.1.9000/ggh4x/stat_difference.py +388 -0
  54. ggh4x_python-0.3.1.9000/ggh4x/stat_funxy.py +436 -0
  55. ggh4x_python-0.3.1.9000/ggh4x/stat_rle.py +290 -0
  56. ggh4x_python-0.3.1.9000/ggh4x/stat_rollingkernel.py +369 -0
  57. ggh4x_python-0.3.1.9000/ggh4x/stat_theodensity.py +681 -0
  58. ggh4x_python-0.3.1.9000/ggh4x/strip_nested.py +448 -0
  59. ggh4x_python-0.3.1.9000/ggh4x/strip_split.py +687 -0
  60. ggh4x_python-0.3.1.9000/ggh4x/strip_tag.py +636 -0
  61. ggh4x_python-0.3.1.9000/ggh4x/strip_themed.py +232 -0
  62. ggh4x_python-0.3.1.9000/ggh4x/strip_vanilla.py +1464 -0
  63. ggh4x_python-0.3.1.9000/ggh4x/themes.py +31 -0
  64. ggh4x_python-0.3.1.9000/ggh4x/themes_ggh4x.py +67 -0
  65. ggh4x_python-0.3.1.9000/pyproject.toml +39 -0
@@ -0,0 +1,35 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+
9
+ # Virtual environments
10
+ .venv/
11
+ .tox/
12
+
13
+ # Compiled extensions
14
+ *.so
15
+ *.pyd
16
+ *.dylib
17
+
18
+ # IDE
19
+ .idea/
20
+ .vscode/
21
+ *.swp
22
+
23
+ # OS
24
+ .DS_Store
25
+ Thumbs.db
26
+ .coverage
27
+
28
+ # Docs build
29
+ site/
30
+ .cache/
31
+ .ipynb_checkpoints/
32
+ validation/
33
+
34
+ # Unrelated R export script (kept on disk, not tracked)
35
+ scripts/
@@ -0,0 +1,3 @@
1
+ # MIT
2
+
3
+ See DESCRIPTION from R package.
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: ggh4x-python
3
+ Version: 0.3.1.9000
4
+ Summary: Python port of the R ggh4x package
5
+ Project-URL: Homepage, https://github.com/Bio-Babel/ggh4x-python
6
+ Project-URL: Issues, https://github.com/Bio-Babel/ggh4x-python/issues
7
+ Author-email: Jeffery Liu <jeffliu.lucky@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: ggplot2-python>=4.0.2
12
+ Requires-Dist: gtable-python>=0.3.6
13
+ Requires-Dist: numpy>=1.24
14
+ Requires-Dist: pandas>=2.0
15
+ Requires-Dist: patchwork-python>=1.3.2
16
+ Requires-Dist: rgrid-python>=4.5.3
17
+ Requires-Dist: scales-python>=1.4.0
18
+ Requires-Dist: scipy>=1.10
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest; extra == 'dev'
21
+ Requires-Dist: pytest-cov; extra == 'dev'
22
+ Requires-Dist: ruff; extra == 'dev'
23
+ Provides-Extra: docs
24
+ Requires-Dist: mkdocs; extra == 'docs'
25
+ Requires-Dist: mkdocs-jupyter; extra == 'docs'
26
+ Requires-Dist: mkdocs-material; extra == 'docs'
27
+ Requires-Dist: mkdocstrings[python]; extra == 'docs'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # ggh4x-python
31
+
32
+ [![PyPI](https://img.shields.io/pypi/v/ggh4x-python)](https://pypi.org/project/ggh4x-python/)
33
+
34
+ Python version of the R **ggh4x** package.
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install ggh4x-python
40
+ ```
@@ -0,0 +1,11 @@
1
+ # ggh4x-python
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/ggh4x-python)](https://pypi.org/project/ggh4x-python/)
4
+
5
+ Python version of the R **ggh4x** package.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install ggh4x-python
11
+ ```
@@ -0,0 +1,140 @@
1
+ """ggh4x-python — Python port of the R ggh4x package.
2
+
3
+ A ggplot2-extension toolkit ported onto ``ggplot2_py``: extended facets
4
+ (``facet_grid2`` / ``facet_wrap2`` / ``facet_nested`` / ``facet_manual``) with
5
+ pluggable strips, per-panel scales and forced panel sizes, plus extra stats,
6
+ geoms, multi-scales, positions/coords, and the ``guide_stringlegend`` legend.
7
+
8
+ Importing this package wires every subsystem's registration side-effects
9
+ (theme elements, the ``+``-add handlers for ``MultiScale`` / ``ForcedSize`` /
10
+ ``FacettedPosScales`` / ``ScaleFacet``, and the ``element_grob`` dispatch for
11
+ ``ElementPartRect``).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ __version__ = "0.3.1.9000"
17
+ __r_commit__ = "63c91b7"
18
+
19
+ # --- Stats -------------------------------------------------------------------
20
+ from .stat_theodensity import StatTheoDensity, stat_theodensity
21
+ from .stat_difference import StatDifference, stat_difference
22
+ from .stat_rle import StatRle, stat_rle
23
+ from .stat_rollingkernel import StatRollingkernel, stat_rollingkernel
24
+ from .stat_funxy import StatFunxy, stat_centroid, stat_funxy, stat_midpoint
25
+
26
+ # --- Geoms -------------------------------------------------------------------
27
+ from .geom_outline_point import GeomOutlinePoint, geom_outline_point
28
+ from .geom_box import GeomBox, geom_box
29
+ from .geom_pointpath import GeomPointPath, GeomPointpath, geom_pointpath
30
+ from .geom_text_aimed import GeomTextAimed, geom_text_aimed
31
+ from .geom_polygonraster import GeomPolygonRaster, geom_polygonraster
32
+ from .geom_rectrug import (
33
+ GeomRectMargin,
34
+ GeomTileMargin,
35
+ geom_rectmargin,
36
+ geom_tilemargin,
37
+ )
38
+
39
+ # --- Helpers / utils / elements ----------------------------------------------
40
+ from .conveniences import (
41
+ center_limits,
42
+ distribute_args,
43
+ elem_list_rect,
44
+ elem_list_text,
45
+ sep_discrete,
46
+ weave_factors,
47
+ )
48
+ from .save import save_plot
49
+ from .help_secondary import help_secondary
50
+ from .element_part_rect import ElementPartRect, element_part_rect
51
+ from .themes_ggh4x import ggh4x_theme_elements
52
+
53
+ # --- Strips ------------------------------------------------------------------
54
+ from .strip_vanilla import Strip, resolve_strip, strip_vanilla
55
+ from .strip_themed import StripThemed, strip_themed
56
+ from .strip_nested import StripNested, strip_nested
57
+ from .strip_split import StripSplit, strip_split
58
+ from .strip_tag import StripTag, strip_tag
59
+
60
+ # --- Facets ------------------------------------------------------------------
61
+ from .facet_grid2 import FacetGrid2, facet_grid2
62
+ from .facet_wrap2 import FacetWrap2, facet_wrap2
63
+ from .facet_nested import FacetNested, facet_nested
64
+ from .facet_nested_wrap import FacetNestedWrap, facet_nested_wrap
65
+ from .facet_manual import FacetManual, facet_manual
66
+
67
+ # --- Panel sizing / per-panel scales -----------------------------------------
68
+ from .panel_scales import (
69
+ at_panel,
70
+ facetted_pos_scales,
71
+ force_panelsizes,
72
+ scale_x_facet,
73
+ scale_y_facet,
74
+ )
75
+
76
+ # --- Coord / Position --------------------------------------------------------
77
+ from .coord_axes_inside import CoordAxesInside, coord_axes_inside
78
+ from .position_lineartrans import PositionLinearTrans, position_lineartrans
79
+ from .position_disjoint_ranges import (
80
+ PositionDisjointRanges,
81
+ position_disjoint_ranges,
82
+ )
83
+
84
+ # --- Multi / listed / manual scales ------------------------------------------
85
+ from .multiscale import (
86
+ scale_color_multi,
87
+ scale_colour_multi,
88
+ scale_fill_multi,
89
+ scale_listed,
90
+ scale_x_manual,
91
+ scale_y_manual,
92
+ )
93
+
94
+ # --- Live guide --------------------------------------------------------------
95
+ from .guide_stringlegend import GuideStringlegend, guide_stringlegend
96
+
97
+ __all__ = [
98
+ "__version__",
99
+ # stats
100
+ "stat_theodensity", "StatTheoDensity",
101
+ "stat_difference", "StatDifference",
102
+ "stat_rle", "StatRle",
103
+ "stat_rollingkernel", "StatRollingkernel",
104
+ "stat_funxy", "StatFunxy", "stat_centroid", "stat_midpoint",
105
+ # geoms
106
+ "geom_outline_point", "GeomOutlinePoint",
107
+ "geom_box", "GeomBox",
108
+ "geom_pointpath", "GeomPointPath", "GeomPointpath",
109
+ "geom_text_aimed", "GeomTextAimed",
110
+ "geom_polygonraster", "GeomPolygonRaster",
111
+ "geom_rectmargin", "geom_tilemargin", "GeomRectMargin", "GeomTileMargin",
112
+ # helpers / elements
113
+ "distribute_args", "elem_list_text", "elem_list_rect", "weave_factors",
114
+ "center_limits", "sep_discrete", "save_plot", "help_secondary",
115
+ "element_part_rect", "ElementPartRect", "ggh4x_theme_elements",
116
+ # strips
117
+ "Strip", "strip_vanilla", "resolve_strip",
118
+ "StripThemed", "strip_themed",
119
+ "StripNested", "strip_nested",
120
+ "StripSplit", "strip_split",
121
+ "StripTag", "strip_tag",
122
+ # facets
123
+ "facet_grid2", "FacetGrid2",
124
+ "facet_wrap2", "FacetWrap2",
125
+ "facet_nested", "FacetNested",
126
+ "facet_nested_wrap", "FacetNestedWrap",
127
+ "facet_manual", "FacetManual",
128
+ # panel sizing / per-panel scales
129
+ "force_panelsizes", "facetted_pos_scales",
130
+ "scale_x_facet", "scale_y_facet", "at_panel",
131
+ # coord / position
132
+ "coord_axes_inside", "CoordAxesInside",
133
+ "position_lineartrans", "PositionLinearTrans",
134
+ "position_disjoint_ranges", "PositionDisjointRanges",
135
+ # multi / listed / manual scales
136
+ "scale_colour_multi", "scale_color_multi", "scale_fill_multi",
137
+ "scale_listed", "scale_x_manual", "scale_y_manual",
138
+ # guide
139
+ "guide_stringlegend", "GuideStringlegend",
140
+ ]
@@ -0,0 +1,432 @@
1
+ """Aimed-text grob (port of the grid layer of ggh4x ``geom_text_aimed.R``).
2
+
3
+ This module ports ``aimed_textGrob()`` and its ``makeContent.aimed_text`` S3
4
+ method, plus the ggplot2-internal ``compute_just`` helper that
5
+ ``GeomTextAimed`` relies on.
6
+
7
+ R source: ``ggh4x/R/geom_text_aimed.R`` (``aimed_textGrob``,
8
+ ``makeContent.aimed_text``) and ggplot2-internal ``compute_just`` / ``just_dir``.
9
+
10
+ Notes
11
+ -----
12
+ * R's ``makeContent.aimed_text`` *reclasses* the grob to a ``"text"`` grob in
13
+ place and returns it. :mod:`grid_py`'s ``text`` renderer reads a scalar
14
+ ``rot``/``hjust``/``vjust`` per call, so per-row angle/justification cannot
15
+ travel on a single text grob. :class:`AimedTextGrob` therefore subclasses
16
+ :class:`grid_py.GTree`: its :meth:`make_content` performs the absolute-unit
17
+ angle computation (after the panel viewport is active) and rebuilds its
18
+ children as one scalar :func:`grid_py.text_grob` per label. This is
19
+ behaviourally identical to R while staying renderable.
20
+ * Angles are computed in millimetres (``convert_x``/``convert_y``) so they are
21
+ invariant under resizing, matching R's design intent.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Any, List, Optional, Sequence, Union
27
+
28
+ import numpy as np
29
+
30
+ from grid_py import (
31
+ GList,
32
+ GTree,
33
+ Gpar,
34
+ Unit,
35
+ convert_x,
36
+ convert_y,
37
+ is_unit,
38
+ text_grob,
39
+ )
40
+
41
+ __all__ = [
42
+ "compute_just",
43
+ "just_dir",
44
+ "aimed_text_grob",
45
+ "AimedTextGrob",
46
+ ]
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # compute_just (ggplot2 internal)
51
+ # ---------------------------------------------------------------------------
52
+ def just_dir(x: np.ndarray, tol: float = 0.001) -> np.ndarray:
53
+ """Map relative positions to justification directions ``1``/``2``/``3``.
54
+
55
+ Port of ggplot2-internal ``just_dir``. ``1`` (left/bottom) for values
56
+ below ``0.5 - tol``, ``3`` (right/top) above ``0.5 + tol``, else ``2``
57
+ (centre/middle).
58
+
59
+ Parameters
60
+ ----------
61
+ x : numpy.ndarray
62
+ Relative positions (typically in ``[0, 1]``).
63
+ tol : float, default ``0.001``
64
+ Half-width of the central dead zone.
65
+
66
+ Returns
67
+ -------
68
+ numpy.ndarray
69
+ Integer direction codes (1-based, matching R).
70
+ """
71
+ x = np.asarray(x, dtype="float64")
72
+ out = np.full(x.shape, 2, dtype=int)
73
+ out[x < 0.5 - tol] = 1
74
+ out[x > 0.5 + tol] = 3
75
+ return out
76
+
77
+
78
+ def compute_just(
79
+ just: Union[str, Sequence[str], np.ndarray, Any],
80
+ a: Any = 0.5,
81
+ b: Any = None,
82
+ angle: Any = 0,
83
+ ) -> Any:
84
+ """Resolve character justifications to numeric ``hjust``/``vjust``.
85
+
86
+ Port of ggplot2-internal ``compute_just(just, a, b, angle)``. Plain
87
+ keywords (``"left"``/``"right"``/``"center"``/``"bottom"``/``"middle"``/
88
+ ``"top"``) map to ``0``/``1``/``0.5``; ``"inward"``/``"outward"`` resolve
89
+ relative to ``a`` (and ``b`` after rotation), honouring ``angle``.
90
+
91
+ Parameters
92
+ ----------
93
+ just : str or sequence of str or array
94
+ The justification specification. Non-character input is returned
95
+ unchanged (matching R's early return).
96
+ a : array-like, default ``0.5``
97
+ Primary position values (e.g. data ``x`` for ``hjust``).
98
+ b : array-like, optional
99
+ Secondary position values; defaults to *a* (used when rotation swaps
100
+ the relevant axis).
101
+ angle : array-like, default ``0``
102
+ Text rotation in degrees.
103
+
104
+ Returns
105
+ -------
106
+ numpy.ndarray
107
+ Numeric justification values.
108
+ """
109
+ # R: if (!is.character(just)) return(just)
110
+ arr = np.atleast_1d(np.asarray(just, dtype=object))
111
+ if not all(isinstance(v, str) for v in arr):
112
+ return just
113
+
114
+ just_chr = np.array([str(v) for v in arr], dtype=object)
115
+ a = np.atleast_1d(np.asarray(a, dtype="float64"))
116
+ if b is None:
117
+ b = a
118
+ b = np.atleast_1d(np.asarray(b, dtype="float64"))
119
+
120
+ has_io = np.array(["outward" in v or "inward" in v for v in just_chr])
121
+ if np.any(has_io):
122
+ ang = np.atleast_1d(np.asarray(angle, dtype="float64")) % 360
123
+ ang = np.where(ang > 180, ang - 360, ang)
124
+ ang = np.where(ang < -180, ang + 360, ang)
125
+ # Broadcast angle to the length of just.
126
+ ang = np.broadcast_to(ang, just_chr.shape).astype("float64").copy()
127
+
128
+ rotated_forward = has_io & (ang > 45) & (ang < 135)
129
+ rotated_backwards = has_io & (ang < -45) & (ang > -135)
130
+ # ab <- ifelse(rotated_forward | rotated_backwards, b, a)
131
+ a_b = np.broadcast_to(a, just_chr.shape).astype("float64").copy()
132
+ b_b = np.broadcast_to(b, just_chr.shape).astype("float64").copy()
133
+ ab = np.where(rotated_forward | rotated_backwards, b_b, a_b)
134
+
135
+ just_swap = rotated_backwards | (np.abs(ang) > 135)
136
+
137
+ inward = ((just_chr == "inward") & ~just_swap) | (
138
+ (just_chr == "outward") & just_swap
139
+ )
140
+ if np.any(inward):
141
+ dir_codes = just_dir(ab[inward])
142
+ mapping = np.array(["left", "middle", "right"], dtype=object)
143
+ just_chr[inward] = mapping[dir_codes - 1]
144
+
145
+ outward = ((just_chr == "outward") & ~just_swap) | (
146
+ (just_chr == "inward") & just_swap
147
+ )
148
+ if np.any(outward):
149
+ dir_codes = just_dir(ab[outward])
150
+ mapping = np.array(["right", "middle", "left"], dtype=object)
151
+ just_chr[outward] = mapping[dir_codes - 1]
152
+
153
+ lookup = {
154
+ "left": 0.0,
155
+ "center": 0.5,
156
+ "centre": 0.5,
157
+ "right": 1.0,
158
+ "bottom": 0.0,
159
+ "middle": 0.5,
160
+ "top": 1.0,
161
+ }
162
+ out = np.array([lookup.get(str(v), 0.5) for v in just_chr], dtype="float64")
163
+ return out
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # aimed_text_grob constructor
168
+ # ---------------------------------------------------------------------------
169
+ def aimed_text_grob(
170
+ label: Any,
171
+ x: Any = 0.5,
172
+ y: Any = 0.5,
173
+ x0: Any = 0.0,
174
+ y0: Any = 0.0,
175
+ just: Any = "centre",
176
+ hjust: Any = None,
177
+ vjust: Any = None,
178
+ rot: Any = 0,
179
+ check_overlap: bool = False,
180
+ default_units: str = "npc",
181
+ name: Optional[str] = None,
182
+ gp: Optional[Gpar] = None,
183
+ vp: Optional[Any] = None,
184
+ flip_upsidedown: bool = True,
185
+ ) -> "AimedTextGrob":
186
+ """Construct an :class:`AimedTextGrob`.
187
+
188
+ Port of R ``aimed_textGrob()`` (``geom_text_aimed.R:161-183``). Bare
189
+ numeric coordinates are wrapped in *default_units*.
190
+
191
+ Parameters
192
+ ----------
193
+ label : array-like
194
+ Text labels.
195
+ x, y : array-like or grid unit
196
+ Text anchor positions.
197
+ x0, y0 : array-like or grid unit
198
+ The aim-target positions.
199
+ just : str or sequence of str, default ``"centre"``
200
+ Default justification.
201
+ hjust, vjust : array-like, optional
202
+ Horizontal / vertical justification.
203
+ rot : array-like, default ``0``
204
+ Base rotation (the aim angle is added on top at draw time).
205
+ check_overlap : bool, default ``False``
206
+ Whether overlapping labels are suppressed.
207
+ default_units : str, default ``"npc"``
208
+ Units for bare numeric coordinates.
209
+ name : str, optional
210
+ Grob name.
211
+ gp : grid_py.Gpar, optional
212
+ Graphical parameters.
213
+ vp : Any, optional
214
+ Viewport.
215
+ flip_upsidedown : bool, default ``True``
216
+ If ``True``, labels rotated into ``(90, 270)`` are flipped 180 degrees
217
+ for readability (with mirrored ``hjust``).
218
+
219
+ Returns
220
+ -------
221
+ AimedTextGrob
222
+ The custom grob.
223
+ """
224
+ x = x if is_unit(x) else Unit(np.atleast_1d(np.asarray(x, dtype="float64")), default_units)
225
+ y = y if is_unit(y) else Unit(np.atleast_1d(np.asarray(y, dtype="float64")), default_units)
226
+ x0 = x0 if is_unit(x0) else Unit(np.atleast_1d(np.asarray(x0, dtype="float64")), default_units)
227
+ y0 = y0 if is_unit(y0) else Unit(np.atleast_1d(np.asarray(y0, dtype="float64")), default_units)
228
+
229
+ return AimedTextGrob(
230
+ label=label,
231
+ x=x,
232
+ y=y,
233
+ x0=x0,
234
+ y0=y0,
235
+ just=just,
236
+ hjust=hjust,
237
+ vjust=vjust,
238
+ rot=rot,
239
+ check_overlap=check_overlap,
240
+ flip_upsidedown=flip_upsidedown,
241
+ name=name,
242
+ gp=gp,
243
+ vp=vp,
244
+ )
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # AimedTextGrob
249
+ # ---------------------------------------------------------------------------
250
+ class AimedTextGrob(GTree):
251
+ """Text grob that rotates each label towards a per-label aim point.
252
+
253
+ Subclass of :class:`grid_py.GTree`. Stored ``x``/``y``/``x0``/``y0`` are
254
+ grid units; :meth:`make_content` converts them to millimetres against the
255
+ live panel viewport, computes ``ang = atan2(y1 - y0, x1 - x0)``, adds it to
256
+ the base ``rot`` and rebuilds the children as one scalar
257
+ :func:`grid_py.text_grob` per label.
258
+
259
+ Port of the grob carrying ``cl = "aimed_text"`` and its S3 method
260
+ ``makeContent.aimed_text`` (``geom_text_aimed.R:188-214``).
261
+ """
262
+
263
+ def __init__(
264
+ self,
265
+ label: Any,
266
+ x: Unit,
267
+ y: Unit,
268
+ x0: Unit,
269
+ y0: Unit,
270
+ just: Any = "centre",
271
+ hjust: Any = None,
272
+ vjust: Any = None,
273
+ rot: Any = 0,
274
+ check_overlap: bool = False,
275
+ flip_upsidedown: bool = True,
276
+ name: Optional[str] = None,
277
+ gp: Optional[Gpar] = None,
278
+ vp: Optional[Any] = None,
279
+ ) -> None:
280
+ super().__init__(
281
+ children=None,
282
+ name=name,
283
+ gp=gp,
284
+ vp=vp,
285
+ _grid_class="aimed_text",
286
+ label=label,
287
+ x=x,
288
+ y=y,
289
+ x0=x0,
290
+ y0=y0,
291
+ just=just,
292
+ hjust=hjust,
293
+ vjust=vjust,
294
+ rot=rot,
295
+ check_overlap=check_overlap,
296
+ flip_upsidedown=flip_upsidedown,
297
+ )
298
+
299
+ def make_content(self) -> "AimedTextGrob":
300
+ """Compute aim angles in mm and rebuild the per-label text children.
301
+
302
+ Returns
303
+ -------
304
+ AimedTextGrob
305
+ ``self``, with its children replaced by the rotated text grobs.
306
+ """
307
+ x1 = np.atleast_1d(np.asarray(convert_x(self.x, "mm", valueOnly=True), dtype="float64"))
308
+ y1 = np.atleast_1d(np.asarray(convert_y(self.y, "mm", valueOnly=True), dtype="float64"))
309
+ x0 = np.atleast_1d(np.asarray(convert_x(self.x0, "mm", valueOnly=True), dtype="float64"))
310
+ y0 = np.atleast_1d(np.asarray(convert_y(self.y0, "mm", valueOnly=True), dtype="float64"))
311
+
312
+ rot, hjust, vjust = compute_aimed_angles(
313
+ x1, y1, x0, y0, self.rot, self.hjust, self.vjust, self.flip_upsidedown
314
+ )
315
+
316
+ labels = self.label
317
+ if isinstance(labels, str):
318
+ labels = [labels]
319
+ labels = list(labels)
320
+ n = len(labels)
321
+
322
+ # Native anchor positions for the text grobs (drawing happens in the
323
+ # panel vp, so the original native/npc x/y are reused directly).
324
+ xs = np.atleast_1d(np.asarray(convert_x(self.x, "native", valueOnly=True), dtype="float64"))
325
+ ys = np.atleast_1d(np.asarray(convert_y(self.y, "native", valueOnly=True), dtype="float64"))
326
+
327
+ gp = self.gp
328
+ children: List[Any] = []
329
+ for i in range(n):
330
+ gp_i = _subset_gpar(gp, i, n)
331
+ children.append(
332
+ text_grob(
333
+ label=str(labels[i]),
334
+ x=Unit(float(xs[i % len(xs)]), "native"),
335
+ y=Unit(float(ys[i % len(ys)]), "native"),
336
+ hjust=float(hjust[i % len(hjust)]),
337
+ vjust=float(vjust[i % len(vjust)]),
338
+ rot=float(rot[i % len(rot)]),
339
+ check_overlap=bool(self.check_overlap),
340
+ gp=gp_i,
341
+ name=f"aimed_text.{i}",
342
+ )
343
+ )
344
+
345
+ self.set_children(GList(*children))
346
+ return self
347
+
348
+
349
+ def compute_aimed_angles(
350
+ x1: np.ndarray,
351
+ y1: np.ndarray,
352
+ x0: np.ndarray,
353
+ y0: np.ndarray,
354
+ rot: Any,
355
+ hjust: Any,
356
+ vjust: Any,
357
+ flip_upsidedown: bool,
358
+ ) -> tuple:
359
+ """Compute rotated text angles and (possibly mirrored) justifications.
360
+
361
+ Pure-math core of :meth:`AimedTextGrob.make_content`, split out for
362
+ verification. Port of ``makeContent.aimed_text``
363
+ (``geom_text_aimed.R:195-204``).
364
+
365
+ Parameters
366
+ ----------
367
+ x1, y1 : numpy.ndarray
368
+ Text anchor positions (mm).
369
+ x0, y0 : numpy.ndarray
370
+ Aim-target positions (mm).
371
+ rot : array-like
372
+ Base rotation (degrees).
373
+ hjust, vjust : array-like
374
+ Justification values.
375
+ flip_upsidedown : bool
376
+ Whether to flip labels rotated into ``(90, 270)``.
377
+
378
+ Returns
379
+ -------
380
+ tuple of numpy.ndarray
381
+ ``(rot, hjust, vjust)`` after the aim rotation (and optional flip).
382
+ """
383
+ rot = np.atleast_1d(np.asarray(rot, dtype="float64"))
384
+ hjust = np.atleast_1d(np.asarray(hjust, dtype="float64"))
385
+ vjust = np.atleast_1d(np.asarray(vjust, dtype="float64"))
386
+
387
+ ang = np.arctan2(y1 - y0, x1 - x0)
388
+ rot = (rot + ang * (180.0 / np.pi)) % 360.0
389
+
390
+ if flip_upsidedown:
391
+ upsidedown = (rot > 90) & (rot < 270)
392
+ rot = np.where(upsidedown, rot + 180, rot) % 360.0
393
+ # hjust may be a scalar; broadcast before mirroring.
394
+ hjust = np.broadcast_to(hjust, upsidedown.shape).astype("float64").copy()
395
+ hjust = np.where(upsidedown, 1.0 - hjust, hjust)
396
+
397
+ return rot, hjust, vjust
398
+
399
+
400
+ def _subset_gpar(gp: Optional[Gpar], i: int, n: int) -> Optional[Gpar]:
401
+ """Return the ``i``-th element of every length>1 :class:`grid_py.Gpar` field.
402
+
403
+ Parameters
404
+ ----------
405
+ gp : grid_py.Gpar or None
406
+ Graphical parameters that may carry per-label vectors.
407
+ i : int
408
+ Row index.
409
+ n : int
410
+ Total number of labels.
411
+
412
+ Returns
413
+ -------
414
+ grid_py.Gpar or None
415
+ A per-label :class:`grid_py.Gpar`, or ``None`` if *gp* is ``None``.
416
+ """
417
+ if gp is None:
418
+ return None
419
+ from ._gap_grobs import _gpar_to_dict
420
+
421
+ params = _gpar_to_dict(gp)
422
+ out = {}
423
+ for key, value in params.items():
424
+ if value is None:
425
+ out[key] = value
426
+ continue
427
+ arr = np.asarray(value)
428
+ if arr.ndim >= 1 and arr.shape[0] == n and n > 1:
429
+ out[key] = arr[i]
430
+ else:
431
+ out[key] = value
432
+ return Gpar(**out)