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_manual.py
ADDED
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
"""Manual panel layout facets (port of ggh4x ``R/facet_manual.R``).
|
|
2
|
+
|
|
3
|
+
``facet_manual`` lays panels out according to a user *design* (a character
|
|
4
|
+
art-string or an integer matrix), letting panels span arbitrary rectangles of a
|
|
5
|
+
cell grid. :class:`FacetManual` subclasses the ggh4x :class:`~ggh4x.facet_wrap2.FacetWrap2`
|
|
6
|
+
sibling (transitively :class:`ggplot2_py.facet.FacetWrap`), inheriting its
|
|
7
|
+
``setup_panel_table`` / ``finish_panels`` but fully overriding ``compute_layout``
|
|
8
|
+
(producing a span layout with ``.TOP``/``.RIGHT``/``.BOTTOM``/``.LEFT`` instead
|
|
9
|
+
of ``ROW``/``COL``), ``map_data``, ``setup_aspect_ratio``, ``setup_axes``,
|
|
10
|
+
``attach_axes`` and ``draw_panels``.
|
|
11
|
+
|
|
12
|
+
The design matrix is a 2-D NumPy integer array of 1-based panel ids with
|
|
13
|
+
``np.nan`` for blank cells, carrying a sidecar ``design_names`` list when the
|
|
14
|
+
design was character-coded with non-numeric labels.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
import pandas as pd
|
|
23
|
+
|
|
24
|
+
from ggplot2_py import calc_element, ggproto
|
|
25
|
+
from ggplot2_py.facet import _combine_vars, _resolve_facet_vars, facet_null, facet_wrap
|
|
26
|
+
from grid_py import Unit, is_unit, null_grob, unit_rep
|
|
27
|
+
|
|
28
|
+
from ggh4x._borrowed_ggplot2 import empty, id, snake_class
|
|
29
|
+
from ggh4x._cli import cli_abort, cli_warn
|
|
30
|
+
from ggh4x._facet_helpers import AspectRatio, _match_facet_arg
|
|
31
|
+
from ggh4x._facet_utils import (
|
|
32
|
+
render_axes,
|
|
33
|
+
split_heights_cm,
|
|
34
|
+
split_widths_cm,
|
|
35
|
+
weave_panel_cols,
|
|
36
|
+
weave_panel_rows,
|
|
37
|
+
)
|
|
38
|
+
from ggh4x._rlang import arg_match0
|
|
39
|
+
from ggh4x.facet_wrap2 import FacetWrap2, purge_guide_labels
|
|
40
|
+
from ggh4x.strip_vanilla import resolve_strip
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"facet_manual",
|
|
44
|
+
"FacetManual",
|
|
45
|
+
"_validate_design",
|
|
46
|
+
"_restrict_axes",
|
|
47
|
+
"_do_purge",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# validate_design (R facet_manual.R:381-437)
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
class _Design:
|
|
55
|
+
"""A validated design: an integer matrix plus optional ``design_names``.
|
|
56
|
+
|
|
57
|
+
Stands in for R's ``matrix`` with ``attr(., "design_names")``. Blank cells
|
|
58
|
+
are ``np.nan`` (the matrix is stored as float to admit ``nan``); ``names`` is
|
|
59
|
+
the sorted unique character labels when the design was character-coded, else
|
|
60
|
+
``None``.
|
|
61
|
+
|
|
62
|
+
Attributes
|
|
63
|
+
----------
|
|
64
|
+
matrix : numpy.ndarray
|
|
65
|
+
2-D float array of 1-based panel ids (``nan`` = blank).
|
|
66
|
+
names : list or None
|
|
67
|
+
The ``design_names`` sidecar.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, matrix: np.ndarray, names: Optional[List[Any]] = None) -> None:
|
|
71
|
+
self.matrix = matrix
|
|
72
|
+
self.names = names
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def shape(self) -> Any:
|
|
76
|
+
"""Return the design matrix shape ``(nrow, ncol)``."""
|
|
77
|
+
return self.matrix.shape
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _validate_design(design: Any = None, trim: bool = True) -> _Design:
|
|
81
|
+
"""Validate / normalise a *design* into an integer :class:`_Design`.
|
|
82
|
+
|
|
83
|
+
Faithful port of ggh4x's ``validate_design`` (``R/facet_manual.R:381-437``).
|
|
84
|
+
Character designs (patchwork-style art-strings) are split on newlines,
|
|
85
|
+
trimmed, and split per character (``'#'`` -> blank). Matrices use ``np.nan``
|
|
86
|
+
for blanks. The unique non-blank values are sorted and renumbered to
|
|
87
|
+
``1..k``; non-numeric labels are kept as ``design_names``. When ``trim`` is
|
|
88
|
+
``True`` the design is cropped to its non-empty rows / columns.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
design : str or array-like or None
|
|
93
|
+
The design specification.
|
|
94
|
+
trim : bool, default True
|
|
95
|
+
Whether to trim empty rows / columns.
|
|
96
|
+
|
|
97
|
+
Returns
|
|
98
|
+
-------
|
|
99
|
+
_Design
|
|
100
|
+
The validated design.
|
|
101
|
+
|
|
102
|
+
Raises
|
|
103
|
+
------
|
|
104
|
+
ValueError
|
|
105
|
+
When *design* is ``None``, non-rectangular, of invalid dimensions, or
|
|
106
|
+
not interpretable as a matrix.
|
|
107
|
+
"""
|
|
108
|
+
if design is None:
|
|
109
|
+
cli_abort("The `design` argument cannot be `None`.")
|
|
110
|
+
|
|
111
|
+
names: Optional[List[Any]] = None
|
|
112
|
+
|
|
113
|
+
# --- character art-string path (patchwork::as_areas) --------------------
|
|
114
|
+
if isinstance(design, str):
|
|
115
|
+
lines = design.split("\n")
|
|
116
|
+
lines = [s.strip() for s in lines]
|
|
117
|
+
lines = [s for s in lines if len(s) > 0]
|
|
118
|
+
rows = [list(s) for s in lines]
|
|
119
|
+
ncols = [len(r) for r in rows]
|
|
120
|
+
if len(set(ncols)) != 1:
|
|
121
|
+
cli_abort("The `design` argument must be rectangular.")
|
|
122
|
+
mat = np.array(rows, dtype=object)
|
|
123
|
+
else:
|
|
124
|
+
mat = np.asarray(design)
|
|
125
|
+
if mat.ndim != 2:
|
|
126
|
+
# Force to a column matrix (R as.matrix on an atomic vector).
|
|
127
|
+
mat = mat.reshape(-1, 1)
|
|
128
|
+
|
|
129
|
+
if mat.ndim != 2 or any(d < 1 for d in mat.shape):
|
|
130
|
+
cli_abort("The `design` argument has invalid dimensions.")
|
|
131
|
+
|
|
132
|
+
dim = mat.shape
|
|
133
|
+
|
|
134
|
+
# Character matrices: '#' -> blank (NA).
|
|
135
|
+
is_char = mat.dtype.kind in ("U", "S", "O") and any(
|
|
136
|
+
isinstance(v, str) for v in mat.flatten()
|
|
137
|
+
)
|
|
138
|
+
flat = mat.flatten()
|
|
139
|
+
if is_char:
|
|
140
|
+
flat = np.array(
|
|
141
|
+
[np.nan if (isinstance(v, str) and v == "#") else v for v in flat],
|
|
142
|
+
dtype=object,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# uniq = sort(unique(design)) over non-blank values.
|
|
146
|
+
def _is_blank(v: Any) -> bool:
|
|
147
|
+
if v is None:
|
|
148
|
+
return True
|
|
149
|
+
try:
|
|
150
|
+
return bool(np.isnan(v))
|
|
151
|
+
except (TypeError, ValueError):
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
non_blank = [v for v in flat if not _is_blank(v)]
|
|
155
|
+
uniq = sorted(set(non_blank), key=lambda z: (str(type(z)), z))
|
|
156
|
+
# design = match(design, uniq) -> 1-based ids, nan for blanks.
|
|
157
|
+
lookup = {v: i + 1 for i, v in enumerate(uniq)}
|
|
158
|
+
matched = np.array(
|
|
159
|
+
[float(lookup[v]) if not _is_blank(v) else np.nan for v in flat],
|
|
160
|
+
dtype=float,
|
|
161
|
+
).reshape(dim)
|
|
162
|
+
|
|
163
|
+
if trim:
|
|
164
|
+
non_empty = ~np.isnan(matched)
|
|
165
|
+
row_any = np.where(non_empty.any(axis=1))[0]
|
|
166
|
+
col_any = np.where(non_empty.any(axis=0))[0]
|
|
167
|
+
if len(row_any):
|
|
168
|
+
keep_row = list(range(int(row_any.min()), int(row_any.max()) + 1))
|
|
169
|
+
else:
|
|
170
|
+
keep_row = list(range(dim[0]))
|
|
171
|
+
if len(col_any):
|
|
172
|
+
keep_col = list(range(int(col_any.min()), int(col_any.max()) + 1))
|
|
173
|
+
else:
|
|
174
|
+
keep_col = list(range(dim[1]))
|
|
175
|
+
matched = matched[np.ix_(keep_row, keep_col)]
|
|
176
|
+
|
|
177
|
+
# Non-numeric uniques become design_names.
|
|
178
|
+
numeric_uniq = all(
|
|
179
|
+
isinstance(v, (int, float, np.integer, np.floating)) for v in uniq
|
|
180
|
+
)
|
|
181
|
+
if not numeric_uniq:
|
|
182
|
+
names = list(uniq)
|
|
183
|
+
|
|
184
|
+
return _Design(matched, names=names)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# restrict_axes (R facet_manual.R:442-453)
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
def _restrict_axes(
|
|
191
|
+
axes: List[Any],
|
|
192
|
+
position: Sequence[int],
|
|
193
|
+
by: Sequence[int],
|
|
194
|
+
which_fun: Any = min,
|
|
195
|
+
restrictor: Any = null_grob,
|
|
196
|
+
) -> List[Any]:
|
|
197
|
+
"""Keep only the edge-most axis per group, blanking / purging the rest.
|
|
198
|
+
|
|
199
|
+
Faithful port of ggh4x's ``restrict_axes`` (``R/facet_manual.R:442-453``).
|
|
200
|
+
Groups *axes* by *by*; within each group only the grob whose *position*
|
|
201
|
+
equals ``which_fun(group)`` is kept. Non-kept grobs are replaced: if
|
|
202
|
+
*restrictor* is callable it is applied per grob (e.g. ``purge_guide_labels``);
|
|
203
|
+
otherwise the value (e.g. ``null_grob()``) is assigned.
|
|
204
|
+
|
|
205
|
+
Parameters
|
|
206
|
+
----------
|
|
207
|
+
axes : list of grob
|
|
208
|
+
One rendered axis per panel.
|
|
209
|
+
position : sequence of int
|
|
210
|
+
Per-panel ``.TOP`` / ``.BOTTOM`` / ``.LEFT`` / ``.RIGHT`` span coordinate.
|
|
211
|
+
by : sequence of int
|
|
212
|
+
Grouping coordinate (the orthogonal span coordinate).
|
|
213
|
+
which_fun : callable, default min
|
|
214
|
+
``min`` (keep the smallest position) or ``max`` (keep the largest).
|
|
215
|
+
restrictor : callable or grob, default null_grob
|
|
216
|
+
Per-grob replacement function or replacement value.
|
|
217
|
+
|
|
218
|
+
Returns
|
|
219
|
+
-------
|
|
220
|
+
list of grob
|
|
221
|
+
The restricted axis list.
|
|
222
|
+
"""
|
|
223
|
+
axes = list(axes)
|
|
224
|
+
position = list(position)
|
|
225
|
+
by = list(by)
|
|
226
|
+
n = len(axes)
|
|
227
|
+
|
|
228
|
+
# keep[i] = (position[i] == which_fun(positions in same group))
|
|
229
|
+
group_target: Dict[Any, Any] = {}
|
|
230
|
+
grouped: Dict[Any, List[int]] = {}
|
|
231
|
+
for i, g in enumerate(by):
|
|
232
|
+
grouped.setdefault(g, []).append(i)
|
|
233
|
+
for g, idxs in grouped.items():
|
|
234
|
+
group_target[g] = which_fun([position[i] for i in idxs])
|
|
235
|
+
|
|
236
|
+
keep = [position[i] == group_target[by[i]] for i in range(n)]
|
|
237
|
+
|
|
238
|
+
is_callable = callable(restrictor)
|
|
239
|
+
for i in range(n):
|
|
240
|
+
if not keep[i]:
|
|
241
|
+
if is_callable:
|
|
242
|
+
axes[i] = restrictor(axes[i])
|
|
243
|
+
else:
|
|
244
|
+
axes[i] = restrictor() if callable(restrictor) else restrictor
|
|
245
|
+
return axes
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
# do_purge (R facet_manual.R:455-466)
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
def _do_purge(a: Sequence[Any], b: Sequence[Any], check_disjoint: bool = False) -> bool:
|
|
252
|
+
"""Decide whether axes can be purged across a span layout.
|
|
253
|
+
|
|
254
|
+
Faithful port of ggh4x's ``do_purge`` (``R/facet_manual.R:455-466``). Takes
|
|
255
|
+
the unique ``(a, b)`` pairs; returns ``True`` when those pairs are 1:1 with
|
|
256
|
+
both ``a`` and ``b`` (each appears once). When *check_disjoint* and more than
|
|
257
|
+
one pair exists, additionally requires the spans (ordered by ``(a, b)``) to be
|
|
258
|
+
non-overlapping (``cummax([0, b[:-1]]) < a``).
|
|
259
|
+
|
|
260
|
+
Parameters
|
|
261
|
+
----------
|
|
262
|
+
a, b : sequence
|
|
263
|
+
Paired span coordinates.
|
|
264
|
+
check_disjoint : bool, default False
|
|
265
|
+
Whether to additionally enforce non-overlapping spans.
|
|
266
|
+
|
|
267
|
+
Returns
|
|
268
|
+
-------
|
|
269
|
+
bool
|
|
270
|
+
"""
|
|
271
|
+
df = pd.DataFrame({"a": list(a), "b": list(b)}).drop_duplicates().reset_index(drop=True)
|
|
272
|
+
aa = df["a"].to_numpy()
|
|
273
|
+
bb = df["b"].to_numpy()
|
|
274
|
+
n = len(df)
|
|
275
|
+
ans = n == len(pd.unique(aa)) and n == len(pd.unique(bb))
|
|
276
|
+
if not check_disjoint or n == 1:
|
|
277
|
+
return bool(ans)
|
|
278
|
+
order = np.lexsort((bb, aa)) # order by a, then b
|
|
279
|
+
a_o = aa[order]
|
|
280
|
+
b_o = bb[order]
|
|
281
|
+
cum = np.maximum.accumulate(np.concatenate([[0], b_o[:-1]]))
|
|
282
|
+
return bool(ans and np.all(cum < a_o))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
# Constructor (R facet_manual.R:62-130)
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
def facet_manual(
|
|
289
|
+
facets: Any,
|
|
290
|
+
design: Any = None,
|
|
291
|
+
widths: Any = None,
|
|
292
|
+
heights: Any = None,
|
|
293
|
+
respect: bool = False,
|
|
294
|
+
drop: bool = True,
|
|
295
|
+
strip_position: str = "top",
|
|
296
|
+
scales: Any = "fixed",
|
|
297
|
+
axes: Any = "margins",
|
|
298
|
+
remove_labels: Any = "none",
|
|
299
|
+
labeller: Any = "label_value",
|
|
300
|
+
trim_blank: bool = True,
|
|
301
|
+
strip: Any = "vanilla",
|
|
302
|
+
) -> Any:
|
|
303
|
+
"""Manual layout for panels.
|
|
304
|
+
|
|
305
|
+
Faithful port of ggh4x's ``facet_manual`` (``R/facet_manual.R:62-130``).
|
|
306
|
+
Panels are placed according to *design* (a character art-string or integer
|
|
307
|
+
matrix), each panel spanning the bounding rectangle of its design cells.
|
|
308
|
+
|
|
309
|
+
Parameters
|
|
310
|
+
----------
|
|
311
|
+
facets : formula / list / dict / str
|
|
312
|
+
Faceting variables.
|
|
313
|
+
design : str or array-like
|
|
314
|
+
Panel-area specification (``'#'`` / ``NA`` mark blank cells).
|
|
315
|
+
widths, heights : numeric or grid_py.Unit or None, default None
|
|
316
|
+
Cell sizes; numerics become relative ``"null"`` units.
|
|
317
|
+
respect : bool, default False
|
|
318
|
+
Whether ``"null"`` widths / heights are proportional.
|
|
319
|
+
drop : bool, default True
|
|
320
|
+
Drop unused factor combinations.
|
|
321
|
+
strip_position : {"top", "bottom", "left", "right"}, default "top"
|
|
322
|
+
scales : {"fixed", "free_x", "free_y", "free"} or bool, default "fixed"
|
|
323
|
+
axes : {"margins", "x", "y", "all"} or bool, default "margins"
|
|
324
|
+
remove_labels : {"none", "x", "y", "all"} or bool, default "none"
|
|
325
|
+
labeller : callable or str, default "label_value"
|
|
326
|
+
trim_blank : bool, default True
|
|
327
|
+
Trim empty design rows / columns.
|
|
328
|
+
strip : Strip or callable or str, default "vanilla"
|
|
329
|
+
|
|
330
|
+
Returns
|
|
331
|
+
-------
|
|
332
|
+
FacetManual or FacetNull
|
|
333
|
+
A facet ggproto object; :func:`ggplot2_py.facet.facet_null` when *facets*
|
|
334
|
+
is empty.
|
|
335
|
+
"""
|
|
336
|
+
strip_position = arg_match0(
|
|
337
|
+
strip_position, ["top", "bottom", "left", "right"], arg_name="strip_position"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
design = _validate_design(design, trim_blank)
|
|
341
|
+
|
|
342
|
+
facets_resolved = _resolve_facet_vars(facet_wrap(facets=facets).params.get("facets"))
|
|
343
|
+
if len(facets_resolved) == 0:
|
|
344
|
+
return facet_null()
|
|
345
|
+
|
|
346
|
+
if widths is not None and not is_unit(widths):
|
|
347
|
+
widths = Unit(widths, "null")
|
|
348
|
+
if heights is not None and not is_unit(heights):
|
|
349
|
+
heights = Unit(heights, "null")
|
|
350
|
+
|
|
351
|
+
dim = design.shape # (nrow, ncol)
|
|
352
|
+
|
|
353
|
+
if widths is not None:
|
|
354
|
+
widths = unit_rep(widths, length_out=dim[1])
|
|
355
|
+
if heights is not None:
|
|
356
|
+
heights = unit_rep(heights, length_out=dim[0])
|
|
357
|
+
|
|
358
|
+
free = _match_facet_arg(scales, ["fixed", "free_x", "free_y", "free"], nm="scales")
|
|
359
|
+
axes = _match_facet_arg(axes, ["margins", "x", "y", "all"], nm="axes")
|
|
360
|
+
rmlab = _match_facet_arg(remove_labels, ["none", "x", "y", "all"], nm="remove_labels")
|
|
361
|
+
strip = resolve_strip(strip)
|
|
362
|
+
|
|
363
|
+
params: Dict[str, Any] = {
|
|
364
|
+
"design": design,
|
|
365
|
+
"facets": {name: name for name in facets_resolved},
|
|
366
|
+
"widths": widths,
|
|
367
|
+
"heights": heights,
|
|
368
|
+
"respect": respect,
|
|
369
|
+
"strip.position": strip_position,
|
|
370
|
+
"strip_position": strip_position,
|
|
371
|
+
"labeller": labeller,
|
|
372
|
+
"drop": drop,
|
|
373
|
+
"nrow": dim[0],
|
|
374
|
+
"ncol": dim[1],
|
|
375
|
+
"free": free,
|
|
376
|
+
"axes": axes,
|
|
377
|
+
"rmlab": rmlab,
|
|
378
|
+
"dim": [dim[0], dim[1]],
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
obj = FacetManual()
|
|
382
|
+
obj._set(shrink=True, strip=strip, params=params)
|
|
383
|
+
return obj
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
# ggproto class (R facet_manual.R:138-377)
|
|
388
|
+
# ---------------------------------------------------------------------------
|
|
389
|
+
class FacetManual(FacetWrap2):
|
|
390
|
+
"""Manual-layout facet ggproto (port of R ``FacetManual``).
|
|
391
|
+
|
|
392
|
+
Subclasses the ggh4x :class:`~ggh4x.facet_wrap2.FacetWrap2` sibling. Produces
|
|
393
|
+
a *span* layout (``.TOP``/``.RIGHT``/``.BOTTOM``/``.LEFT``, one row per unique
|
|
394
|
+
panel) rather than a ``ROW``/``COL`` grid, then weaves per-panel axes and
|
|
395
|
+
strips honouring those spans.
|
|
396
|
+
|
|
397
|
+
Attributes
|
|
398
|
+
----------
|
|
399
|
+
shrink : bool
|
|
400
|
+
strip : Strip
|
|
401
|
+
params : dict
|
|
402
|
+
"""
|
|
403
|
+
|
|
404
|
+
_class_name = "FacetManual"
|
|
405
|
+
|
|
406
|
+
shrink: bool = True
|
|
407
|
+
strip: Any = None
|
|
408
|
+
|
|
409
|
+
# -- compute_layout (R:141-201) -----------------------------------------
|
|
410
|
+
def compute_layout(self, data: List[pd.DataFrame], params: Dict[str, Any]) -> pd.DataFrame:
|
|
411
|
+
"""Translate the design matrix into a span layout.
|
|
412
|
+
|
|
413
|
+
Port of ggh4x's ``FacetManual$compute_layout`` (``R/facet_manual.R:141-201``).
|
|
414
|
+
|
|
415
|
+
Parameters
|
|
416
|
+
----------
|
|
417
|
+
data : list of DataFrame
|
|
418
|
+
params : dict
|
|
419
|
+
|
|
420
|
+
Returns
|
|
421
|
+
-------
|
|
422
|
+
pandas.DataFrame
|
|
423
|
+
Span layout with ``.TOP``/``.RIGHT``/``.BOTTOM``/``.LEFT``, ``PANEL``,
|
|
424
|
+
``SCALE_X``, ``SCALE_Y`` and the faceting-var columns.
|
|
425
|
+
"""
|
|
426
|
+
vars_ = _resolve_facet_vars(params.get("facets"))
|
|
427
|
+
if len(vars_) == 0:
|
|
428
|
+
return pd.DataFrame(
|
|
429
|
+
{
|
|
430
|
+
".TOP": [1],
|
|
431
|
+
".RIGHT": [1],
|
|
432
|
+
".BOTTOM": [1],
|
|
433
|
+
".LEFT": [1],
|
|
434
|
+
"PANEL": pd.Categorical([1]),
|
|
435
|
+
"SCALE_X": [1],
|
|
436
|
+
"SCALE_Y": [1],
|
|
437
|
+
}
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
design = params["design"]
|
|
441
|
+
mat = design.matrix
|
|
442
|
+
nrow, ncol = mat.shape
|
|
443
|
+
|
|
444
|
+
# split(row(design), design) -> per-id row range; same for cols.
|
|
445
|
+
row_idx = np.repeat(np.arange(1, nrow + 1)[:, None], ncol, axis=1)
|
|
446
|
+
col_idx = np.repeat(np.arange(1, ncol + 1)[None, :], nrow, axis=0)
|
|
447
|
+
flat_design = mat.flatten(order="F") # R fills column-major
|
|
448
|
+
flat_row = row_idx.flatten(order="F")
|
|
449
|
+
flat_col = col_idx.flatten(order="F")
|
|
450
|
+
|
|
451
|
+
# ids in first-occurrence order matching R's split() (sorted by id value).
|
|
452
|
+
valid = ~np.isnan(flat_design)
|
|
453
|
+
ids_present = sorted(set(int(v) for v in flat_design[valid]))
|
|
454
|
+
|
|
455
|
+
tops, rights, bottoms, lefts, panel_ids = [], [], [], [], []
|
|
456
|
+
for pid in ids_present:
|
|
457
|
+
mask = (flat_design == pid)
|
|
458
|
+
rows_g = flat_row[mask]
|
|
459
|
+
cols_g = flat_col[mask]
|
|
460
|
+
tops.append(int(rows_g.min()))
|
|
461
|
+
bottoms.append(int(rows_g.max()))
|
|
462
|
+
lefts.append(int(cols_g.min()))
|
|
463
|
+
rights.append(int(cols_g.max()))
|
|
464
|
+
panel_ids.append(pid)
|
|
465
|
+
|
|
466
|
+
layout = pd.DataFrame(
|
|
467
|
+
{
|
|
468
|
+
".TOP": tops,
|
|
469
|
+
".RIGHT": rights,
|
|
470
|
+
".BOTTOM": bottoms,
|
|
471
|
+
".LEFT": lefts,
|
|
472
|
+
"PANEL": pd.Categorical(panel_ids, categories=panel_ids),
|
|
473
|
+
}
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
base = _combine_vars(data, vars_, drop=params.get("drop", True))
|
|
477
|
+
base = base.reset_index(drop=True)
|
|
478
|
+
id_arr = id(base, drop=True)
|
|
479
|
+
n = int(id_arr.n)
|
|
480
|
+
|
|
481
|
+
if n > len(layout):
|
|
482
|
+
n = len(layout)
|
|
483
|
+
id_arr = np.asarray(id_arr)[:n]
|
|
484
|
+
dropped = base.apply(
|
|
485
|
+
lambda r: ":".join(str(x) for x in r), axis=1
|
|
486
|
+
).tolist()[n:]
|
|
487
|
+
cli_warn(
|
|
488
|
+
"Found more facetting levels than designed. The following levels "
|
|
489
|
+
"are dropped: " + ", ".join(dropped)
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
# R: lnames <- attr(layout, "design_names"). The design_names attribute
|
|
493
|
+
# is set on `design` (validate_design), NOT on `layout`, so in R this is
|
|
494
|
+
# always NULL and the partial-match warning + reorder below is dead code.
|
|
495
|
+
# Mirror R exactly by reading it from `layout` (which carries no such
|
|
496
|
+
# attr) so the block never fires -- reading `design.names` here produced
|
|
497
|
+
# a spurious "partial match" warning that R never emits.
|
|
498
|
+
lnames = getattr(layout, "attrs", {}).get("design_names")
|
|
499
|
+
if lnames is not None and len(base.columns) > 0:
|
|
500
|
+
first_col = base.iloc[:, 0]
|
|
501
|
+
isect = [v for v in lnames if v in set(first_col)]
|
|
502
|
+
if len(isect) != 0 and len(isect) != len(base):
|
|
503
|
+
cli_warn(
|
|
504
|
+
"Only partial match found between facetting levels and design levels."
|
|
505
|
+
)
|
|
506
|
+
elif len(isect) > 0:
|
|
507
|
+
# base <- base[match(base[[1]], isect), ]
|
|
508
|
+
order_map = {v: i for i, v in enumerate(isect)}
|
|
509
|
+
new_order = [order_map.get(v, len(isect)) for v in first_col]
|
|
510
|
+
base = base.iloc[np.argsort(np.argsort(new_order, kind="mergesort"))].reset_index(drop=True)
|
|
511
|
+
|
|
512
|
+
if n < len(layout):
|
|
513
|
+
panel_int = np.asarray(layout["PANEL"].astype(int))
|
|
514
|
+
keep = panel_int <= n
|
|
515
|
+
layout = layout.loc[keep].reset_index(drop=True)
|
|
516
|
+
kept_ids = [p for p in panel_ids if p <= n]
|
|
517
|
+
layout["PANEL"] = pd.Categorical(
|
|
518
|
+
layout["PANEL"].astype(int), categories=kept_ids
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
id_int = np.asarray(id_arr, dtype=int)
|
|
522
|
+
order = np.argsort(id_int, kind="mergesort") # base[order(id), ]
|
|
523
|
+
base_ordered = base.iloc[order].reset_index(drop=True)
|
|
524
|
+
|
|
525
|
+
panels = pd.concat(
|
|
526
|
+
[layout.reset_index(drop=True), base_ordered], axis=1
|
|
527
|
+
)
|
|
528
|
+
panels["SCALE_X"] = np.arange(1, n + 1) if params["free"]["x"] else 1
|
|
529
|
+
panels["SCALE_Y"] = np.arange(1, n + 1) if params["free"]["y"] else 1
|
|
530
|
+
|
|
531
|
+
# order(PANEL)
|
|
532
|
+
panel_int = np.asarray(panels["PANEL"].astype(int))
|
|
533
|
+
panels = panels.iloc[np.argsort(panel_int, kind="mergesort")].reset_index(drop=True)
|
|
534
|
+
return panels
|
|
535
|
+
|
|
536
|
+
# -- map_data (R:203-240) -----------------------------------------------
|
|
537
|
+
def map_data(self, data: pd.DataFrame, layout: pd.DataFrame, params: Dict[str, Any]) -> pd.DataFrame:
|
|
538
|
+
"""Assign ``PANEL`` to layer data by matching facet values to the layout.
|
|
539
|
+
|
|
540
|
+
Port of ggh4x's ``FacetManual$map_data`` (``R/facet_manual.R:203-240``).
|
|
541
|
+
|
|
542
|
+
Parameters
|
|
543
|
+
----------
|
|
544
|
+
data : pandas.DataFrame
|
|
545
|
+
layout : pandas.DataFrame
|
|
546
|
+
params : dict
|
|
547
|
+
|
|
548
|
+
Returns
|
|
549
|
+
-------
|
|
550
|
+
pandas.DataFrame
|
|
551
|
+
*data* with a ``PANEL`` column; rows not matching any panel dropped.
|
|
552
|
+
"""
|
|
553
|
+
if empty(data):
|
|
554
|
+
out = data.copy() if isinstance(data, pd.DataFrame) else pd.DataFrame()
|
|
555
|
+
out["PANEL"] = pd.Categorical([])
|
|
556
|
+
return out
|
|
557
|
+
|
|
558
|
+
vars_ = _resolve_facet_vars(params.get("facets"))
|
|
559
|
+
if len(vars_) == 0:
|
|
560
|
+
data = data.copy()
|
|
561
|
+
data["PANEL"] = list(layout["PANEL"])
|
|
562
|
+
return data
|
|
563
|
+
|
|
564
|
+
data = data.copy()
|
|
565
|
+
facet_vals = data[[v for v in vars_ if v in data.columns]].copy()
|
|
566
|
+
# Coerce facet vals + layout keys to factor (string) for matching.
|
|
567
|
+
for c in facet_vals.columns:
|
|
568
|
+
facet_vals[c] = facet_vals[c].astype(str)
|
|
569
|
+
layout = layout.copy()
|
|
570
|
+
lkeys = [v for v in vars_ if v in layout.columns]
|
|
571
|
+
|
|
572
|
+
missing_facets = [v for v in vars_ if v not in facet_vals.columns]
|
|
573
|
+
if missing_facets:
|
|
574
|
+
to_add = layout[missing_facets].drop_duplicates().reset_index(drop=True)
|
|
575
|
+
data_rep = np.repeat(np.arange(len(data)), len(to_add))
|
|
576
|
+
facet_rep = np.tile(np.arange(len(to_add)), len(data))
|
|
577
|
+
data = data.iloc[data_rep].reset_index(drop=True)
|
|
578
|
+
facet_vals = pd.concat(
|
|
579
|
+
[
|
|
580
|
+
facet_vals.iloc[data_rep].reset_index(drop=True),
|
|
581
|
+
to_add.iloc[facet_rep].reset_index(drop=True),
|
|
582
|
+
],
|
|
583
|
+
axis=1,
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
# join_keys: match facet_vals to layout on the faceting vars.
|
|
587
|
+
layout_keys = layout[lkeys].copy()
|
|
588
|
+
for c in layout_keys.columns:
|
|
589
|
+
layout_keys[c] = layout_keys[c].astype(str)
|
|
590
|
+
for c in facet_vals.columns:
|
|
591
|
+
facet_vals[c] = facet_vals[c].astype(str)
|
|
592
|
+
|
|
593
|
+
layout_keys = layout_keys.assign(_PANEL=list(layout["PANEL"]))
|
|
594
|
+
merged = facet_vals.merge(layout_keys, on=lkeys, how="left")
|
|
595
|
+
data["PANEL"] = pd.Categorical(
|
|
596
|
+
merged["_PANEL"].to_numpy(),
|
|
597
|
+
categories=list(layout["PANEL"].cat.categories)
|
|
598
|
+
if isinstance(layout["PANEL"].dtype, pd.CategoricalDtype)
|
|
599
|
+
else None,
|
|
600
|
+
)
|
|
601
|
+
data = data.loc[~data["PANEL"].isna()].reset_index(drop=True)
|
|
602
|
+
return data
|
|
603
|
+
|
|
604
|
+
# -- setup_aspect_ratio (R:242-252) -------------------------------------
|
|
605
|
+
def setup_aspect_ratio(
|
|
606
|
+
self,
|
|
607
|
+
coord: Any,
|
|
608
|
+
free: Dict[str, bool],
|
|
609
|
+
theme: Any,
|
|
610
|
+
ranges: Sequence[Any],
|
|
611
|
+
) -> AspectRatio:
|
|
612
|
+
"""Resolve the aspect ratio + ``respect`` flag.
|
|
613
|
+
|
|
614
|
+
Port of ggh4x's ``FacetManual$setup_aspect_ratio``
|
|
615
|
+
(``R/facet_manual.R:242-252``). Unlike :class:`FacetWrap2`, R returns
|
|
616
|
+
``NULL`` (not 1) in the free-scales / no-theme-aspect case, relying on the
|
|
617
|
+
inherited ``setup_panel_table`` to apply ``respect <- params$respect %||%
|
|
618
|
+
attr(aspect, "respect") %||% FALSE`` and ``heights <- params$heights %||%
|
|
619
|
+
unit(abs(aspect %||% 1), "null")``.
|
|
620
|
+
|
|
621
|
+
Because the Python :class:`FacetWrap2` ``setup_panel_table`` consumes an
|
|
622
|
+
:class:`AspectRatio` carrier (it reads ``aspect.value`` / ``aspect.respect``
|
|
623
|
+
and cannot accept ``None``), the ``NULL`` case is modelled here as
|
|
624
|
+
``AspectRatio(1.0, False)`` -- exactly R's ``aspect %||% 1`` (heights fall
|
|
625
|
+
back to ``unit(1, "null")``) and ``attr(NULL, "respect") %||% FALSE``
|
|
626
|
+
(``respect`` defers to ``params['respect']``, which ``facet_manual``
|
|
627
|
+
always sets to a concrete bool). The non-``NULL`` case carries
|
|
628
|
+
``respect=True`` like ``FacetWrap2``.
|
|
629
|
+
|
|
630
|
+
Parameters
|
|
631
|
+
----------
|
|
632
|
+
coord : Coord
|
|
633
|
+
free : dict
|
|
634
|
+
theme : Theme
|
|
635
|
+
ranges : sequence
|
|
636
|
+
|
|
637
|
+
Returns
|
|
638
|
+
-------
|
|
639
|
+
AspectRatio
|
|
640
|
+
``AspectRatio(1.0, False)`` for the R ``NULL`` case; otherwise the
|
|
641
|
+
theme/coord aspect with ``respect=True``.
|
|
642
|
+
"""
|
|
643
|
+
aspect_ratio = calc_element("aspect.ratio", theme) if theme is not None else None
|
|
644
|
+
if aspect_ratio is None and not free["x"] and not free["y"] and ranges:
|
|
645
|
+
aspect_ratio = coord.aspect(ranges[0])
|
|
646
|
+
if aspect_ratio is None:
|
|
647
|
+
# R: returns NULL -> setup_panel_table uses `abs(aspect %||% 1)` = 1
|
|
648
|
+
# and `params$respect %||% attr(NULL,"respect") %||% FALSE`.
|
|
649
|
+
return AspectRatio(1.0, False)
|
|
650
|
+
return AspectRatio(float(aspect_ratio), True)
|
|
651
|
+
|
|
652
|
+
# -- setup_axes (R:254-294) ---------------------------------------------
|
|
653
|
+
def setup_axes(
|
|
654
|
+
self,
|
|
655
|
+
axes: Dict[str, Any],
|
|
656
|
+
layout: pd.DataFrame,
|
|
657
|
+
params: Dict[str, Any],
|
|
658
|
+
theme: Any,
|
|
659
|
+
) -> pd.DataFrame:
|
|
660
|
+
"""Pick per-panel axis grobs and decide span-aware purging.
|
|
661
|
+
|
|
662
|
+
Port of ggh4x's ``FacetManual$setup_axes`` (``R/facet_manual.R:254-294``).
|
|
663
|
+
Returns a DataFrame of per-panel axis grobs keyed by ``t``/``b``/``l``/``r``
|
|
664
|
+
= PANEL index (NOT the matrix + measurements list of :class:`FacetWrap2`).
|
|
665
|
+
|
|
666
|
+
Parameters
|
|
667
|
+
----------
|
|
668
|
+
axes : dict
|
|
669
|
+
Transposed batch axes from :func:`render_axes`.
|
|
670
|
+
layout : pandas.DataFrame
|
|
671
|
+
params : dict
|
|
672
|
+
theme : Theme
|
|
673
|
+
|
|
674
|
+
Returns
|
|
675
|
+
-------
|
|
676
|
+
pandas.DataFrame
|
|
677
|
+
``{t, b, l, r, axes_top, axes_bottom, axes_left, axes_right}``.
|
|
678
|
+
"""
|
|
679
|
+
panel = [int(v) for v in layout["PANEL"].astype(int)]
|
|
680
|
+
scale_x = [int(v) for v in layout["SCALE_X"]]
|
|
681
|
+
scale_y = [int(v) for v in layout["SCALE_Y"]]
|
|
682
|
+
|
|
683
|
+
x_top = axes["x"]["top"]
|
|
684
|
+
x_bottom = axes["x"]["bottom"]
|
|
685
|
+
y_left = axes["y"]["left"]
|
|
686
|
+
y_right = axes["y"]["right"]
|
|
687
|
+
|
|
688
|
+
top = [x_top[i - 1] for i in scale_x]
|
|
689
|
+
bottom = [x_bottom[i - 1] for i in scale_x]
|
|
690
|
+
left = [y_left[i - 1] for i in scale_y]
|
|
691
|
+
right = [y_right[i - 1] for i in scale_y]
|
|
692
|
+
|
|
693
|
+
dot_top = list(layout[".TOP"])
|
|
694
|
+
dot_bottom = list(layout[".BOTTOM"])
|
|
695
|
+
dot_left = list(layout[".LEFT"])
|
|
696
|
+
dot_right = list(layout[".RIGHT"])
|
|
697
|
+
|
|
698
|
+
purge_x = (not params["free"]["x"]) and (params["rmlab"]["x"] or not params["axes"]["x"])
|
|
699
|
+
purge_y = (not params["free"]["y"]) and (params["rmlab"]["y"] or not params["axes"]["y"])
|
|
700
|
+
|
|
701
|
+
purge_x = purge_x and _do_purge(dot_left, dot_right)
|
|
702
|
+
purge_y = purge_y and _do_purge(dot_top, dot_bottom)
|
|
703
|
+
|
|
704
|
+
if purge_x:
|
|
705
|
+
purger = purge_guide_labels if params["rmlab"]["x"] else null_grob()
|
|
706
|
+
top = _restrict_axes(top, dot_top, dot_left, min, purger)
|
|
707
|
+
bottom = _restrict_axes(bottom, dot_bottom, dot_left, max, purger)
|
|
708
|
+
|
|
709
|
+
if purge_y:
|
|
710
|
+
purger = purge_guide_labels if params["rmlab"]["y"] else null_grob()
|
|
711
|
+
left = _restrict_axes(left, dot_left, dot_top, min, purger)
|
|
712
|
+
right = _restrict_axes(right, dot_right, dot_top, max, purger)
|
|
713
|
+
|
|
714
|
+
return pd.DataFrame(
|
|
715
|
+
{
|
|
716
|
+
"t": panel,
|
|
717
|
+
"b": panel,
|
|
718
|
+
"l": panel,
|
|
719
|
+
"r": panel,
|
|
720
|
+
"axes_top": top,
|
|
721
|
+
"axes_bottom": bottom,
|
|
722
|
+
"axes_left": left,
|
|
723
|
+
"axes_right": right,
|
|
724
|
+
}
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
# -- attach_axes (R:296-327) --------------------------------------------
|
|
728
|
+
def attach_axes(
|
|
729
|
+
self,
|
|
730
|
+
panels: Any,
|
|
731
|
+
axes: pd.DataFrame,
|
|
732
|
+
sizes: Dict[str, Unit],
|
|
733
|
+
params: Dict[str, Any],
|
|
734
|
+
inside: bool = True,
|
|
735
|
+
) -> Any:
|
|
736
|
+
"""Weave the four per-panel axis sides into the panel gtable.
|
|
737
|
+
|
|
738
|
+
Port of ggh4x's ``FacetManual$attach_axes`` (``R/facet_manual.R:296-327``).
|
|
739
|
+
Zeroes interior strip-side gaps when scales are fixed and the spans are
|
|
740
|
+
disjoint, then weaves each axis side via :func:`weave_panel_rows` /
|
|
741
|
+
:func:`weave_panel_cols`.
|
|
742
|
+
|
|
743
|
+
Parameters
|
|
744
|
+
----------
|
|
745
|
+
panels : Gtable
|
|
746
|
+
axes : pandas.DataFrame
|
|
747
|
+
The per-panel grob frame from :meth:`setup_axes`.
|
|
748
|
+
sizes : dict
|
|
749
|
+
``{top, bottom, left, right}`` size unit vectors.
|
|
750
|
+
params : dict
|
|
751
|
+
inside : bool, default True
|
|
752
|
+
Whether ``strip.placement`` is ``"inside"``.
|
|
753
|
+
|
|
754
|
+
Returns
|
|
755
|
+
-------
|
|
756
|
+
Gtable
|
|
757
|
+
"""
|
|
758
|
+
panel_layout = _panel_layout(panels)
|
|
759
|
+
strip_pos = params.get("strip.position", params.get("strip_position", "top"))
|
|
760
|
+
|
|
761
|
+
if (not params["free"]["y"]) and _do_purge(panel_layout["t"], panel_layout["b"], True):
|
|
762
|
+
if inside or strip_pos != "left":
|
|
763
|
+
# sizes$left[-1] <- 0 (drop first element)
|
|
764
|
+
_zero_unit_slice(sizes, "left", drop="first")
|
|
765
|
+
if inside or strip_pos != "right":
|
|
766
|
+
_zero_unit_slice(sizes, "right", drop="last")
|
|
767
|
+
if (not params["free"]["x"]) and _do_purge(panel_layout["l"], panel_layout["r"], True):
|
|
768
|
+
# NOTE: R has a missing brace here so only the first line is the
|
|
769
|
+
# conditional body; the second `if` always runs. Reproduce faithfully.
|
|
770
|
+
if inside or strip_pos != "bottom":
|
|
771
|
+
_zero_unit_slice(sizes, "bottom", drop="last")
|
|
772
|
+
if inside or strip_pos != "top":
|
|
773
|
+
_zero_unit_slice(sizes, "top", drop="first")
|
|
774
|
+
|
|
775
|
+
panels = weave_panel_rows(
|
|
776
|
+
panels, axes, -1, sizes["top"], "axis-t", 3, "off", "t", "axes_top"
|
|
777
|
+
)
|
|
778
|
+
panels = weave_panel_rows(
|
|
779
|
+
panels, axes, 0, sizes["bottom"], "axis-b", 3, "off", "b", "axes_bottom"
|
|
780
|
+
)
|
|
781
|
+
panels = weave_panel_cols(
|
|
782
|
+
panels, axes, -1, sizes["left"], "axis-l", 3, "off", "l", "axes_left"
|
|
783
|
+
)
|
|
784
|
+
panels = weave_panel_cols(
|
|
785
|
+
panels, axes, 0, sizes["right"], "axis-r", 3, "off", "r", "axes_right"
|
|
786
|
+
)
|
|
787
|
+
return panels
|
|
788
|
+
|
|
789
|
+
# -- draw_panels (R:329-376) --------------------------------------------
|
|
790
|
+
def draw_panels(
|
|
791
|
+
self,
|
|
792
|
+
panels: list,
|
|
793
|
+
layout: pd.DataFrame,
|
|
794
|
+
x_scales: list,
|
|
795
|
+
y_scales: list,
|
|
796
|
+
ranges: list,
|
|
797
|
+
coord: Any,
|
|
798
|
+
data: Any,
|
|
799
|
+
theme: Any,
|
|
800
|
+
params: Dict[str, Any],
|
|
801
|
+
) -> Any:
|
|
802
|
+
"""Assemble the manual panel gtable (full replacement of the base pipeline).
|
|
803
|
+
|
|
804
|
+
Port of ggh4x's ``FacetManual$draw_panels`` (``R/facet_manual.R:329-376``).
|
|
805
|
+
|
|
806
|
+
Parameters
|
|
807
|
+
----------
|
|
808
|
+
panels : list
|
|
809
|
+
layout : pandas.DataFrame
|
|
810
|
+
x_scales, y_scales : list
|
|
811
|
+
ranges : list
|
|
812
|
+
coord : Coord
|
|
813
|
+
data : Any
|
|
814
|
+
theme : Theme
|
|
815
|
+
params : dict
|
|
816
|
+
|
|
817
|
+
Returns
|
|
818
|
+
-------
|
|
819
|
+
Gtable
|
|
820
|
+
"""
|
|
821
|
+
if (params["free"]["x"] or params["free"]["y"]) and not coord.is_free():
|
|
822
|
+
cli_abort(f"`{snake_class(coord)}` doesn't support free scales.")
|
|
823
|
+
|
|
824
|
+
strip = self.strip
|
|
825
|
+
|
|
826
|
+
# Decorate per-layer grobs into one panel grob per PANEL.
|
|
827
|
+
from ggh4x.facet_grid2 import _decorate_panels
|
|
828
|
+
|
|
829
|
+
panel_grobs = _decorate_panels(panels, layout, ranges, coord, theme)
|
|
830
|
+
|
|
831
|
+
# Inherited FacetWrap2.setup_panel_table reads .TOP/.BOTTOM/.LEFT/.RIGHT.
|
|
832
|
+
panel_table = self.setup_panel_table(
|
|
833
|
+
panel_grobs, layout, theme, coord, ranges, params
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
axes = render_axes(ranges, ranges, coord, theme, transpose=True)
|
|
837
|
+
axes = self.setup_axes(axes, layout, params, theme)
|
|
838
|
+
|
|
839
|
+
panel_pos = _panel_layout(panel_table)
|
|
840
|
+
sizes = {
|
|
841
|
+
"top": split_heights_cm(list(axes["axes_top"]), split=panel_pos["t"]),
|
|
842
|
+
"bottom": split_heights_cm(list(axes["axes_bottom"]), split=panel_pos["b"]),
|
|
843
|
+
"left": split_widths_cm(list(axes["axes_left"]), split=panel_pos["l"]),
|
|
844
|
+
"right": split_widths_cm(list(axes["axes_right"]), split=panel_pos["r"]),
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
strip_placement = calc_element("strip.placement", theme) if theme is not None else None
|
|
848
|
+
inside = (strip_placement if strip_placement is not None else "inside") == "inside"
|
|
849
|
+
panel_table = self.attach_axes(panel_table, axes, sizes, params, inside=inside)
|
|
850
|
+
|
|
851
|
+
# Synthesize ROW/COL late so Strip.setup(type="wrap") can treat the span
|
|
852
|
+
# layout like a wrap layout.
|
|
853
|
+
strip_pos = params.get("strip.position", params.get("strip_position", "top"))
|
|
854
|
+
simplify = {
|
|
855
|
+
"top": (".TOP", ".LEFT"),
|
|
856
|
+
"bottom": (".BOTTOM", ".LEFT"),
|
|
857
|
+
"left": (".TOP", ".LEFT"),
|
|
858
|
+
"right": (".TOP", ".RIGHT"),
|
|
859
|
+
}[strip_pos]
|
|
860
|
+
layout = layout.copy()
|
|
861
|
+
layout["ROW"] = layout[simplify[0]].to_numpy()
|
|
862
|
+
layout["COL"] = layout[simplify[1]].to_numpy()
|
|
863
|
+
|
|
864
|
+
strip.setup(layout, params, theme, type="wrap")
|
|
865
|
+
panel_table = strip.incorporate_wrap(
|
|
866
|
+
panel_table, strip_pos, clip=coord.clip, sizes=sizes
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
return self.finish_panels(
|
|
870
|
+
panels=panel_table, layout=layout, params=params, theme=theme
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
# ---------------------------------------------------------------------------
|
|
875
|
+
# Module-private helpers
|
|
876
|
+
# ---------------------------------------------------------------------------
|
|
877
|
+
def _panel_layout(table: Any) -> pd.DataFrame:
|
|
878
|
+
"""Return the ``"panel-*"`` rows of *table*'s layout as a DataFrame (t/b/l/r)."""
|
|
879
|
+
lay = table.layout
|
|
880
|
+
if not isinstance(lay, pd.DataFrame):
|
|
881
|
+
lay = pd.DataFrame({k: list(v) for k, v in lay.items()})
|
|
882
|
+
mask = lay["name"].astype(str).str.match(r"^panel")
|
|
883
|
+
return lay.loc[mask, ["t", "b", "l", "r"]].reset_index(drop=True)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _zero_unit_slice(sizes: Dict[str, Unit], key: str, drop: str) -> None:
|
|
887
|
+
"""Zero all-but-one elements of ``sizes[key]`` in place (R negative-index drop).
|
|
888
|
+
|
|
889
|
+
``drop="first"`` zeroes elements ``[1:]`` (R ``x[-1] <- 0``); ``drop="last"``
|
|
890
|
+
zeroes elements ``[:-1]`` (R ``x[-length(x)] <- 0``).
|
|
891
|
+
"""
|
|
892
|
+
u = sizes[key]
|
|
893
|
+
n = len(u)
|
|
894
|
+
if n <= 1:
|
|
895
|
+
return
|
|
896
|
+
if drop == "first":
|
|
897
|
+
for i in range(1, n):
|
|
898
|
+
u[i] = Unit(0, "cm")
|
|
899
|
+
else: # last
|
|
900
|
+
for i in range(0, n - 1):
|
|
901
|
+
u[i] = Unit(0, "cm")
|