ggh4x-python 0.3.1.9000__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.
- ggh4x/__init__.py +140 -0
- ggh4x/_aimed_text_grob.py +432 -0
- ggh4x/_borrowed_ggplot2.py +273 -0
- ggh4x/_cli.py +84 -0
- ggh4x/_datasets.py +106 -0
- ggh4x/_download.py +111 -0
- ggh4x/_facet_helpers.py +313 -0
- ggh4x/_facet_utils.py +649 -0
- ggh4x/_gap_grobs.py +606 -0
- ggh4x/_registry.py +10 -0
- ggh4x/_rlang.py +93 -0
- ggh4x/_utils.py +150 -0
- ggh4x/_vctrs.py +233 -0
- ggh4x/conveniences.py +601 -0
- ggh4x/coord_axes_inside.py +380 -0
- ggh4x/element_part_rect.py +545 -0
- ggh4x/facet_grid2.py +1018 -0
- ggh4x/facet_manual.py +901 -0
- ggh4x/facet_nested.py +776 -0
- ggh4x/facet_nested_wrap.py +193 -0
- ggh4x/facet_wrap2.py +896 -0
- ggh4x/geom_box.py +536 -0
- ggh4x/geom_outline_point.py +444 -0
- ggh4x/geom_pointpath.py +259 -0
- ggh4x/geom_polygonraster.py +252 -0
- ggh4x/geom_rectrug.py +489 -0
- ggh4x/geom_text_aimed.py +279 -0
- ggh4x/guide_stringlegend.py +354 -0
- ggh4x/help_secondary.py +549 -0
- ggh4x/multiscale/__init__.py +51 -0
- ggh4x/multiscale/_multiscale_add.py +207 -0
- ggh4x/multiscale/scale_listed.py +167 -0
- ggh4x/multiscale/scale_manual.py +478 -0
- ggh4x/multiscale/scale_multi.py +393 -0
- ggh4x/panel_scales/__init__.py +58 -0
- ggh4x/panel_scales/at_panel.py +115 -0
- ggh4x/panel_scales/facetted_pos_scales.py +647 -0
- ggh4x/panel_scales/force_panelsize.py +411 -0
- ggh4x/panel_scales/scale_facet.py +222 -0
- ggh4x/position_disjoint_ranges.py +229 -0
- ggh4x/position_lineartrans.py +242 -0
- ggh4x/py.typed +0 -0
- ggh4x/resources/faithful.csv +273 -0
- ggh4x/resources/iris.csv +151 -0
- ggh4x/resources/mtcars.csv +33 -0
- ggh4x/resources/pressure.csv +20 -0
- ggh4x/resources/volcano.csv +87 -0
- ggh4x/save.py +255 -0
- ggh4x/stat_difference.py +388 -0
- ggh4x/stat_funxy.py +436 -0
- ggh4x/stat_rle.py +290 -0
- ggh4x/stat_rollingkernel.py +369 -0
- ggh4x/stat_theodensity.py +681 -0
- ggh4x/strip_nested.py +448 -0
- ggh4x/strip_split.py +687 -0
- ggh4x/strip_tag.py +636 -0
- ggh4x/strip_themed.py +232 -0
- ggh4x/strip_vanilla.py +1464 -0
- ggh4x/themes.py +31 -0
- ggh4x/themes_ggh4x.py +67 -0
- ggh4x_python-0.3.1.9000.dist-info/METADATA +40 -0
- ggh4x_python-0.3.1.9000.dist-info/RECORD +64 -0
- ggh4x_python-0.3.1.9000.dist-info/WHEEL +4 -0
- ggh4x_python-0.3.1.9000.dist-info/licenses/LICENSE +3 -0
ggh4x/facet_nested.py
ADDED
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
"""Nested-strip grid facets (port of ggh4x ``R/facet_nested.R``).
|
|
2
|
+
|
|
3
|
+
``facet_nested()`` behaves like :func:`ggh4x.facet_grid2` but merges adjacent
|
|
4
|
+
strips that share an (outer) faceting-variable value into a single spanning
|
|
5
|
+
strip, and -- unlike ``facet_grid()`` -- only auto-expands a missing faceting
|
|
6
|
+
variable when there is **no** variable in that direction at all (so partially
|
|
7
|
+
faceted layers are allowed, the defining feature of nesting). Hierarchy lines
|
|
8
|
+
(``nest_line``) are drawn between strip layers to indicate the grouping.
|
|
9
|
+
|
|
10
|
+
The :class:`FacetNested` ggproto subclasses :class:`ggh4x.facet_grid2.FacetGrid2`
|
|
11
|
+
and overrides three seams:
|
|
12
|
+
|
|
13
|
+
* :meth:`FacetNested.map_data` -- assign data rows to panels, treating a
|
|
14
|
+
faceting variable as "missing" (and force-expanding) only when *no* variable
|
|
15
|
+
in its direction is present in the layer (R: ``facet_nested.R:130-187``).
|
|
16
|
+
* :meth:`FacetNested.vars_combine` -- build the cross-product base of facet
|
|
17
|
+
values, permitting layers missing some vars by blank-filling (``""``) the
|
|
18
|
+
absent columns rather than erroring (R: ``facet_nested.R:188-234``).
|
|
19
|
+
* :meth:`FacetNested.finish_panels` -- draw the nest indicator lines via
|
|
20
|
+
:func:`add_nest_indicator` (R: ``facet_nested.R:236-238``).
|
|
21
|
+
|
|
22
|
+
The default strip is :func:`ggh4x.strip_nested.strip_nested` (the label-merging
|
|
23
|
+
nested strip), matching R's ``strip = "nested"``.
|
|
24
|
+
|
|
25
|
+
R source: ``ggh4x/R/facet_nested.R``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import warnings
|
|
31
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
32
|
+
|
|
33
|
+
import numpy as np
|
|
34
|
+
import pandas as pd
|
|
35
|
+
|
|
36
|
+
from ggplot2_py.theme_elements import (
|
|
37
|
+
ElementBlank,
|
|
38
|
+
calc_element,
|
|
39
|
+
combine_elements,
|
|
40
|
+
element_blank,
|
|
41
|
+
element_grob,
|
|
42
|
+
element_line,
|
|
43
|
+
is_theme_element,
|
|
44
|
+
)
|
|
45
|
+
from grid_py import Unit, unit_c
|
|
46
|
+
from gtable_py import gtable_add_grob
|
|
47
|
+
|
|
48
|
+
from ggh4x._borrowed_ggplot2 import empty, id, unique_combs
|
|
49
|
+
from ggh4x._cli import cli_abort
|
|
50
|
+
from ggh4x._facet_helpers import reshape_add_margins
|
|
51
|
+
from ggh4x._facet_utils import df_grid
|
|
52
|
+
from ggh4x.facet_grid2 import FacetGrid2, _as_name_list, new_grid_facets
|
|
53
|
+
from ggh4x.strip_nested import strip_nested
|
|
54
|
+
|
|
55
|
+
# Importing this module registers the ``ggh4x.facet.nestline`` theme element.
|
|
56
|
+
import ggh4x.themes_ggh4x # noqa: F401
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"facet_nested",
|
|
60
|
+
"FacetNested",
|
|
61
|
+
"add_nest_indicator",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Borrowed ggplot2 internals (eval_facets / join_keys) -- not exported by the
|
|
67
|
+
# sibling ports, so reimplemented here on resolved column-name lists.
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
def _eval_facets(
|
|
70
|
+
facets: Sequence[str],
|
|
71
|
+
data: pd.DataFrame,
|
|
72
|
+
possible_columns: Optional[Sequence[str]] = None,
|
|
73
|
+
) -> pd.DataFrame:
|
|
74
|
+
"""Evaluate the faceting variables against a layer's data.
|
|
75
|
+
|
|
76
|
+
Port of ggplot2's ``eval_facets`` (``borrowed_ggplot2.R:297-327``) reduced to
|
|
77
|
+
the resolved-column-name model: each name in *facets* that names a column of
|
|
78
|
+
*data* yields that column; names absent from *data* are dropped (so a layer
|
|
79
|
+
missing some faceting variables simply contributes fewer columns).
|
|
80
|
+
|
|
81
|
+
Parameters
|
|
82
|
+
----------
|
|
83
|
+
facets : sequence of str
|
|
84
|
+
Resolved faceting-variable names (e.g. ``["vs", "cyl"]``).
|
|
85
|
+
data : pandas.DataFrame
|
|
86
|
+
A single layer's data frame.
|
|
87
|
+
possible_columns : sequence of str, optional
|
|
88
|
+
The union of all layers' column names (R ``.possible_columns``); used only
|
|
89
|
+
to mirror the R signature -- evaluation here is purely by membership in
|
|
90
|
+
``data.columns``.
|
|
91
|
+
|
|
92
|
+
Returns
|
|
93
|
+
-------
|
|
94
|
+
pandas.DataFrame
|
|
95
|
+
One column per evaluated facet, in *facets* order, length ``len(data)``.
|
|
96
|
+
"""
|
|
97
|
+
cols: Dict[str, Any] = {}
|
|
98
|
+
for f in facets:
|
|
99
|
+
if isinstance(data, pd.DataFrame) and f in data.columns:
|
|
100
|
+
cols[f] = data[f].to_numpy()
|
|
101
|
+
return pd.DataFrame(cols, index=range(len(data)) if isinstance(data, pd.DataFrame) else None)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _join_keys(x: pd.DataFrame, y: pd.DataFrame, by: Sequence[str]) -> Dict[str, np.ndarray]:
|
|
105
|
+
"""Compute matchable integer keys for two frames over shared columns.
|
|
106
|
+
|
|
107
|
+
Port of ggplot2's ``join_keys`` (``borrowed_ggplot2.R:429-437``): row-bind
|
|
108
|
+
``x[by]`` and ``y[by]``, assign a single stable id over the combined frame
|
|
109
|
+
(via :func:`ggh4x._borrowed_ggplot2.id`), then split back into the ``x`` and
|
|
110
|
+
``y`` id vectors so that equal key-combinations share an id.
|
|
111
|
+
|
|
112
|
+
Parameters
|
|
113
|
+
----------
|
|
114
|
+
x, y : pandas.DataFrame
|
|
115
|
+
Frames sharing the *by* columns (already factor-coerced by the caller).
|
|
116
|
+
by : sequence of str
|
|
117
|
+
The columns to key on.
|
|
118
|
+
|
|
119
|
+
Returns
|
|
120
|
+
-------
|
|
121
|
+
dict
|
|
122
|
+
``{"x": ndarray, "y": ndarray, "n": int}`` -- the per-row ids of ``x`` and
|
|
123
|
+
``y`` and the total number of distinct combinations.
|
|
124
|
+
"""
|
|
125
|
+
by = list(by)
|
|
126
|
+
nx = len(x)
|
|
127
|
+
joint = pd.concat(
|
|
128
|
+
[x[by].reset_index(drop=True), y[by].reset_index(drop=True)],
|
|
129
|
+
ignore_index=True,
|
|
130
|
+
)
|
|
131
|
+
keys = id(joint, drop=True)
|
|
132
|
+
n = int(keys.n)
|
|
133
|
+
return {"x": np.asarray(keys[:nx]), "y": np.asarray(keys[nx:]), "n": n}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _as_r_character(value: Any) -> Any:
|
|
137
|
+
"""Stringify a value the way R's ``as.character`` would for facet labels.
|
|
138
|
+
|
|
139
|
+
R coerces a numeric like ``6`` (stored as a double) to ``"6"`` -- not
|
|
140
|
+
``"6.0"``. Mirror that: whole-valued floats lose the trailing ``.0``; ``NaN``
|
|
141
|
+
/ ``None`` pass through unchanged so downstream factor coercion still sees a
|
|
142
|
+
missing value.
|
|
143
|
+
|
|
144
|
+
Parameters
|
|
145
|
+
----------
|
|
146
|
+
value : Any
|
|
147
|
+
A scalar facet value.
|
|
148
|
+
|
|
149
|
+
Returns
|
|
150
|
+
-------
|
|
151
|
+
Any
|
|
152
|
+
The R-style character representation (or the original value if NA).
|
|
153
|
+
"""
|
|
154
|
+
if value is None or (isinstance(value, float) and np.isnan(value)):
|
|
155
|
+
return value
|
|
156
|
+
if isinstance(value, (int, np.integer)):
|
|
157
|
+
return str(int(value))
|
|
158
|
+
if isinstance(value, (float, np.floating)):
|
|
159
|
+
f = float(value)
|
|
160
|
+
return str(int(f)) if f.is_integer() else repr(f)
|
|
161
|
+
return str(value)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _as_factor_addNA(series: Any) -> pd.Categorical:
|
|
165
|
+
"""Coerce a column to a factor (a present NA handled downstream by ``id``).
|
|
166
|
+
|
|
167
|
+
Mirrors R ``addNA(as.factor(x), ifany = TRUE)`` for key matching. The
|
|
168
|
+
:func:`ggh4x._borrowed_ggplot2.id` used by :func:`_join_keys` already ports
|
|
169
|
+
``addNA(ifany = TRUE)`` (it gives a present NA its own extra level), so this
|
|
170
|
+
only needs to produce a clean :class:`pandas.Categorical`, preserving an
|
|
171
|
+
existing categorical's level order.
|
|
172
|
+
|
|
173
|
+
Parameters
|
|
174
|
+
----------
|
|
175
|
+
series : pandas.Series or array-like
|
|
176
|
+
A facet-value column.
|
|
177
|
+
|
|
178
|
+
Returns
|
|
179
|
+
-------
|
|
180
|
+
pandas.Categorical
|
|
181
|
+
The factor; a present NA becomes a matchable extra level via ``id``.
|
|
182
|
+
"""
|
|
183
|
+
if isinstance(series, pd.Categorical):
|
|
184
|
+
return series
|
|
185
|
+
if isinstance(series, pd.Series) and isinstance(series.dtype, pd.CategoricalDtype):
|
|
186
|
+
return pd.Categorical(series)
|
|
187
|
+
return pd.Categorical(series)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# Constructor
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
def facet_nested(
|
|
194
|
+
rows: Any = None,
|
|
195
|
+
cols: Any = None,
|
|
196
|
+
scales: Any = "fixed",
|
|
197
|
+
space: Any = "fixed",
|
|
198
|
+
axes: Any = "margins",
|
|
199
|
+
remove_labels: Any = "none",
|
|
200
|
+
independent: Any = "none",
|
|
201
|
+
shrink: bool = True,
|
|
202
|
+
labeller: Any = "label_value",
|
|
203
|
+
as_table: bool = True,
|
|
204
|
+
switch: Optional[str] = None,
|
|
205
|
+
drop: bool = True,
|
|
206
|
+
margins: Any = False,
|
|
207
|
+
nest_line: Any = None,
|
|
208
|
+
solo_line: bool = False,
|
|
209
|
+
resect: Any = None,
|
|
210
|
+
render_empty: bool = True,
|
|
211
|
+
strip: Any = strip_nested,
|
|
212
|
+
bleed: Optional[bool] = None,
|
|
213
|
+
) -> "FacetNested":
|
|
214
|
+
"""Layout panels in a grid with nested strips.
|
|
215
|
+
|
|
216
|
+
Port of ggh4x's ``facet_nested()`` (``R/facet_nested.R:60-120``). Inherits the
|
|
217
|
+
capabilities of :func:`ggh4x.facet_grid2` and adds label-merged nested strips
|
|
218
|
+
plus hierarchy ``nest_line``s. Unlike ``facet_grid()`` it only auto-expands a
|
|
219
|
+
missing faceting variable when there is no variable in that direction
|
|
220
|
+
(allowing partially faceted layers); at least one layer must still contain all
|
|
221
|
+
faceting variables.
|
|
222
|
+
|
|
223
|
+
Parameters
|
|
224
|
+
----------
|
|
225
|
+
rows, cols : formula / list / dict / None
|
|
226
|
+
Faceting variables for rows and columns. Variable order encodes the
|
|
227
|
+
hierarchy: the first is the outermost (furthest from the panels).
|
|
228
|
+
scales : {"fixed", "free_x", "free_y", "free"} or bool, default "fixed"
|
|
229
|
+
space : {"fixed", "free_x", "free_y", "free"} or bool, default "fixed"
|
|
230
|
+
axes : {"margins", "x", "y", "all"} or bool, default "margins"
|
|
231
|
+
remove_labels : {"none", "x", "y", "all"} or bool, default "none"
|
|
232
|
+
independent : {"none", "x", "y", "all"} or bool, default "none"
|
|
233
|
+
shrink : bool, default True
|
|
234
|
+
labeller : callable or str, default "label_value"
|
|
235
|
+
as_table : bool, default True
|
|
236
|
+
switch : {"x", "y", "both", None}, default None
|
|
237
|
+
drop : bool, default True
|
|
238
|
+
margins : bool or list of str, default False
|
|
239
|
+
nest_line : ElementLine / ElementBlank / bool / None, default None
|
|
240
|
+
The hierarchy line element. ``None`` is treated as R's default
|
|
241
|
+
``element_line(inherit_blank=True)`` (so it inherits the (blank) theme
|
|
242
|
+
element ``ggh4x.facet.nestline`` and draws nothing unless the theme turns
|
|
243
|
+
it on). ``True`` -> ``element_line()``; ``False`` -> ``element_blank()``.
|
|
244
|
+
solo_line : bool, default False
|
|
245
|
+
Draw nest lines on single-child parent strips too (``True``) or only on
|
|
246
|
+
multi-child parents (``False``).
|
|
247
|
+
resect : Unit or None, default None
|
|
248
|
+
How much to shorten each nest line at both ends. ``None`` -> ``0 mm``.
|
|
249
|
+
render_empty : bool, default True
|
|
250
|
+
strip : Strip or callable or str, default :func:`ggh4x.strip_nested.strip_nested`
|
|
251
|
+
The strip specification (defaults to the nested label-merging strip).
|
|
252
|
+
bleed : bool or None, default None
|
|
253
|
+
Deprecated. When given, emits a ``DeprecationWarning`` and forwards to the
|
|
254
|
+
resolved strip's ``bleed`` param (set it via ``strip_nested(bleed=...)``).
|
|
255
|
+
|
|
256
|
+
Returns
|
|
257
|
+
-------
|
|
258
|
+
FacetNested
|
|
259
|
+
A ggproto facet object that can be added to a plot.
|
|
260
|
+
"""
|
|
261
|
+
from ggh4x.strip_vanilla import resolve_strip
|
|
262
|
+
|
|
263
|
+
strip = resolve_strip(strip)
|
|
264
|
+
if bleed is not None:
|
|
265
|
+
warnings.warn(
|
|
266
|
+
"The `bleed` argument of `facet_nested()` is deprecated as of ggh4x "
|
|
267
|
+
"0.2.0. The `bleed` argument should be set in the `strip_nested()` "
|
|
268
|
+
"function instead.",
|
|
269
|
+
DeprecationWarning,
|
|
270
|
+
stacklevel=2,
|
|
271
|
+
)
|
|
272
|
+
strip.params["bleed"] = bool(bleed)
|
|
273
|
+
|
|
274
|
+
nest_line = _coerce_nest_line(nest_line)
|
|
275
|
+
if resect is None:
|
|
276
|
+
resect = Unit(0, "mm")
|
|
277
|
+
|
|
278
|
+
params = {
|
|
279
|
+
"nest_line": nest_line,
|
|
280
|
+
"solo_line": bool(solo_line),
|
|
281
|
+
"resect": resect,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return new_grid_facets(
|
|
285
|
+
rows,
|
|
286
|
+
cols,
|
|
287
|
+
scales,
|
|
288
|
+
space,
|
|
289
|
+
axes,
|
|
290
|
+
remove_labels,
|
|
291
|
+
independent,
|
|
292
|
+
shrink,
|
|
293
|
+
labeller,
|
|
294
|
+
as_table,
|
|
295
|
+
switch,
|
|
296
|
+
drop,
|
|
297
|
+
margins,
|
|
298
|
+
render_empty,
|
|
299
|
+
strip,
|
|
300
|
+
params=params,
|
|
301
|
+
super_=FacetNested,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _coerce_nest_line(nest_line: Any) -> Any:
|
|
306
|
+
"""Normalise the ``nest_line`` argument to an element (R ``facet_nested.R:91-104``).
|
|
307
|
+
|
|
308
|
+
``None`` reproduces R's constructor default ``element_line(inherit_blank =
|
|
309
|
+
TRUE)``; ``True`` -> ``element_line()``; ``False`` -> ``element_blank()``. Any
|
|
310
|
+
other value must be an :class:`ElementLine` or :class:`ElementBlank`, else an
|
|
311
|
+
error is raised.
|
|
312
|
+
|
|
313
|
+
Parameters
|
|
314
|
+
----------
|
|
315
|
+
nest_line : ElementLine / ElementBlank / bool / None
|
|
316
|
+
The user-supplied nest-line argument.
|
|
317
|
+
|
|
318
|
+
Returns
|
|
319
|
+
-------
|
|
320
|
+
Element
|
|
321
|
+
A validated line / blank element.
|
|
322
|
+
"""
|
|
323
|
+
if nest_line is None:
|
|
324
|
+
return element_line(inherit_blank=True)
|
|
325
|
+
if nest_line is True:
|
|
326
|
+
return element_line()
|
|
327
|
+
if nest_line is False:
|
|
328
|
+
return element_blank()
|
|
329
|
+
if not (
|
|
330
|
+
is_theme_element(nest_line, "line") or is_theme_element(nest_line, "blank")
|
|
331
|
+
):
|
|
332
|
+
cli_abort(
|
|
333
|
+
"The `nest_line` argument must be `element_blank` or inherit from "
|
|
334
|
+
"`element_line`."
|
|
335
|
+
)
|
|
336
|
+
return nest_line
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ---------------------------------------------------------------------------
|
|
340
|
+
# ggproto
|
|
341
|
+
# ---------------------------------------------------------------------------
|
|
342
|
+
class FacetNested(FacetGrid2):
|
|
343
|
+
"""Nested-strip grid facet ggproto (port of R ``FacetNested``).
|
|
344
|
+
|
|
345
|
+
Subclasses :class:`ggh4x.facet_grid2.FacetGrid2`. Overrides ``map_data`` (the
|
|
346
|
+
per-direction "missing only when no var in that direction" rule),
|
|
347
|
+
``vars_combine`` (blank-fill absent columns instead of erroring) and
|
|
348
|
+
``finish_panels`` (draw nest indicator lines).
|
|
349
|
+
|
|
350
|
+
Attributes
|
|
351
|
+
----------
|
|
352
|
+
shrink : bool
|
|
353
|
+
strip : Strip
|
|
354
|
+
Defaults to a :class:`ggh4x.strip_nested.StripNested`.
|
|
355
|
+
params : dict
|
|
356
|
+
Adds ``nest_line``, ``solo_line``, ``resect`` to the ``FacetGrid2`` params.
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
_class_name = "FacetNested"
|
|
360
|
+
|
|
361
|
+
# -- map_data -----------------------------------------------------------
|
|
362
|
+
def map_data(
|
|
363
|
+
self,
|
|
364
|
+
data: pd.DataFrame,
|
|
365
|
+
layout: pd.DataFrame,
|
|
366
|
+
params: Dict[str, Any],
|
|
367
|
+
) -> pd.DataFrame:
|
|
368
|
+
"""Assign data rows to panels with the nesting missing-var rule.
|
|
369
|
+
|
|
370
|
+
Port of R ``FacetNested$map_data`` (``facet_nested.R:130-187``). Differs
|
|
371
|
+
from the stock :meth:`ggplot2_py.facet.FacetGrid.map_data`: a faceting
|
|
372
|
+
variable is only treated as missing (and force-expanded across panels)
|
|
373
|
+
when *none* of the variables in its direction (rows / cols) are present in
|
|
374
|
+
the layer, which is what lets a partially faceted layer nest.
|
|
375
|
+
|
|
376
|
+
Parameters
|
|
377
|
+
----------
|
|
378
|
+
data : pandas.DataFrame
|
|
379
|
+
A single layer's data.
|
|
380
|
+
layout : pandas.DataFrame
|
|
381
|
+
The panel layout (carries ``PANEL`` + the faceting-var columns).
|
|
382
|
+
params : dict
|
|
383
|
+
Facet params (``rows``, ``cols``, ``margins``, ``_possible_columns``).
|
|
384
|
+
|
|
385
|
+
Returns
|
|
386
|
+
-------
|
|
387
|
+
pandas.DataFrame
|
|
388
|
+
*data* with an integer/categorical ``PANEL`` column.
|
|
389
|
+
"""
|
|
390
|
+
if empty(data):
|
|
391
|
+
out = data.copy() if isinstance(data, pd.DataFrame) else pd.DataFrame()
|
|
392
|
+
out["PANEL"] = pd.Series([], dtype="int64")
|
|
393
|
+
return out
|
|
394
|
+
|
|
395
|
+
rows = params.get("rows")
|
|
396
|
+
cols = params.get("cols")
|
|
397
|
+
row_names = _as_name_list(rows)
|
|
398
|
+
col_names = _as_name_list(cols)
|
|
399
|
+
vars_ = row_names + col_names
|
|
400
|
+
|
|
401
|
+
if len(vars_) == 0:
|
|
402
|
+
data = data.copy()
|
|
403
|
+
# R: data$PANEL <- layout$PANEL (recycles the single layout PANEL
|
|
404
|
+
# across all data rows when there are no faceting variables).
|
|
405
|
+
panel_vals = list(layout["PANEL"])
|
|
406
|
+
n = len(data)
|
|
407
|
+
if len(panel_vals) and n:
|
|
408
|
+
data["PANEL"] = [panel_vals[i % len(panel_vals)] for i in range(n)]
|
|
409
|
+
else:
|
|
410
|
+
data["PANEL"] = panel_vals
|
|
411
|
+
return data
|
|
412
|
+
|
|
413
|
+
possible_columns = params.get("_possible_columns")
|
|
414
|
+
margin_vars = [
|
|
415
|
+
[c for c in row_names if c in data.columns],
|
|
416
|
+
[c for c in col_names if c in data.columns],
|
|
417
|
+
]
|
|
418
|
+
|
|
419
|
+
data = reshape_add_margins(data, margin_vars, params.get("margins", False))
|
|
420
|
+
facet_vals = _eval_facets(vars_, data, possible_columns)
|
|
421
|
+
|
|
422
|
+
# Only set as missing if it has no variable in that direction.
|
|
423
|
+
missing_facets: List[str] = []
|
|
424
|
+
if not any(r in facet_vals.columns for r in row_names):
|
|
425
|
+
missing_facets += [r for r in row_names if r not in facet_vals.columns]
|
|
426
|
+
if not any(c in facet_vals.columns for c in col_names):
|
|
427
|
+
missing_facets += [c for c in col_names if c not in facet_vals.columns]
|
|
428
|
+
|
|
429
|
+
if len(missing_facets) > 0:
|
|
430
|
+
to_add = layout[missing_facets].drop_duplicates().reset_index(drop=True)
|
|
431
|
+
n_data = len(data)
|
|
432
|
+
n_add = len(to_add)
|
|
433
|
+
# data_rep = rep.int(1:nrow(data), nrow(to_add)) (data fastest)
|
|
434
|
+
data_rep = np.tile(np.arange(n_data), n_add)
|
|
435
|
+
# facet_rep = rep(1:nrow(to_add), each = nrow(data))
|
|
436
|
+
facet_rep = np.repeat(np.arange(n_add), n_data)
|
|
437
|
+
data = data.iloc[data_rep].reset_index(drop=True)
|
|
438
|
+
facet_vals = facet_vals.iloc[data_rep].reset_index(drop=True)
|
|
439
|
+
add_block = to_add.iloc[facet_rep].reset_index(drop=True)
|
|
440
|
+
facet_vals = pd.concat([facet_vals, add_block], axis=1)
|
|
441
|
+
|
|
442
|
+
data = data.copy()
|
|
443
|
+
if len(facet_vals) == 0:
|
|
444
|
+
data["PANEL"] = -1
|
|
445
|
+
return data
|
|
446
|
+
|
|
447
|
+
# ``by`` = vars that appear in facet_vals (intersection, in vars order).
|
|
448
|
+
by = [v for v in vars_ if v in facet_vals.columns and v in layout.columns]
|
|
449
|
+
# Factor-coerce + addNA on both sides (R: lapply(.., as.factor) then
|
|
450
|
+
# addNA(ifany=TRUE)). R's ``as.factor`` stringifies levels, so numeric
|
|
451
|
+
# facet values (``6``) on one side match the blank-filled character
|
|
452
|
+
# values (``"6"`` / ``""``) on the other -- normalise via
|
|
453
|
+
# :func:`_as_r_character` before factoring so both key columns align.
|
|
454
|
+
fv = facet_vals.copy()
|
|
455
|
+
lay = layout.copy()
|
|
456
|
+
for c in by:
|
|
457
|
+
fv[c] = _as_factor_addNA(fv[c].map(_as_r_character))
|
|
458
|
+
lay[c] = _as_factor_addNA(lay[c].map(_as_r_character))
|
|
459
|
+
keys = _join_keys(fv, lay, by=by)
|
|
460
|
+
# PANEL = layout$PANEL[match(keys$x, keys$y)]
|
|
461
|
+
panel_lookup: Dict[int, Any] = {}
|
|
462
|
+
layout_panel = list(layout["PANEL"])
|
|
463
|
+
for j, ky in enumerate(keys["y"]):
|
|
464
|
+
panel_lookup.setdefault(int(ky), layout_panel[j])
|
|
465
|
+
matched = [panel_lookup.get(int(kx)) for kx in keys["x"]]
|
|
466
|
+
data["PANEL"] = matched
|
|
467
|
+
return data
|
|
468
|
+
|
|
469
|
+
# -- vars_combine -------------------------------------------------------
|
|
470
|
+
def vars_combine(
|
|
471
|
+
self,
|
|
472
|
+
data: List[pd.DataFrame],
|
|
473
|
+
env: Any = None,
|
|
474
|
+
vars_: Any = None,
|
|
475
|
+
drop: bool = True,
|
|
476
|
+
) -> pd.DataFrame:
|
|
477
|
+
"""Combine faceting variables, blank-filling layers missing some vars.
|
|
478
|
+
|
|
479
|
+
Port of R ``FacetNested$vars_combine`` (``facet_nested.R:188-234``).
|
|
480
|
+
Builds the cross-product base from layers that provide *all* requested
|
|
481
|
+
variables (at least one must), then for each partial layer appends the
|
|
482
|
+
grid of its present-variable values against the base's other columns with
|
|
483
|
+
those absent columns set to the empty string ``""`` (line 227 -- the
|
|
484
|
+
divergence from vanilla ``combine_vars``).
|
|
485
|
+
|
|
486
|
+
Parameters
|
|
487
|
+
----------
|
|
488
|
+
data : list of DataFrame
|
|
489
|
+
The plot + layer data frames.
|
|
490
|
+
env : Any, optional
|
|
491
|
+
Unused (kept for R signature parity).
|
|
492
|
+
vars_ : dict or list of str
|
|
493
|
+
The faceting-variable names for this direction.
|
|
494
|
+
drop : bool, default True
|
|
495
|
+
Drop unused factor combinations.
|
|
496
|
+
|
|
497
|
+
Returns
|
|
498
|
+
-------
|
|
499
|
+
pandas.DataFrame
|
|
500
|
+
Unique combinations (with blank-filled partial-layer rows).
|
|
501
|
+
"""
|
|
502
|
+
names = _as_name_list(vars_)
|
|
503
|
+
if len(names) == 0:
|
|
504
|
+
return pd.DataFrame()
|
|
505
|
+
|
|
506
|
+
possible_columns = sorted(
|
|
507
|
+
{c for df in data if isinstance(df, pd.DataFrame) for c in df.columns}
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
values: List[pd.DataFrame] = []
|
|
511
|
+
for df in data:
|
|
512
|
+
v = _eval_facets(names, df, possible_columns)
|
|
513
|
+
if v.shape[1] > 0:
|
|
514
|
+
values.append(v)
|
|
515
|
+
|
|
516
|
+
has_all = [v.shape[1] == len(names) for v in values]
|
|
517
|
+
if not any(has_all):
|
|
518
|
+
missing_per = []
|
|
519
|
+
for v in values:
|
|
520
|
+
missing_per.append([n for n in names if n not in v.columns])
|
|
521
|
+
detail = "; ".join(
|
|
522
|
+
f"layer {i} is missing {m}" for i, m in enumerate(missing_per)
|
|
523
|
+
)
|
|
524
|
+
cli_abort(
|
|
525
|
+
"At least one layer must contain all faceting variables: "
|
|
526
|
+
f"{names}. {detail}"
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
base = (
|
|
530
|
+
pd.concat([v for v, h in zip(values, has_all) if h], ignore_index=True)
|
|
531
|
+
.drop_duplicates()
|
|
532
|
+
.reset_index(drop=True)
|
|
533
|
+
)
|
|
534
|
+
base = base[names]
|
|
535
|
+
if not drop:
|
|
536
|
+
base = unique_combs(base)
|
|
537
|
+
|
|
538
|
+
for v, h in zip(values, has_all):
|
|
539
|
+
if h or empty(v):
|
|
540
|
+
continue
|
|
541
|
+
present = [c for c in base.columns if c in v.columns]
|
|
542
|
+
absent = [c for c in base.columns if c not in v.columns]
|
|
543
|
+
# new = unique(value[intersect(names(base), names(value))]) (R:222).
|
|
544
|
+
new = v[present].drop_duplicates().reset_index(drop=True)
|
|
545
|
+
if drop:
|
|
546
|
+
new = unique_combs(new)
|
|
547
|
+
# old = base[setdiff(names(base), names(value))] -- ALL base rows of
|
|
548
|
+
# the absent columns (NOT deduplicated, R:221), blank-filled to ""
|
|
549
|
+
# (R:227). R's assignment also coerces the whole column to character;
|
|
550
|
+
# cast the absent columns of ``base`` to object so the rbind below
|
|
551
|
+
# yields a uniform string column (matching R's coercion).
|
|
552
|
+
old = base[absent].reset_index(drop=True)
|
|
553
|
+
for c in absent:
|
|
554
|
+
old[c] = ""
|
|
555
|
+
# R's ``old[...] <- ""`` coerces the whole column to character,
|
|
556
|
+
# which then propagates to ``base`` through ``rbind``; mirror that
|
|
557
|
+
# by stringifying the existing (e.g. numeric) ``base`` values so
|
|
558
|
+
# the concatenated column is a uniform character vector.
|
|
559
|
+
base[c] = base[c].map(_as_r_character)
|
|
560
|
+
grid = df_grid(old, new)
|
|
561
|
+
base = pd.concat([base, grid[base.columns]], ignore_index=True)
|
|
562
|
+
|
|
563
|
+
# R does NOT deduplicate after the partial-layer rbind (facet_nested.R:228);
|
|
564
|
+
# the downstream ``compute_layout`` collapses duplicates. Keep parity.
|
|
565
|
+
base = base.reset_index(drop=True)
|
|
566
|
+
if empty(base):
|
|
567
|
+
cli_abort("Facetting variables must have at least one value.")
|
|
568
|
+
return base
|
|
569
|
+
|
|
570
|
+
# -- finish_panels ------------------------------------------------------
|
|
571
|
+
def finish_panels(
|
|
572
|
+
self,
|
|
573
|
+
panels: Any,
|
|
574
|
+
layout: pd.DataFrame,
|
|
575
|
+
params: Dict[str, Any],
|
|
576
|
+
theme: Any,
|
|
577
|
+
) -> Any:
|
|
578
|
+
"""Draw the nest indicator lines onto the assembled panel table.
|
|
579
|
+
|
|
580
|
+
Port of R ``FacetNested$finish_panels`` (``facet_nested.R:236-238``);
|
|
581
|
+
delegates to :func:`add_nest_indicator`.
|
|
582
|
+
|
|
583
|
+
Parameters
|
|
584
|
+
----------
|
|
585
|
+
panels : Gtable
|
|
586
|
+
The assembled panel gtable.
|
|
587
|
+
layout : pandas.DataFrame
|
|
588
|
+
params : dict
|
|
589
|
+
theme : Theme
|
|
590
|
+
|
|
591
|
+
Returns
|
|
592
|
+
-------
|
|
593
|
+
Gtable
|
|
594
|
+
The panel gtable with nest-line ``"nester"`` grobs added.
|
|
595
|
+
"""
|
|
596
|
+
return add_nest_indicator(panels, params, theme)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
# ---------------------------------------------------------------------------
|
|
600
|
+
# Nest-indicator helper (shared by FacetNested + FacetNestedWrap)
|
|
601
|
+
# ---------------------------------------------------------------------------
|
|
602
|
+
def _layout_df(table: Any) -> pd.DataFrame:
|
|
603
|
+
"""Return a gtable layout (dict-of-lists) as a DataFrame with a 1-based index.
|
|
604
|
+
|
|
605
|
+
Adds an ``index`` column equal to the 1-based position of each layout entry
|
|
606
|
+
(R ``layout$index <- seq_len(nrow(layout))``), so the positional index can be
|
|
607
|
+
carried through the ``startswith("strip-")`` filter back to ``panels.grobs`` /
|
|
608
|
+
``panels.layout`` (which remain dict-of-lists / list and are mutated in place).
|
|
609
|
+
|
|
610
|
+
Parameters
|
|
611
|
+
----------
|
|
612
|
+
table : Gtable
|
|
613
|
+
|
|
614
|
+
Returns
|
|
615
|
+
-------
|
|
616
|
+
pandas.DataFrame
|
|
617
|
+
The layout with an added 1-based integer ``index`` column.
|
|
618
|
+
"""
|
|
619
|
+
lay = table.layout
|
|
620
|
+
df = pd.DataFrame({k: list(v) for k, v in lay.items()})
|
|
621
|
+
df["index"] = np.arange(1, len(df) + 1)
|
|
622
|
+
return df
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def add_nest_indicator(panels: Any, params: Dict[str, Any], theme: Any) -> Any:
|
|
626
|
+
"""Draw hierarchy nest lines between strip layers of an assembled panel table.
|
|
627
|
+
|
|
628
|
+
Faithful port of ggh4x's ``add_nest_indicator`` (``facet_nested.R:243-354``).
|
|
629
|
+
Resolves the ``nest_line`` element (inheriting from the theme's
|
|
630
|
+
``ggh4x.facet.nestline``); returns *panels* unchanged when it is ``None`` /
|
|
631
|
+
``False`` / blank. Otherwise, for the horizontal (top/bottom) and vertical
|
|
632
|
+
(left/right) strips it draws a shortened polyline (``resect``) along the inner
|
|
633
|
+
edge of every *parent* (multi-child, ``l != r`` / ``t != b``) strip -- or, when
|
|
634
|
+
``solo_line`` is set, every strip except the innermost layer -- by adding a
|
|
635
|
+
``"nester"`` grob onto that strip's sub-gtable. It then bumps
|
|
636
|
+
``panels.layout['z']`` by the exact z-offset (R lines 300-307 / 343-350) so the
|
|
637
|
+
line-carrying strip layer paints above the lower strip layers.
|
|
638
|
+
|
|
639
|
+
Parameters
|
|
640
|
+
----------
|
|
641
|
+
panels : Gtable
|
|
642
|
+
The assembled panel gtable; its ``strip-*`` cells must be per-layer
|
|
643
|
+
sub-gtables (as produced by the nested strip subsystem).
|
|
644
|
+
params : dict
|
|
645
|
+
Facet params; reads ``nest_line``, ``solo_line``, ``resect``.
|
|
646
|
+
theme : Theme
|
|
647
|
+
The resolved plot theme (for ``ggh4x.facet.nestline``).
|
|
648
|
+
|
|
649
|
+
Returns
|
|
650
|
+
-------
|
|
651
|
+
Gtable
|
|
652
|
+
*panels* with the nest-line grobs added and z-order adjusted.
|
|
653
|
+
"""
|
|
654
|
+
nest_line = params.get("nest_line")
|
|
655
|
+
if nest_line is None or nest_line is False:
|
|
656
|
+
return panels
|
|
657
|
+
nest_line = combine_elements(nest_line, calc_element("ggh4x.facet.nestline", theme))
|
|
658
|
+
if is_theme_element(nest_line, "blank") or isinstance(nest_line, ElementBlank):
|
|
659
|
+
return panels
|
|
660
|
+
solo = bool(params.get("solo_line"))
|
|
661
|
+
|
|
662
|
+
# Locate strips (1-based ``index`` carried through the filter).
|
|
663
|
+
layout = _layout_df(panels)
|
|
664
|
+
names = layout["name"].astype(str)
|
|
665
|
+
is_strip = names.str.startswith("strip-")
|
|
666
|
+
layout = layout[is_strip].reset_index(drop=True)
|
|
667
|
+
|
|
668
|
+
resect = params.get("resect")
|
|
669
|
+
if resect is None:
|
|
670
|
+
resect = Unit(0, "mm")
|
|
671
|
+
# active = unit(c(0, 1), "npc") + c(1, -1) * resect
|
|
672
|
+
active = Unit([0, 1], "npc") + unit_c(1.0 * resect, -1.0 * resect)
|
|
673
|
+
|
|
674
|
+
# -- Horizontal (top/bottom) strips -------------------------------------
|
|
675
|
+
h_strip = layout
|
|
676
|
+
if not solo:
|
|
677
|
+
h_strip = h_strip[h_strip["l"] != h_strip["r"]]
|
|
678
|
+
else:
|
|
679
|
+
hn = h_strip["name"].astype(str)
|
|
680
|
+
h_strip = h_strip[hn.str.startswith("strip-b") | hn.str.startswith("strip-t")]
|
|
681
|
+
if len(h_strip) > 0:
|
|
682
|
+
index = [int(i) for i in h_strip["index"]]
|
|
683
|
+
is_secondary = bool(
|
|
684
|
+
h_strip["name"].astype(str).str.startswith("strip-b").any()
|
|
685
|
+
)
|
|
686
|
+
passive = [float(is_secondary), float(is_secondary)]
|
|
687
|
+
indicator = element_grob(
|
|
688
|
+
nest_line, x=active, y=passive, default_units="npc"
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
if solo:
|
|
692
|
+
kept: List[int] = []
|
|
693
|
+
for idx in index:
|
|
694
|
+
gt = panels.grobs[idx - 1]
|
|
695
|
+
pos = 1 if is_secondary else int(gt.shape[0])
|
|
696
|
+
if int(gt.layout["t"][0]) != pos:
|
|
697
|
+
kept.append(idx)
|
|
698
|
+
index = kept
|
|
699
|
+
|
|
700
|
+
for idx in index:
|
|
701
|
+
gt = panels.grobs[idx - 1]
|
|
702
|
+
s = {k: gt.layout[k][0] for k in ("t", "l", "r", "b", "z")}
|
|
703
|
+
gt = gtable_add_grob(
|
|
704
|
+
gt,
|
|
705
|
+
indicator,
|
|
706
|
+
t=int(s["t"]),
|
|
707
|
+
l=int(s["l"]),
|
|
708
|
+
b=int(s["b"]),
|
|
709
|
+
r=int(s["r"]),
|
|
710
|
+
z=s["z"],
|
|
711
|
+
name="nester",
|
|
712
|
+
clip="off",
|
|
713
|
+
)
|
|
714
|
+
panels.grobs[idx - 1] = gt
|
|
715
|
+
|
|
716
|
+
if index:
|
|
717
|
+
offset = [int(panels.grobs[idx - 1].layout["t"][0]) for idx in index]
|
|
718
|
+
if not is_secondary:
|
|
719
|
+
nlevels = int(panels.grobs[index[0] - 1].shape[0])
|
|
720
|
+
offset = [nlevels - o for o in offset]
|
|
721
|
+
z_list = panels.layout["z"]
|
|
722
|
+
for idx, off in zip(index, offset):
|
|
723
|
+
z_list[idx - 1] = z_list[idx - 1] + off
|
|
724
|
+
|
|
725
|
+
# -- Vertical (left/right) strips ---------------------------------------
|
|
726
|
+
v_strip = layout
|
|
727
|
+
if not solo:
|
|
728
|
+
v_strip = v_strip[v_strip["t"] != v_strip["b"]]
|
|
729
|
+
else:
|
|
730
|
+
vn = v_strip["name"].astype(str)
|
|
731
|
+
v_strip = v_strip[vn.str.startswith("strip-r") | vn.str.startswith("strip-l")]
|
|
732
|
+
if len(v_strip) > 0:
|
|
733
|
+
index = [int(i) for i in v_strip["index"]]
|
|
734
|
+
is_secondary = bool(
|
|
735
|
+
v_strip["name"].astype(str).str.startswith("strip-r").any()
|
|
736
|
+
)
|
|
737
|
+
passive = [float(not is_secondary), float(not is_secondary)]
|
|
738
|
+
indicator = element_grob(
|
|
739
|
+
nest_line, x=passive, y=active, default_units="npc"
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
if solo:
|
|
743
|
+
kept = []
|
|
744
|
+
for idx in index:
|
|
745
|
+
gt = panels.grobs[idx - 1]
|
|
746
|
+
pos = 1 if is_secondary else int(gt.shape[1])
|
|
747
|
+
if int(gt.layout["l"][0]) != pos:
|
|
748
|
+
kept.append(idx)
|
|
749
|
+
index = kept
|
|
750
|
+
|
|
751
|
+
for idx in index:
|
|
752
|
+
gt = panels.grobs[idx - 1]
|
|
753
|
+
s = {k: gt.layout[k][0] for k in ("t", "l", "r", "b", "z")}
|
|
754
|
+
gt = gtable_add_grob(
|
|
755
|
+
gt,
|
|
756
|
+
indicator,
|
|
757
|
+
t=int(s["t"]),
|
|
758
|
+
l=int(s["l"]),
|
|
759
|
+
b=int(s["b"]),
|
|
760
|
+
r=int(s["r"]),
|
|
761
|
+
z=s["z"],
|
|
762
|
+
name="nester",
|
|
763
|
+
clip="off",
|
|
764
|
+
)
|
|
765
|
+
panels.grobs[idx - 1] = gt
|
|
766
|
+
|
|
767
|
+
if index:
|
|
768
|
+
offset = [int(panels.grobs[idx - 1].layout["l"][0]) for idx in index]
|
|
769
|
+
if not is_secondary:
|
|
770
|
+
nlevels = int(panels.grobs[index[0] - 1].shape[1])
|
|
771
|
+
offset = [nlevels - o for o in offset]
|
|
772
|
+
z_list = panels.layout["z"]
|
|
773
|
+
for idx, off in zip(index, offset):
|
|
774
|
+
z_list[idx - 1] = z_list[idx - 1] + off
|
|
775
|
+
|
|
776
|
+
return panels
|