ggplot2-python 4.0.2.9000.post4__py3-none-any.whl → 4.0.2.9000.post6__py3-none-any.whl
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.
- ggplot2_py/__init__.py +17 -6
- ggplot2_py/_compat.py +66 -9
- ggplot2_py/_env.py +160 -0
- ggplot2_py/{guide_axis.py → _guide_axis.py} +32 -10
- ggplot2_py/{guide_colourbar.py → _guide_colourbar.py} +1 -1
- ggplot2_py/{guide_legend.py → _guide_legend.py} +22 -27
- ggplot2_py/coord.py +1094 -119
- ggplot2_py/extension/__init__.py +365 -0
- ggplot2_py/facet.py +2 -2
- ggplot2_py/geom.py +595 -88
- ggplot2_py/ggproto.py +402 -78
- ggplot2_py/guide.py +997 -121
- ggplot2_py/layer.py +8 -3
- ggplot2_py/plot.py +169 -4
- ggplot2_py/plot_render.py +113 -549
- ggplot2_py/scale.py +193 -93
- ggplot2_py/theme_defaults.py +18 -2
- ggplot2_py/theme_elements.py +162 -59
- {ggplot2_python-4.0.2.9000.post4.dist-info → ggplot2_python-4.0.2.9000.post6.dist-info}/METADATA +6 -1
- {ggplot2_python-4.0.2.9000.post4.dist-info → ggplot2_python-4.0.2.9000.post6.dist-info}/RECORD +22 -20
- {ggplot2_python-4.0.2.9000.post4.dist-info → ggplot2_python-4.0.2.9000.post6.dist-info}/WHEEL +1 -1
- {ggplot2_python-4.0.2.9000.post4.dist-info → ggplot2_python-4.0.2.9000.post6.dist-info}/licenses/LICENSE +0 -0
ggplot2_py/__init__.py
CHANGED
|
@@ -7,7 +7,7 @@ approach to creating statistical visualizations.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
__version__ = "4.0.2.9000.
|
|
10
|
+
__version__ = "4.0.2.9000.post6"
|
|
11
11
|
__r_commit__ = "c02c05a"
|
|
12
12
|
|
|
13
13
|
# ---------------------------------------------------------------------------
|
|
@@ -23,7 +23,10 @@ from ggplot2_py.ggproto import (
|
|
|
23
23
|
ggproto,
|
|
24
24
|
ggproto_parent,
|
|
25
25
|
is_ggproto,
|
|
26
|
+
fetch_ggproto,
|
|
27
|
+
bind_method,
|
|
26
28
|
)
|
|
29
|
+
from ggplot2_py._env import PlotEnv
|
|
27
30
|
|
|
28
31
|
# ---------------------------------------------------------------------------
|
|
29
32
|
# Aesthetics
|
|
@@ -62,6 +65,8 @@ from ggplot2_py.plot import (
|
|
|
62
65
|
ggplotGrob,
|
|
63
66
|
ggplot_add,
|
|
64
67
|
add_gg,
|
|
68
|
+
register_pre_add_hook,
|
|
69
|
+
unregister_pre_add_hook,
|
|
65
70
|
get_last_plot,
|
|
66
71
|
set_last_plot,
|
|
67
72
|
last_plot,
|
|
@@ -118,6 +123,7 @@ from ggplot2_py.geom import (
|
|
|
118
123
|
GeomRaster,
|
|
119
124
|
GeomText,
|
|
120
125
|
GeomLabel,
|
|
126
|
+
GeomAbsText,
|
|
121
127
|
GeomBoxplot,
|
|
122
128
|
GeomViolin,
|
|
123
129
|
GeomDotplot,
|
|
@@ -168,6 +174,7 @@ from ggplot2_py.geom import (
|
|
|
168
174
|
geom_raster,
|
|
169
175
|
geom_text,
|
|
170
176
|
geom_label,
|
|
177
|
+
geom_abs_text,
|
|
171
178
|
geom_boxplot,
|
|
172
179
|
geom_violin,
|
|
173
180
|
geom_dotplot,
|
|
@@ -561,6 +568,7 @@ from ggplot2_py.guide import (
|
|
|
561
568
|
is_guide,
|
|
562
569
|
is_guides,
|
|
563
570
|
new_guide,
|
|
571
|
+
register_guide,
|
|
564
572
|
old_guide,
|
|
565
573
|
guide_geom,
|
|
566
574
|
guide_train,
|
|
@@ -734,7 +742,8 @@ __all__ = [
|
|
|
734
742
|
# Version
|
|
735
743
|
"__version__",
|
|
736
744
|
# Core
|
|
737
|
-
"GGProto", "ggproto", "ggproto_parent", "is_ggproto",
|
|
745
|
+
"GGProto", "ggproto", "ggproto_parent", "is_ggproto", "fetch_ggproto",
|
|
746
|
+
"bind_method", "PlotEnv",
|
|
738
747
|
"Waiver", "waiver", "is_waiver",
|
|
739
748
|
# Aesthetics
|
|
740
749
|
"aes", "after_stat", "after_scale", "stage", "vars",
|
|
@@ -745,7 +754,9 @@ __all__ = [
|
|
|
745
754
|
"Layer", "layer", "is_layer",
|
|
746
755
|
# Plot
|
|
747
756
|
"ggplot", "is_ggplot", "ggplot_build", "ggplot_gtable", "ggplotGrob",
|
|
748
|
-
"ggplot_add", "add_gg",
|
|
757
|
+
"ggplot_add", "add_gg",
|
|
758
|
+
"register_pre_add_hook", "unregister_pre_add_hook",
|
|
759
|
+
"get_last_plot", "set_last_plot", "last_plot",
|
|
749
760
|
"print_plot", "get_alt_text", "update_ggplot",
|
|
750
761
|
# Introspection
|
|
751
762
|
"get_layer_data", "get_layer_grob", "get_panel_scales",
|
|
@@ -756,7 +767,7 @@ __all__ = [
|
|
|
756
767
|
# Geom classes
|
|
757
768
|
"Geom", "GeomPoint", "GeomPath", "GeomLine", "GeomStep",
|
|
758
769
|
"GeomBar", "GeomCol", "GeomRect", "GeomTile", "GeomRaster",
|
|
759
|
-
"GeomText", "GeomLabel", "GeomBoxplot", "GeomViolin", "GeomDotplot",
|
|
770
|
+
"GeomText", "GeomLabel", "GeomAbsText", "GeomBoxplot", "GeomViolin", "GeomDotplot",
|
|
760
771
|
"GeomRibbon", "GeomArea", "GeomSmooth", "GeomPolygon",
|
|
761
772
|
"GeomErrorbar", "GeomErrorbarh", "GeomCrossbar", "GeomLinerange", "GeomPointrange",
|
|
762
773
|
"GeomSegment", "GeomCurve", "GeomSpoke",
|
|
@@ -767,7 +778,7 @@ __all__ = [
|
|
|
767
778
|
# Geom constructors
|
|
768
779
|
"geom_point", "geom_path", "geom_line", "geom_step",
|
|
769
780
|
"geom_bar", "geom_col", "geom_rect", "geom_tile", "geom_raster",
|
|
770
|
-
"geom_text", "geom_label", "geom_boxplot", "geom_violin", "geom_dotplot",
|
|
781
|
+
"geom_text", "geom_label", "geom_abs_text", "geom_boxplot", "geom_violin", "geom_dotplot",
|
|
771
782
|
"geom_ribbon", "geom_area", "geom_smooth", "geom_polygon",
|
|
772
783
|
"geom_errorbar", "geom_errorbarh", "geom_crossbar", "geom_linerange", "geom_pointrange",
|
|
773
784
|
"geom_segment", "geom_curve", "geom_spoke",
|
|
@@ -897,7 +908,7 @@ __all__ = [
|
|
|
897
908
|
# Guides
|
|
898
909
|
"Guide", "GuideAxis", "GuideAxisLogticks", "GuideAxisStack",
|
|
899
910
|
"GuideAxisTheta", "GuideBins", "GuideColourbar", "GuideColoursteps",
|
|
900
|
-
"GuideCustom", "GuideLegend", "GuideNone",
|
|
911
|
+
"GuideCustom", "GuideLegend", "GuideNone", "register_guide",
|
|
901
912
|
"guide_axis", "guide_legend", "guide_colourbar", "guide_colorbar",
|
|
902
913
|
"guide_old_colourbar", "guide_old_colorbar",
|
|
903
914
|
"guide_coloursteps", "guide_colorsteps", "guide_bins",
|
ggplot2_py/_compat.py
CHANGED
|
@@ -8,6 +8,8 @@ ggplot2 relies on, adapted for idiomatic Python usage.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import importlib
|
|
11
|
+
import logging
|
|
12
|
+
import sys
|
|
11
13
|
import warnings
|
|
12
14
|
from typing import Any, NoReturn, Optional
|
|
13
15
|
|
|
@@ -41,6 +43,40 @@ __all__ = [
|
|
|
41
43
|
# ---------------------------------------------------------------------------
|
|
42
44
|
# CLI messaging helpers (rlang / cli replacements)
|
|
43
45
|
# ---------------------------------------------------------------------------
|
|
46
|
+
#
|
|
47
|
+
# R distinguishes three output channels:
|
|
48
|
+
#
|
|
49
|
+
# * ``cli::cli_abort`` — error stream (``stop``) → Python ``raise``
|
|
50
|
+
# * ``cli::cli_warn`` — warning stream (``warning``) → ``warnings.warn(..., UserWarning)``
|
|
51
|
+
# * ``cli::cli_inform``— message stream (``message``) → ``logging.INFO``
|
|
52
|
+
#
|
|
53
|
+
# In R these are three separate streams: ``suppressWarnings`` does not
|
|
54
|
+
# silence messages, ``suppressMessages`` does not silence warnings, and
|
|
55
|
+
# the test framework's ``expect_message`` / ``expect_warning`` discriminate
|
|
56
|
+
# between them. This Python port matches that split — informational
|
|
57
|
+
# output (e.g. ``ggsave``'s "Saving …", ``stat_bin``'s "using bins = 30")
|
|
58
|
+
# routes through the standard :mod:`logging` module at INFO level so
|
|
59
|
+
# pytest does not count it as a warning, ``warnings.catch_warnings``
|
|
60
|
+
# does not capture it, and ``logging.getLogger("ggplot2_py").setLevel(
|
|
61
|
+
# logging.WARNING)`` mirrors R's ``suppressMessages``.
|
|
62
|
+
|
|
63
|
+
#: Package-level logger. Library convention says "do not configure the
|
|
64
|
+
#: root logger" — instead we attach a single :class:`logging.StreamHandler`
|
|
65
|
+
#: to *this* logger and set :attr:`Logger.propagate` to ``False``, so the
|
|
66
|
+
#: handler runs even when the user has not called
|
|
67
|
+
#: :func:`logging.basicConfig` and ours never duplicates onto theirs.
|
|
68
|
+
_logger: logging.Logger = logging.getLogger("ggplot2_py")
|
|
69
|
+
if not _logger.handlers:
|
|
70
|
+
_handler = logging.StreamHandler(stream=sys.stderr)
|
|
71
|
+
# R's ``message()`` prints the bare message text (no level/timestamp
|
|
72
|
+
# prefix); mirror that to keep ggplot2_py output indistinguishable
|
|
73
|
+
# from the R original.
|
|
74
|
+
_handler.setFormatter(logging.Formatter("%(message)s"))
|
|
75
|
+
_handler.setLevel(logging.INFO)
|
|
76
|
+
_logger.addHandler(_handler)
|
|
77
|
+
_logger.setLevel(logging.INFO)
|
|
78
|
+
_logger.propagate = False
|
|
79
|
+
|
|
44
80
|
|
|
45
81
|
def cli_abort(
|
|
46
82
|
message: str,
|
|
@@ -81,7 +117,13 @@ def cli_warn(
|
|
|
81
117
|
call: Optional[str] = None,
|
|
82
118
|
**kwargs: Any,
|
|
83
119
|
) -> None:
|
|
84
|
-
"""Issue a ``UserWarning``
|
|
120
|
+
"""Issue a ``UserWarning`` — R *warning* stream equivalent.
|
|
121
|
+
|
|
122
|
+
Counterpart of :func:`cli_inform`: this function targets the
|
|
123
|
+
Python warning stream (matching R ``cli::cli_warn`` / ``warning()``),
|
|
124
|
+
while informational output goes through :mod:`logging` at INFO
|
|
125
|
+
level. Keeping the two streams separate preserves R's
|
|
126
|
+
``suppressWarnings`` / ``suppressMessages`` granularity.
|
|
85
127
|
|
|
86
128
|
Parameters
|
|
87
129
|
----------
|
|
@@ -105,24 +147,39 @@ def cli_inform(
|
|
|
105
147
|
call: Optional[str] = None,
|
|
106
148
|
**kwargs: Any,
|
|
107
149
|
) -> None:
|
|
108
|
-
"""Emit an informational message
|
|
150
|
+
"""Emit an informational message at ``logging.INFO`` level.
|
|
151
|
+
|
|
152
|
+
R analogue: ``cli::cli_inform`` / ``rlang::inform`` write to R's
|
|
153
|
+
*message* stream — informational output that is **distinct from**
|
|
154
|
+
R's *warning* stream (see :func:`cli_warn`). Python's natural
|
|
155
|
+
equivalent is the :mod:`logging` module at INFO level:
|
|
109
156
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
``suppressMessages
|
|
157
|
+
* pytest does not count INFO records as warnings (its
|
|
158
|
+
``--strict-warnings`` flag and "warnings summary" only track
|
|
159
|
+
:mod:`warnings`);
|
|
160
|
+
* ``warnings.catch_warnings`` does not capture them;
|
|
161
|
+
* the user opt-out parallels R's ``suppressMessages`` —
|
|
162
|
+
``logging.getLogger("ggplot2_py").setLevel(logging.WARNING)``
|
|
163
|
+
silences cli_inform output without affecting cli_warn.
|
|
164
|
+
|
|
165
|
+
By default the package logger has a :class:`logging.StreamHandler`
|
|
166
|
+
attached at INFO level (R-faithful: ``cli_inform`` messages are
|
|
167
|
+
visible to stderr unless explicitly suppressed).
|
|
115
168
|
|
|
116
169
|
Parameters
|
|
117
170
|
----------
|
|
118
171
|
message : str
|
|
119
|
-
Informational message.
|
|
172
|
+
Informational message. May contain ``{name}``-style placeholders.
|
|
120
173
|
call : str, optional
|
|
121
174
|
Name of the calling function (unused; matches R signature).
|
|
122
175
|
**kwargs : Any
|
|
123
176
|
Substitution values for placeholders in *message*.
|
|
124
177
|
"""
|
|
125
|
-
|
|
178
|
+
try:
|
|
179
|
+
formatted = message.format(**kwargs) if kwargs else message
|
|
180
|
+
except (KeyError, IndexError):
|
|
181
|
+
formatted = message
|
|
182
|
+
_logger.info(formatted)
|
|
126
183
|
|
|
127
184
|
|
|
128
185
|
# ---------------------------------------------------------------------------
|
ggplot2_py/_env.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PlotEnv — layered namespace lookup for scale/guide constructors.
|
|
3
|
+
|
|
4
|
+
Port of R's ``find_global(name, env, mode = "function")`` (R ref:
|
|
5
|
+
``ggplot2/R/scale-type.R:39-54``). Quoting the R source:
|
|
6
|
+
|
|
7
|
+
Look for object first in parent environment and if not found, then in
|
|
8
|
+
ggplot2 namespace environment. This makes it possible to override
|
|
9
|
+
default scales by setting them in the parent environment.
|
|
10
|
+
|
|
11
|
+
In R, ``plot@plot_env`` is the environment where the plot was constructed
|
|
12
|
+
(typically ``parent.frame()`` at ``ggplot()`` call time), and a chain of
|
|
13
|
+
environments terminates in ``asNamespace("ggplot2")``. ggnewscale
|
|
14
|
+
exploits this by **injecting** ``scale_<bumped_aes>_<type>`` functions
|
|
15
|
+
into ``plot@plot_env`` (R ref:
|
|
16
|
+
``ggnewscale/R/rename-aes.R:87-104``), so that when build-time
|
|
17
|
+
default-scale resolution runs, the injected constructor wins.
|
|
18
|
+
|
|
19
|
+
In Python, the closest equivalent is a list-of-namespaces walked in
|
|
20
|
+
order, with the ``ggplot2_py.scales`` module as the implicit fallback
|
|
21
|
+
layer. :class:`PlotEnv` encapsulates that — call sites pass a
|
|
22
|
+
``PlotEnv`` to :func:`find_scale` / ``ScalesList.add_defaults`` /
|
|
23
|
+
``ScalesList.add_missing``, and lookups walk the user-pushed layers
|
|
24
|
+
first.
|
|
25
|
+
|
|
26
|
+
A *namespace* is anything that responds to attribute access **or**
|
|
27
|
+
mapping ``__getitem__``. ``dict``, ``types.ModuleType``,
|
|
28
|
+
``types.SimpleNamespace``, ``argparse.Namespace``, and arbitrary
|
|
29
|
+
objects implementing ``__getattr__`` all work.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from typing import Any, Callable, List, Optional
|
|
35
|
+
|
|
36
|
+
__all__ = ["PlotEnv"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PlotEnv:
|
|
40
|
+
"""Layered scale/guide-constructor lookup table for a plot.
|
|
41
|
+
|
|
42
|
+
Mirrors the *effective* behaviour of R's ``plot@plot_env`` + the
|
|
43
|
+
``find_global`` walk: lookups proceed through the user-pushed
|
|
44
|
+
layers in **last-pushed-first-checked** order (LIFO), then fall
|
|
45
|
+
back to the ``ggplot2_py.scales`` module.
|
|
46
|
+
|
|
47
|
+
Each layer may be any object that supports either attribute access
|
|
48
|
+
(``getattr(layer, name)``) or mapping access (``layer[name]``); the
|
|
49
|
+
first that yields a non-``None`` value wins.
|
|
50
|
+
|
|
51
|
+
Examples
|
|
52
|
+
--------
|
|
53
|
+
>>> env = PlotEnv()
|
|
54
|
+
>>> env.push({"scale_colour_continuous": my_factory})
|
|
55
|
+
>>> env.lookup("scale_colour_continuous") is my_factory
|
|
56
|
+
True
|
|
57
|
+
|
|
58
|
+
The implicit fallback to ``ggplot2_py.scales`` is intentionally
|
|
59
|
+
**not** included in :attr:`layers` so that :meth:`clone` produces
|
|
60
|
+
an env with the same user-visible chain.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, *layers: Any) -> None:
|
|
64
|
+
# Layers are stored in **push order**. Lookup walks them in
|
|
65
|
+
# reverse so that the most recently pushed layer wins, matching
|
|
66
|
+
# R's environment-chain semantics (the immediate parent is
|
|
67
|
+
# checked before more ancestral ones).
|
|
68
|
+
self._layers: List[Any] = [l for l in layers if l is not None]
|
|
69
|
+
|
|
70
|
+
# -- Mutation ----------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def push(self, layer: Any) -> None:
|
|
73
|
+
"""Add a new lookup layer. Last-pushed wins on conflicts.
|
|
74
|
+
|
|
75
|
+
Parameters
|
|
76
|
+
----------
|
|
77
|
+
layer : object
|
|
78
|
+
Any dict-like or attribute-bearing namespace.
|
|
79
|
+
"""
|
|
80
|
+
if layer is None:
|
|
81
|
+
return
|
|
82
|
+
self._layers.append(layer)
|
|
83
|
+
|
|
84
|
+
def pop(self) -> Optional[Any]:
|
|
85
|
+
"""Remove and return the most-recently-pushed layer, or ``None``
|
|
86
|
+
if the chain is empty.
|
|
87
|
+
"""
|
|
88
|
+
if not self._layers:
|
|
89
|
+
return None
|
|
90
|
+
return self._layers.pop()
|
|
91
|
+
|
|
92
|
+
# -- Query -------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def lookup(self, name: str) -> Optional[Callable]:
|
|
95
|
+
"""Resolve *name* through the layer chain, then the
|
|
96
|
+
``ggplot2_py.scales`` fallback module.
|
|
97
|
+
|
|
98
|
+
Returns the first hit, or ``None`` if nothing matches.
|
|
99
|
+
|
|
100
|
+
Notes
|
|
101
|
+
-----
|
|
102
|
+
The fallback layer is searched **last** even when the lookup
|
|
103
|
+
chain is non-empty — matching R's
|
|
104
|
+
``c(env, list(as_namespace("ggplot2")))`` (R ref:
|
|
105
|
+
``scale-type.R:44``).
|
|
106
|
+
"""
|
|
107
|
+
for layer in reversed(self._layers):
|
|
108
|
+
val = _ns_get(layer, name)
|
|
109
|
+
if val is not None:
|
|
110
|
+
return val
|
|
111
|
+
# Fallback: the package's own scale-constructor module. Import
|
|
112
|
+
# lazily to avoid an import cycle with scale.py at load time —
|
|
113
|
+
# the cycle only matters during module construction; at call
|
|
114
|
+
# time every module is loaded so the import never raises.
|
|
115
|
+
from ggplot2_py import scales as _scales_mod
|
|
116
|
+
return getattr(_scales_mod, name, None)
|
|
117
|
+
|
|
118
|
+
def __contains__(self, name: str) -> bool:
|
|
119
|
+
return self.lookup(name) is not None
|
|
120
|
+
|
|
121
|
+
# -- Plumbing ----------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def clone(self) -> "PlotEnv":
|
|
124
|
+
"""Return a new :class:`PlotEnv` with the same layer chain.
|
|
125
|
+
|
|
126
|
+
The layers themselves are **not** deep-copied — mirrors R env
|
|
127
|
+
semantics where ``plot_clone`` does not snapshot the
|
|
128
|
+
environment contents.
|
|
129
|
+
"""
|
|
130
|
+
new = PlotEnv()
|
|
131
|
+
new._layers = list(self._layers)
|
|
132
|
+
return new
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def layers(self) -> List[Any]:
|
|
136
|
+
"""Read-only view of the layer chain (in push order)."""
|
|
137
|
+
return list(self._layers)
|
|
138
|
+
|
|
139
|
+
def __repr__(self) -> str:
|
|
140
|
+
return f"<PlotEnv layers={len(self._layers)}>"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _ns_get(layer: Any, name: str) -> Any:
|
|
144
|
+
"""Resolve *name* on *layer*, trying mapping then attribute access.
|
|
145
|
+
|
|
146
|
+
Returns the found value, or ``None`` if absent. Catches only the
|
|
147
|
+
natural "key absent" / "unsupported indexing" cases — anything else
|
|
148
|
+
propagates so real bugs surface (per the project rule against
|
|
149
|
+
over-broad fallback logic).
|
|
150
|
+
"""
|
|
151
|
+
# Mapping path (dict and dict-like). ``str``/``bytes`` are
|
|
152
|
+
# excluded because their ``__getitem__`` is positional and would
|
|
153
|
+
# raise ``TypeError`` for non-integer keys.
|
|
154
|
+
if hasattr(layer, "__getitem__") and not isinstance(layer, (str, bytes)):
|
|
155
|
+
try:
|
|
156
|
+
return layer[name]
|
|
157
|
+
except (KeyError, TypeError):
|
|
158
|
+
pass
|
|
159
|
+
# Attribute path (modules, SimpleNamespace, regular objects).
|
|
160
|
+
return getattr(layer, name, None)
|
|
@@ -55,8 +55,8 @@ _PT: float = 72.27 / 25.4
|
|
|
55
55
|
# Helpers
|
|
56
56
|
# ---------------------------------------------------------------------------
|
|
57
57
|
|
|
58
|
-
def _unit_to_cm(u: Unit) -> float:
|
|
59
|
-
"""Convert a Unit (possibly compound/sum) to cm
|
|
58
|
+
def _unit_to_cm(u: Unit, axis: str = "height") -> float:
|
|
59
|
+
"""Convert a Unit (possibly compound/sum) to cm along *axis*.
|
|
60
60
|
|
|
61
61
|
``convert_height/width`` can't handle compound ``"sum"`` units
|
|
62
62
|
without a viewport context. This helper decomposes them into
|
|
@@ -70,8 +70,17 @@ def _unit_to_cm(u: Unit) -> float:
|
|
|
70
70
|
|
|
71
71
|
The ``data`` element is itself a multi-element Unit with the
|
|
72
72
|
individual operands.
|
|
73
|
+
|
|
74
|
+
Parameters
|
|
75
|
+
----------
|
|
76
|
+
u : Unit
|
|
77
|
+
Unit to convert.
|
|
78
|
+
axis : {"height", "width"}
|
|
79
|
+
Axis to measure along — needed for viewport-relative units
|
|
80
|
+
(``"npc"``) which resolve differently per axis in non-square
|
|
81
|
+
viewports. Compound sums propagate the axis to children.
|
|
73
82
|
"""
|
|
74
|
-
from grid_py import convert_height
|
|
83
|
+
from grid_py import convert_height, convert_width
|
|
75
84
|
|
|
76
85
|
units = getattr(u, "_units", None)
|
|
77
86
|
values = getattr(u, "_values", None)
|
|
@@ -79,6 +88,8 @@ def _unit_to_cm(u: Unit) -> float:
|
|
|
79
88
|
if units is None or values is None:
|
|
80
89
|
return 0.0
|
|
81
90
|
|
|
91
|
+
fn = convert_width if axis == "width" else convert_height
|
|
92
|
+
|
|
82
93
|
total_cm = 0.0
|
|
83
94
|
n = len(u)
|
|
84
95
|
for i in range(n):
|
|
@@ -88,7 +99,7 @@ def _unit_to_cm(u: Unit) -> float:
|
|
|
88
99
|
# The operands are stored as a multi-element Unit in data[i]
|
|
89
100
|
inner = data[i] if data and i < len(data) else None
|
|
90
101
|
if inner is not None and isinstance(inner, Unit):
|
|
91
|
-
total_cm += _unit_to_cm(inner)
|
|
102
|
+
total_cm += _unit_to_cm(inner, axis)
|
|
92
103
|
continue
|
|
93
104
|
|
|
94
105
|
# Skip context-dependent units we can't resolve statically
|
|
@@ -99,7 +110,7 @@ def _unit_to_cm(u: Unit) -> float:
|
|
|
99
110
|
val = float(values[i]) if i < len(values) else 0.0
|
|
100
111
|
leaf = Unit(val, unit_type)
|
|
101
112
|
try:
|
|
102
|
-
cm =
|
|
113
|
+
cm = fn(leaf, "cm", valueOnly=True)
|
|
103
114
|
total_cm += float(np.sum(cm))
|
|
104
115
|
except Exception:
|
|
105
116
|
pass
|
|
@@ -125,12 +136,12 @@ def _width_cm(x: Any) -> float:
|
|
|
125
136
|
# For compound (sum) units, convert_width returns bogus results;
|
|
126
137
|
# decompose and convert leaf-by-leaf instead.
|
|
127
138
|
if _has_sum_unit(u):
|
|
128
|
-
return _unit_to_cm(u)
|
|
139
|
+
return _unit_to_cm(u, "width")
|
|
129
140
|
try:
|
|
130
141
|
result = convert_width(u, "cm", valueOnly=True)
|
|
131
142
|
return float(np.sum(result))
|
|
132
143
|
except Exception:
|
|
133
|
-
return _unit_to_cm(u)
|
|
144
|
+
return _unit_to_cm(u, "width")
|
|
134
145
|
|
|
135
146
|
|
|
136
147
|
def _height_cm(x: Any) -> float:
|
|
@@ -143,12 +154,12 @@ def _height_cm(x: Any) -> float:
|
|
|
143
154
|
else:
|
|
144
155
|
return 0.0
|
|
145
156
|
if _has_sum_unit(u):
|
|
146
|
-
return _unit_to_cm(u)
|
|
157
|
+
return _unit_to_cm(u, "height")
|
|
147
158
|
try:
|
|
148
159
|
result = convert_height(u, "cm", valueOnly=True)
|
|
149
160
|
return float(np.sum(result))
|
|
150
161
|
except Exception:
|
|
151
|
-
return _unit_to_cm(u)
|
|
162
|
+
return _unit_to_cm(u, "height")
|
|
152
163
|
|
|
153
164
|
|
|
154
165
|
# ---------------------------------------------------------------------------
|
|
@@ -243,6 +254,15 @@ def draw_axis(
|
|
|
243
254
|
minor_tick_length = tick_length * 0.5 # R: axis.minor.ticks.length = rel(0.75)
|
|
244
255
|
|
|
245
256
|
# --- Build axis line (R: GuideAxis$build_decor, lines 313-322) -----
|
|
257
|
+
# R: the line is ``element_grob(elements$line, ...)``; when ``axis.line``
|
|
258
|
+
# is ``element_blank()`` (the default in theme_grey/theme_bw) this yields a
|
|
259
|
+
# zeroGrob — i.e. *no* line. The previous fallback dict drew a spurious
|
|
260
|
+
# grey20 line regardless, which is invisible at a panel edge but crosses
|
|
261
|
+
# the interior for a rotated radial axis. Honour the blank element.
|
|
262
|
+
from ggplot2_py.theme_elements import calc_element as _calc_el
|
|
263
|
+
_raw_line_el = _calc_el(f"axis.line.{aes}", theme)
|
|
264
|
+
_line_blank = _raw_line_el is None or _is_blank(_raw_line_el)
|
|
265
|
+
|
|
246
266
|
if cap == "none" or len(breaks) == 0:
|
|
247
267
|
line_start, line_end = 0.0, 1.0
|
|
248
268
|
else:
|
|
@@ -250,7 +270,9 @@ def draw_axis(
|
|
|
250
270
|
line_end = max(breaks) if cap in ("both", "upper") else 1.0
|
|
251
271
|
|
|
252
272
|
line_lwd = float(line_el.get("linewidth", 0.5)) * _PT
|
|
253
|
-
if
|
|
273
|
+
if _line_blank:
|
|
274
|
+
axis_line = null_grob(name="axis.line")
|
|
275
|
+
elif is_horizontal:
|
|
254
276
|
axis_line = segments_grob(
|
|
255
277
|
x0=[line_start], y0=[orth_side],
|
|
256
278
|
x1=[line_end], y1=[orth_side],
|
|
@@ -681,7 +681,7 @@ def assemble_colourbar(
|
|
|
681
681
|
gt = gtable_add_grob(gt, label_tree, t=1, l=1, clip="off", name="labels")
|
|
682
682
|
|
|
683
683
|
# Add title
|
|
684
|
-
from ggplot2_py.
|
|
684
|
+
from ggplot2_py._guide_legend import add_legend_title
|
|
685
685
|
gt = add_legend_title(gt, title_grob, position="top")
|
|
686
686
|
|
|
687
687
|
# Add padding
|
|
@@ -25,6 +25,8 @@ from grid_py import (
|
|
|
25
25
|
Gpar,
|
|
26
26
|
Unit,
|
|
27
27
|
Viewport,
|
|
28
|
+
convert_height,
|
|
29
|
+
convert_width,
|
|
28
30
|
null_grob,
|
|
29
31
|
rect_grob,
|
|
30
32
|
text_grob,
|
|
@@ -930,8 +932,8 @@ def package_legend_box(
|
|
|
930
932
|
max_width_cm = 0.0
|
|
931
933
|
heights_cm: List[float] = []
|
|
932
934
|
for lg in legends:
|
|
933
|
-
max_width_cm = max(max_width_cm, _gtable_total_cm(lg.widths))
|
|
934
|
-
heights_cm.append(_gtable_total_cm(lg.heights))
|
|
935
|
+
max_width_cm = max(max_width_cm, _gtable_total_cm(lg.widths, "width"))
|
|
936
|
+
heights_cm.append(_gtable_total_cm(lg.heights, "height"))
|
|
935
937
|
guides = gtable_col(
|
|
936
938
|
name="guides",
|
|
937
939
|
grobs=legends,
|
|
@@ -944,8 +946,8 @@ def package_legend_box(
|
|
|
944
946
|
max_height_cm = 0.0
|
|
945
947
|
widths_cm: List[float] = []
|
|
946
948
|
for lg in legends:
|
|
947
|
-
max_height_cm = max(max_height_cm, _gtable_total_cm(lg.heights))
|
|
948
|
-
widths_cm.append(_gtable_total_cm(lg.widths))
|
|
949
|
+
max_height_cm = max(max_height_cm, _gtable_total_cm(lg.heights, "height"))
|
|
950
|
+
widths_cm.append(_gtable_total_cm(lg.widths, "width"))
|
|
949
951
|
guides = gtable_row(
|
|
950
952
|
name="guides",
|
|
951
953
|
grobs=legends,
|
|
@@ -1040,30 +1042,23 @@ def _interleave(
|
|
|
1040
1042
|
return result
|
|
1041
1043
|
|
|
1042
1044
|
|
|
1043
|
-
def _gtable_total_cm(unit: Optional[Unit]) -> float:
|
|
1044
|
-
"""Sum a Unit vector
|
|
1045
|
+
def _gtable_total_cm(unit: Optional[Unit], axis: str = "height") -> float:
|
|
1046
|
+
"""Sum a Unit vector along *axis* (``"height"`` or ``"width"``),
|
|
1047
|
+
returning cm as a float.
|
|
1045
1048
|
|
|
1046
|
-
|
|
1047
|
-
|
|
1049
|
+
Delegates to :func:`grid_py.convert_height` / :func:`grid_py.convert_width`
|
|
1050
|
+
so font-relative units (``"lines"``, ``"char"``, ``"strwidth"``,
|
|
1051
|
+
``"strheight"``) resolve against the active gp / device — matching R's
|
|
1052
|
+
``sum(convertHeight(unit, "cm", valueOnly=TRUE))`` / ``convertWidth``.
|
|
1053
|
+
|
|
1054
|
+
Axis matters for viewport-relative units (``"npc"``): for a
|
|
1055
|
+
non-square viewport, ``convert_height`` and ``convert_width`` resolve
|
|
1056
|
+
``unit(0.5, "npc")`` to different cm values. Callers must pass the
|
|
1057
|
+
axis the unit is being measured along — typically ``"width"`` for
|
|
1058
|
+
gtable widths, ``"height"`` for gtable heights.
|
|
1048
1059
|
"""
|
|
1049
1060
|
if unit is None or len(unit) == 0:
|
|
1050
1061
|
return 0.0
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
vals = part.values if hasattr(part, "values") else [0.0]
|
|
1055
|
-
units = part.units if hasattr(part, "units") else ["cm"]
|
|
1056
|
-
v = vals[0] if vals else 0.0
|
|
1057
|
-
u = units[0] if units else "cm"
|
|
1058
|
-
if u == "cm":
|
|
1059
|
-
total += v
|
|
1060
|
-
elif u == "mm":
|
|
1061
|
-
total += v / 10.0
|
|
1062
|
-
elif u == "inches":
|
|
1063
|
-
total += v * 2.54
|
|
1064
|
-
elif u == "pt" or u == "points":
|
|
1065
|
-
total += v / 72.27 * 2.54
|
|
1066
|
-
else:
|
|
1067
|
-
# null, npc, etc. — use the numeric value as a rough estimate
|
|
1068
|
-
total += v
|
|
1069
|
-
return total
|
|
1062
|
+
fn = convert_width if axis == "width" else convert_height
|
|
1063
|
+
cm = fn(unit, "cm", valueOnly=True)
|
|
1064
|
+
return float(np.sum(np.asarray(cm)))
|