ggplot2-python 4.0.2.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.
- ggplot2_py/__init__.py +852 -0
- ggplot2_py/_compat.py +475 -0
- ggplot2_py/_plugins.py +129 -0
- ggplot2_py/_utils.py +544 -0
- ggplot2_py/aes.py +586 -0
- ggplot2_py/annotation.py +540 -0
- ggplot2_py/coord.py +2108 -0
- ggplot2_py/coords/__init__.py +49 -0
- ggplot2_py/datasets.py +265 -0
- ggplot2_py/draw_key.py +454 -0
- ggplot2_py/facet.py +1456 -0
- ggplot2_py/fortify.py +95 -0
- ggplot2_py/geom.py +4516 -0
- ggplot2_py/geoms/__init__.py +12 -0
- ggplot2_py/ggproto.py +279 -0
- ggplot2_py/guide.py +2925 -0
- ggplot2_py/guide_axis.py +615 -0
- ggplot2_py/guide_colourbar.py +657 -0
- ggplot2_py/guide_legend.py +1061 -0
- ggplot2_py/guides/__init__.py +8 -0
- ggplot2_py/labeller.py +296 -0
- ggplot2_py/labels.py +309 -0
- ggplot2_py/layer.py +954 -0
- ggplot2_py/layout.py +754 -0
- ggplot2_py/limits.py +314 -0
- ggplot2_py/plot.py +1401 -0
- ggplot2_py/plot_render.py +866 -0
- ggplot2_py/position.py +1269 -0
- ggplot2_py/protocols.py +171 -0
- ggplot2_py/py.typed +0 -0
- ggplot2_py/qplot.py +233 -0
- ggplot2_py/resources/diamonds.csv +53941 -0
- ggplot2_py/resources/economics.csv +575 -0
- ggplot2_py/resources/economics_long.csv +2871 -0
- ggplot2_py/resources/faithfuld.csv +5626 -0
- ggplot2_py/resources/luv_colours.csv +658 -0
- ggplot2_py/resources/midwest.csv +438 -0
- ggplot2_py/resources/mpg.csv +235 -0
- ggplot2_py/resources/msleep.csv +84 -0
- ggplot2_py/resources/presidential.csv +13 -0
- ggplot2_py/resources/seals.csv +1156 -0
- ggplot2_py/resources/txhousing.csv +8603 -0
- ggplot2_py/save.py +316 -0
- ggplot2_py/scale.py +2727 -0
- ggplot2_py/scales/__init__.py +4252 -0
- ggplot2_py/stat.py +6071 -0
- ggplot2_py/stats/__init__.py +9 -0
- ggplot2_py/theme.py +490 -0
- ggplot2_py/theme_defaults.py +1350 -0
- ggplot2_py/theme_elements.py +2052 -0
- ggplot2_python-4.0.2.9000.dist-info/METADATA +179 -0
- ggplot2_python-4.0.2.9000.dist-info/RECORD +54 -0
- ggplot2_python-4.0.2.9000.dist-info/WHEEL +4 -0
- ggplot2_python-4.0.2.9000.dist-info/licenses/LICENSE +3 -0
ggplot2_py/position.py
ADDED
|
@@ -0,0 +1,1269 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Position adjustments for ggplot2.
|
|
3
|
+
|
|
4
|
+
Position adjustments control how overlapping geoms are arranged.
|
|
5
|
+
Each position is a GGProto object with ``setup_params``,
|
|
6
|
+
``setup_data``, ``compute_layer``, and ``compute_panel`` methods.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
|
|
17
|
+
from ggplot2_py._compat import Waiver, is_waiver, waiver, cli_abort, cli_warn
|
|
18
|
+
from ggplot2_py.ggproto import GGProto, ggproto
|
|
19
|
+
from ggplot2_py._utils import snake_class, compact, empty
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Position",
|
|
23
|
+
"PositionIdentity",
|
|
24
|
+
"PositionDodge",
|
|
25
|
+
"PositionDodge2",
|
|
26
|
+
"PositionJitter",
|
|
27
|
+
"PositionJitterdodge",
|
|
28
|
+
"PositionNudge",
|
|
29
|
+
"PositionStack",
|
|
30
|
+
"PositionFill",
|
|
31
|
+
"position_identity",
|
|
32
|
+
"position_dodge",
|
|
33
|
+
"position_dodge2",
|
|
34
|
+
"position_jitter",
|
|
35
|
+
"position_jitterdodge",
|
|
36
|
+
"position_nudge",
|
|
37
|
+
"position_stack",
|
|
38
|
+
"position_fill",
|
|
39
|
+
"is_position",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Helpers
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
def _transform_position(
|
|
48
|
+
df: pd.DataFrame,
|
|
49
|
+
trans_x: Optional[Callable] = None,
|
|
50
|
+
trans_y: Optional[Callable] = None,
|
|
51
|
+
) -> pd.DataFrame:
|
|
52
|
+
"""Apply transformation functions to position aesthetics.
|
|
53
|
+
|
|
54
|
+
Parameters
|
|
55
|
+
----------
|
|
56
|
+
df : pd.DataFrame
|
|
57
|
+
trans_x, trans_y : callable or None
|
|
58
|
+
|
|
59
|
+
Returns
|
|
60
|
+
-------
|
|
61
|
+
pd.DataFrame
|
|
62
|
+
"""
|
|
63
|
+
df = df.copy()
|
|
64
|
+
x_cols = [c for c in df.columns if c in ("x", "xmin", "xmax", "xend", "xintercept")]
|
|
65
|
+
y_cols = [c for c in df.columns if c in ("y", "ymin", "ymax", "yend", "yintercept")]
|
|
66
|
+
if trans_x is not None:
|
|
67
|
+
for c in x_cols:
|
|
68
|
+
df[c] = trans_x(df[c].values)
|
|
69
|
+
if trans_y is not None:
|
|
70
|
+
for c in y_cols:
|
|
71
|
+
df[c] = trans_y(df[c].values)
|
|
72
|
+
return df
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _check_required_aesthetics(
|
|
76
|
+
required: Sequence[str],
|
|
77
|
+
present: Sequence[str],
|
|
78
|
+
name: str,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Check that required aesthetics are present.
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
required : sequence of str
|
|
85
|
+
Aesthetic names, possibly with ``|`` for alternatives.
|
|
86
|
+
present : sequence of str
|
|
87
|
+
name : str
|
|
88
|
+
Name of the component for error messages.
|
|
89
|
+
|
|
90
|
+
Raises
|
|
91
|
+
------
|
|
92
|
+
ValueError
|
|
93
|
+
If a required aesthetic is missing.
|
|
94
|
+
"""
|
|
95
|
+
present_set = set(present)
|
|
96
|
+
for req in required:
|
|
97
|
+
alternatives = req.split("|")
|
|
98
|
+
if not any(a in present_set for a in alternatives):
|
|
99
|
+
cli_abort(f"{name} requires the following missing aesthetics: {req}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _resolution(x: np.ndarray, zero: bool = True) -> float:
|
|
103
|
+
"""Compute the resolution of a numeric vector.
|
|
104
|
+
|
|
105
|
+
Parameters
|
|
106
|
+
----------
|
|
107
|
+
x : array-like
|
|
108
|
+
zero : bool
|
|
109
|
+
|
|
110
|
+
Returns
|
|
111
|
+
-------
|
|
112
|
+
float
|
|
113
|
+
"""
|
|
114
|
+
x = np.asarray(x, dtype=float)
|
|
115
|
+
x = x[np.isfinite(x)]
|
|
116
|
+
if len(x) < 2:
|
|
117
|
+
return 1.0
|
|
118
|
+
unique_vals = np.unique(x)
|
|
119
|
+
if len(unique_vals) < 2:
|
|
120
|
+
return 1.0
|
|
121
|
+
diffs = np.diff(np.sort(unique_vals))
|
|
122
|
+
diffs = diffs[diffs > 0]
|
|
123
|
+
if len(diffs) == 0:
|
|
124
|
+
return 1.0
|
|
125
|
+
res = float(np.min(diffs))
|
|
126
|
+
if zero:
|
|
127
|
+
res = min(res, abs(float(unique_vals[0]))) if unique_vals[0] != 0 else res
|
|
128
|
+
return res
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _collide(
|
|
132
|
+
data: pd.DataFrame,
|
|
133
|
+
width: Optional[float],
|
|
134
|
+
name: str,
|
|
135
|
+
strategy: Callable,
|
|
136
|
+
reverse: bool = False,
|
|
137
|
+
**kwargs: Any,
|
|
138
|
+
) -> pd.DataFrame:
|
|
139
|
+
"""Set up and execute a collision strategy (dodge, stack).
|
|
140
|
+
|
|
141
|
+
Parameters
|
|
142
|
+
----------
|
|
143
|
+
data : pd.DataFrame
|
|
144
|
+
width : float or None
|
|
145
|
+
name : str
|
|
146
|
+
strategy : callable
|
|
147
|
+
reverse : bool
|
|
148
|
+
**kwargs
|
|
149
|
+
Extra args for the strategy function.
|
|
150
|
+
|
|
151
|
+
Returns
|
|
152
|
+
-------
|
|
153
|
+
pd.DataFrame
|
|
154
|
+
"""
|
|
155
|
+
data = data.copy()
|
|
156
|
+
|
|
157
|
+
# Determine width
|
|
158
|
+
if width is not None:
|
|
159
|
+
if "xmin" not in data.columns or "xmax" not in data.columns:
|
|
160
|
+
data["xmin"] = data["x"] - width / 2
|
|
161
|
+
data["xmax"] = data["x"] + width / 2
|
|
162
|
+
else:
|
|
163
|
+
if "xmin" not in data.columns or "xmax" not in data.columns:
|
|
164
|
+
data["xmin"] = data["x"]
|
|
165
|
+
data["xmax"] = data["x"]
|
|
166
|
+
widths = (data["xmax"] - data["xmin"]).dropna().unique()
|
|
167
|
+
width = widths[0] if len(widths) > 0 else 0.0
|
|
168
|
+
|
|
169
|
+
# Sort
|
|
170
|
+
if reverse:
|
|
171
|
+
data = data.sort_values(["xmin", "group"], ascending=[True, True]).reset_index(drop=True)
|
|
172
|
+
else:
|
|
173
|
+
data = data.sort_values(["xmin", "group"], ascending=[True, False]).reset_index(drop=True)
|
|
174
|
+
|
|
175
|
+
original_order = data.index.copy()
|
|
176
|
+
|
|
177
|
+
# Apply strategy per xmin group
|
|
178
|
+
if "ymax" in data.columns:
|
|
179
|
+
groups = data.groupby("xmin", sort=False)
|
|
180
|
+
parts = []
|
|
181
|
+
for _, grp in groups:
|
|
182
|
+
parts.append(strategy(grp.copy(), width, **kwargs))
|
|
183
|
+
data = pd.concat(parts, ignore_index=True) if parts else data
|
|
184
|
+
elif "y" in data.columns:
|
|
185
|
+
data["ymax"] = data["y"]
|
|
186
|
+
groups = data.groupby("xmin", sort=False)
|
|
187
|
+
parts = []
|
|
188
|
+
for _, grp in groups:
|
|
189
|
+
parts.append(strategy(grp.copy(), width, **kwargs))
|
|
190
|
+
data = pd.concat(parts, ignore_index=True) if parts else data
|
|
191
|
+
data["y"] = data["ymax"]
|
|
192
|
+
|
|
193
|
+
return data
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# Base Position
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
class Position(GGProto):
|
|
201
|
+
"""Base position adjustment class.
|
|
202
|
+
|
|
203
|
+
Attributes
|
|
204
|
+
----------
|
|
205
|
+
required_aes : tuple of str
|
|
206
|
+
Aesthetics required for this position.
|
|
207
|
+
default_aes : dict
|
|
208
|
+
Default aesthetic values.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
# --- Auto-registration registry (Python-exclusive) -------------------
|
|
212
|
+
_registry: Dict[str, Any] = {}
|
|
213
|
+
|
|
214
|
+
required_aes: Tuple[str, ...] = ()
|
|
215
|
+
default_aes: Dict[str, Any] = {}
|
|
216
|
+
|
|
217
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
218
|
+
super().__init_subclass__(**kwargs)
|
|
219
|
+
name = cls.__name__
|
|
220
|
+
if name.startswith("Position") and len(name) > 8:
|
|
221
|
+
key = name[8:]
|
|
222
|
+
Position._registry[key] = cls
|
|
223
|
+
Position._registry[key.lower()] = cls
|
|
224
|
+
|
|
225
|
+
def use_defaults(
|
|
226
|
+
self, data: pd.DataFrame, params: Optional[Dict[str, Any]] = None
|
|
227
|
+
) -> pd.DataFrame:
|
|
228
|
+
"""Fill in default position aesthetics.
|
|
229
|
+
|
|
230
|
+
Parameters
|
|
231
|
+
----------
|
|
232
|
+
data : pd.DataFrame
|
|
233
|
+
params : dict
|
|
234
|
+
Fixed aesthetic params from the layer.
|
|
235
|
+
|
|
236
|
+
Returns
|
|
237
|
+
-------
|
|
238
|
+
pd.DataFrame
|
|
239
|
+
"""
|
|
240
|
+
if empty(data):
|
|
241
|
+
return data
|
|
242
|
+
|
|
243
|
+
params = params or {}
|
|
244
|
+
aes_names = self.aesthetics()
|
|
245
|
+
|
|
246
|
+
# Filter params to only position aesthetics not already in data
|
|
247
|
+
relevant = {k: v for k, v in params.items() if k in aes_names and k not in data.columns}
|
|
248
|
+
defaults = {
|
|
249
|
+
k: v for k, v in self.default_aes.items()
|
|
250
|
+
if k not in data.columns and k not in relevant
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if not relevant and not defaults:
|
|
254
|
+
return data
|
|
255
|
+
|
|
256
|
+
data = data.copy()
|
|
257
|
+
for k, v in defaults.items():
|
|
258
|
+
if callable(v):
|
|
259
|
+
data[k] = v(data)
|
|
260
|
+
elif np.isscalar(v):
|
|
261
|
+
data[k] = v
|
|
262
|
+
for k, v in relevant.items():
|
|
263
|
+
if np.isscalar(v) or (hasattr(v, "__len__") and len(v) == 1):
|
|
264
|
+
data[k] = v
|
|
265
|
+
elif hasattr(v, "__len__") and len(v) == len(data):
|
|
266
|
+
data[k] = v
|
|
267
|
+
return data
|
|
268
|
+
|
|
269
|
+
def setup_params(self, data: pd.DataFrame) -> Dict[str, Any]:
|
|
270
|
+
"""Modify or validate parameters.
|
|
271
|
+
|
|
272
|
+
Parameters
|
|
273
|
+
----------
|
|
274
|
+
data : pd.DataFrame
|
|
275
|
+
|
|
276
|
+
Returns
|
|
277
|
+
-------
|
|
278
|
+
dict
|
|
279
|
+
"""
|
|
280
|
+
return {}
|
|
281
|
+
|
|
282
|
+
def setup_data(
|
|
283
|
+
self, data: pd.DataFrame, params: Dict[str, Any]
|
|
284
|
+
) -> pd.DataFrame:
|
|
285
|
+
"""Modify or validate data.
|
|
286
|
+
|
|
287
|
+
Parameters
|
|
288
|
+
----------
|
|
289
|
+
data : pd.DataFrame
|
|
290
|
+
params : dict
|
|
291
|
+
|
|
292
|
+
Returns
|
|
293
|
+
-------
|
|
294
|
+
pd.DataFrame
|
|
295
|
+
"""
|
|
296
|
+
_check_required_aesthetics(self.required_aes, data.columns, snake_class(self))
|
|
297
|
+
return data
|
|
298
|
+
|
|
299
|
+
def compute_layer(
|
|
300
|
+
self,
|
|
301
|
+
data: pd.DataFrame,
|
|
302
|
+
params: Dict[str, Any],
|
|
303
|
+
layout: Any,
|
|
304
|
+
) -> pd.DataFrame:
|
|
305
|
+
"""Apply position adjustment across all panels.
|
|
306
|
+
|
|
307
|
+
Splits data by ``PANEL`` and delegates to ``compute_panel``.
|
|
308
|
+
|
|
309
|
+
Parameters
|
|
310
|
+
----------
|
|
311
|
+
data : pd.DataFrame
|
|
312
|
+
params : dict
|
|
313
|
+
layout : Layout
|
|
314
|
+
|
|
315
|
+
Returns
|
|
316
|
+
-------
|
|
317
|
+
pd.DataFrame
|
|
318
|
+
"""
|
|
319
|
+
if empty(data):
|
|
320
|
+
return data
|
|
321
|
+
|
|
322
|
+
panels = []
|
|
323
|
+
for panel_id, panel_data in data.groupby("PANEL", sort=False, observed=True):
|
|
324
|
+
if len(panel_data) == 0:
|
|
325
|
+
continue
|
|
326
|
+
scales = None
|
|
327
|
+
if hasattr(layout, "get_scales"):
|
|
328
|
+
scales = layout.get_scales(panel_id)
|
|
329
|
+
result = self.compute_panel(
|
|
330
|
+
data=panel_data.copy(),
|
|
331
|
+
params=params,
|
|
332
|
+
scales=scales,
|
|
333
|
+
)
|
|
334
|
+
panels.append(result)
|
|
335
|
+
|
|
336
|
+
if panels:
|
|
337
|
+
return pd.concat(panels, ignore_index=True)
|
|
338
|
+
return data
|
|
339
|
+
|
|
340
|
+
def compute_panel(
|
|
341
|
+
self,
|
|
342
|
+
data: pd.DataFrame,
|
|
343
|
+
params: Dict[str, Any],
|
|
344
|
+
scales: Any = None,
|
|
345
|
+
) -> pd.DataFrame:
|
|
346
|
+
"""Apply position adjustment for one panel.
|
|
347
|
+
|
|
348
|
+
Parameters
|
|
349
|
+
----------
|
|
350
|
+
data : pd.DataFrame
|
|
351
|
+
params : dict
|
|
352
|
+
scales : dict or None
|
|
353
|
+
|
|
354
|
+
Returns
|
|
355
|
+
-------
|
|
356
|
+
pd.DataFrame
|
|
357
|
+
"""
|
|
358
|
+
cli_abort(f"{snake_class(self)} has not implemented compute_panel().")
|
|
359
|
+
|
|
360
|
+
def aesthetics(self) -> List[str]:
|
|
361
|
+
"""List position aesthetics.
|
|
362
|
+
|
|
363
|
+
Returns
|
|
364
|
+
-------
|
|
365
|
+
list of str
|
|
366
|
+
"""
|
|
367
|
+
required = list(self.required_aes) if self.required_aes else []
|
|
368
|
+
# Expand pipe-separated alternatives
|
|
369
|
+
expanded = []
|
|
370
|
+
for r in required:
|
|
371
|
+
expanded.extend(r.split("|"))
|
|
372
|
+
return list(set(expanded) | set(self.default_aes.keys()))
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ---------------------------------------------------------------------------
|
|
376
|
+
# PositionIdentity
|
|
377
|
+
# ---------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
class PositionIdentity(Position):
|
|
380
|
+
"""No position adjustment (pass-through)."""
|
|
381
|
+
|
|
382
|
+
def compute_layer(
|
|
383
|
+
self,
|
|
384
|
+
data: pd.DataFrame,
|
|
385
|
+
params: Dict[str, Any],
|
|
386
|
+
layout: Any,
|
|
387
|
+
) -> pd.DataFrame:
|
|
388
|
+
return data
|
|
389
|
+
|
|
390
|
+
def compute_panel(
|
|
391
|
+
self,
|
|
392
|
+
data: pd.DataFrame,
|
|
393
|
+
params: Dict[str, Any],
|
|
394
|
+
scales: Any = None,
|
|
395
|
+
) -> pd.DataFrame:
|
|
396
|
+
return data
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ---------------------------------------------------------------------------
|
|
400
|
+
# PositionDodge
|
|
401
|
+
# ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
class PositionDodge(Position):
|
|
404
|
+
"""Dodge overlapping elements side-to-side.
|
|
405
|
+
|
|
406
|
+
Attributes
|
|
407
|
+
----------
|
|
408
|
+
width : float or None
|
|
409
|
+
Dodging width.
|
|
410
|
+
preserve : str
|
|
411
|
+
``"total"`` or ``"single"``.
|
|
412
|
+
orientation : str
|
|
413
|
+
``"x"`` or ``"y"``.
|
|
414
|
+
reverse : bool
|
|
415
|
+
Whether to reverse dodge order.
|
|
416
|
+
"""
|
|
417
|
+
|
|
418
|
+
width: Optional[float] = None
|
|
419
|
+
preserve: str = "total"
|
|
420
|
+
orientation: str = "x"
|
|
421
|
+
reverse: bool = False
|
|
422
|
+
default_aes: Dict[str, Any] = {"order": None}
|
|
423
|
+
|
|
424
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
425
|
+
for k, v in kwargs.items():
|
|
426
|
+
setattr(self, k, v)
|
|
427
|
+
|
|
428
|
+
def setup_params(self, data: pd.DataFrame) -> Dict[str, Any]:
|
|
429
|
+
"""Set up dodge parameters.
|
|
430
|
+
|
|
431
|
+
Parameters
|
|
432
|
+
----------
|
|
433
|
+
data : pd.DataFrame
|
|
434
|
+
|
|
435
|
+
Returns
|
|
436
|
+
-------
|
|
437
|
+
dict
|
|
438
|
+
"""
|
|
439
|
+
flipped = self.orientation == "y"
|
|
440
|
+
n = None
|
|
441
|
+
if self.preserve == "single" and "group" in data.columns:
|
|
442
|
+
# Count max groups per position
|
|
443
|
+
if "x" in data.columns:
|
|
444
|
+
n = data.groupby("x")["group"].nunique().max()
|
|
445
|
+
elif "xmin" in data.columns:
|
|
446
|
+
n = data.groupby("xmin")["group"].nunique().max()
|
|
447
|
+
if n is not None:
|
|
448
|
+
n = int(n)
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
"width": self.width,
|
|
452
|
+
"n": n,
|
|
453
|
+
"flipped_aes": flipped,
|
|
454
|
+
"reverse": self.reverse,
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
def setup_data(
|
|
458
|
+
self, data: pd.DataFrame, params: Dict[str, Any]
|
|
459
|
+
) -> pd.DataFrame:
|
|
460
|
+
data = data.copy()
|
|
461
|
+
if "x" not in data.columns and "xmin" in data.columns and "xmax" in data.columns:
|
|
462
|
+
data["x"] = (data["xmin"] + data["xmax"]) / 2
|
|
463
|
+
return data
|
|
464
|
+
|
|
465
|
+
def compute_panel(
|
|
466
|
+
self,
|
|
467
|
+
data: pd.DataFrame,
|
|
468
|
+
params: Dict[str, Any],
|
|
469
|
+
scales: Any = None,
|
|
470
|
+
) -> pd.DataFrame:
|
|
471
|
+
"""Dodge elements within a panel.
|
|
472
|
+
|
|
473
|
+
Parameters
|
|
474
|
+
----------
|
|
475
|
+
data : pd.DataFrame
|
|
476
|
+
params : dict
|
|
477
|
+
scales : ignored
|
|
478
|
+
|
|
479
|
+
Returns
|
|
480
|
+
-------
|
|
481
|
+
pd.DataFrame
|
|
482
|
+
"""
|
|
483
|
+
return _pos_dodge(data, params.get("width"), n=params.get("n"))
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _pos_dodge(
|
|
487
|
+
df: pd.DataFrame,
|
|
488
|
+
width: Optional[float] = None,
|
|
489
|
+
n: Optional[int] = None,
|
|
490
|
+
) -> pd.DataFrame:
|
|
491
|
+
"""Core dodge algorithm.
|
|
492
|
+
|
|
493
|
+
Mirrors R's ``pos_dodge`` used via ``collide()``, which splits the
|
|
494
|
+
data by x-position and dodges elements at each position independently.
|
|
495
|
+
|
|
496
|
+
Parameters
|
|
497
|
+
----------
|
|
498
|
+
df : pd.DataFrame
|
|
499
|
+
width : float or None
|
|
500
|
+
n : int or None
|
|
501
|
+
|
|
502
|
+
Returns
|
|
503
|
+
-------
|
|
504
|
+
pd.DataFrame
|
|
505
|
+
"""
|
|
506
|
+
df = df.copy()
|
|
507
|
+
if "group" not in df.columns:
|
|
508
|
+
return df
|
|
509
|
+
|
|
510
|
+
if "xmin" not in df.columns or "xmax" not in df.columns:
|
|
511
|
+
df["xmin"] = df["x"]
|
|
512
|
+
df["xmax"] = df["x"]
|
|
513
|
+
|
|
514
|
+
# R's collide() splits by xmin and dodges within each position.
|
|
515
|
+
df["_x_pos"] = df["xmin"].round(6)
|
|
516
|
+
|
|
517
|
+
parts = []
|
|
518
|
+
for _, pos_group in df.groupby("_x_pos", sort=False, observed=True):
|
|
519
|
+
pos_group = pos_group.copy()
|
|
520
|
+
local_n = n if n is not None else pos_group["group"].nunique()
|
|
521
|
+
if local_n <= 1:
|
|
522
|
+
parts.append(pos_group)
|
|
523
|
+
continue
|
|
524
|
+
|
|
525
|
+
d_width = float((pos_group["xmax"] - pos_group["xmin"]).max())
|
|
526
|
+
local_width = width if width is not None else d_width
|
|
527
|
+
|
|
528
|
+
unique_groups = np.sort(pos_group["group"].unique())
|
|
529
|
+
group_map = {g: i for i, g in enumerate(unique_groups)}
|
|
530
|
+
group_idx = pos_group["group"].map(group_map).values
|
|
531
|
+
|
|
532
|
+
pos_group["x"] = pos_group["x"].values + local_width * ((group_idx + 0.5) / local_n - 0.5)
|
|
533
|
+
pos_group["xmin"] = pos_group["x"] - d_width / local_n / 2
|
|
534
|
+
pos_group["xmax"] = pos_group["x"] + d_width / local_n / 2
|
|
535
|
+
parts.append(pos_group)
|
|
536
|
+
|
|
537
|
+
df = pd.concat(parts, ignore_index=False)
|
|
538
|
+
df.drop(columns=["_x_pos"], inplace=True, errors="ignore")
|
|
539
|
+
return df
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
# ---------------------------------------------------------------------------
|
|
543
|
+
# PositionDodge2
|
|
544
|
+
# ---------------------------------------------------------------------------
|
|
545
|
+
|
|
546
|
+
class PositionDodge2(PositionDodge):
|
|
547
|
+
"""Dodge with variable widths.
|
|
548
|
+
|
|
549
|
+
Attributes
|
|
550
|
+
----------
|
|
551
|
+
padding : float
|
|
552
|
+
Proportion of space between elements (0 to 1).
|
|
553
|
+
group_row : str
|
|
554
|
+
``"single"`` or ``"many"``.
|
|
555
|
+
"""
|
|
556
|
+
|
|
557
|
+
padding: float = 0.1
|
|
558
|
+
group_row: str = "single"
|
|
559
|
+
|
|
560
|
+
def setup_params(self, data: pd.DataFrame) -> Dict[str, Any]:
|
|
561
|
+
n = None
|
|
562
|
+
if self.preserve == "single":
|
|
563
|
+
# R semantics: n = max number of unique groups at any single
|
|
564
|
+
# (PANEL, x) position. For a simple boxplot without fill,
|
|
565
|
+
# there is 1 group per x, so n=1 → no dodging.
|
|
566
|
+
if "x" in data.columns and "group" in data.columns:
|
|
567
|
+
n = int(data.groupby(["PANEL", "x"], observed=True)["group"]
|
|
568
|
+
.nunique().max())
|
|
569
|
+
else:
|
|
570
|
+
n = 1
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
"width": self.width,
|
|
574
|
+
"n": n,
|
|
575
|
+
"padding": self.padding,
|
|
576
|
+
"reverse": self.reverse,
|
|
577
|
+
"flipped_aes": False,
|
|
578
|
+
"group_row": self.group_row,
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
def compute_panel(
|
|
582
|
+
self,
|
|
583
|
+
data: pd.DataFrame,
|
|
584
|
+
params: Dict[str, Any],
|
|
585
|
+
scales: Any = None,
|
|
586
|
+
) -> pd.DataFrame:
|
|
587
|
+
return _pos_dodge2(
|
|
588
|
+
data,
|
|
589
|
+
params.get("width"),
|
|
590
|
+
n=params.get("n"),
|
|
591
|
+
padding=params.get("padding", 0.1),
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _pos_dodge2(
|
|
596
|
+
df: pd.DataFrame,
|
|
597
|
+
width: Optional[float] = None,
|
|
598
|
+
n: Optional[int] = None,
|
|
599
|
+
padding: float = 0.1,
|
|
600
|
+
) -> pd.DataFrame:
|
|
601
|
+
"""Core dodge2 algorithm.
|
|
602
|
+
|
|
603
|
+
Mirrors R's ``pos_dodge2`` which uses ``collide()`` to dodge
|
|
604
|
+
elements sharing the same x position independently of elements at
|
|
605
|
+
other positions.
|
|
606
|
+
|
|
607
|
+
Parameters
|
|
608
|
+
----------
|
|
609
|
+
df : pd.DataFrame
|
|
610
|
+
width : float or None
|
|
611
|
+
n : int or None
|
|
612
|
+
Maximum number of groups to dodge within each x position.
|
|
613
|
+
padding : float
|
|
614
|
+
|
|
615
|
+
Returns
|
|
616
|
+
-------
|
|
617
|
+
pd.DataFrame
|
|
618
|
+
"""
|
|
619
|
+
df = df.copy()
|
|
620
|
+
if "xmin" not in df.columns or "xmax" not in df.columns:
|
|
621
|
+
if "x" in df.columns:
|
|
622
|
+
df["xmin"] = df["x"]
|
|
623
|
+
df["xmax"] = df["x"]
|
|
624
|
+
else:
|
|
625
|
+
return df
|
|
626
|
+
|
|
627
|
+
# R's collide() splits data by x-position and dodges within each.
|
|
628
|
+
# Group rows that share the same (rounded) x center so that only
|
|
629
|
+
# elements at the same position are dodged against each other.
|
|
630
|
+
center = (df["xmin"] + df["xmax"]) / 2
|
|
631
|
+
# Use rounded center to find co-located elements
|
|
632
|
+
df["_x_pos"] = center.round(6)
|
|
633
|
+
|
|
634
|
+
parts = []
|
|
635
|
+
for _, pos_group in df.groupby("_x_pos", sort=False, observed=True):
|
|
636
|
+
pos_group = pos_group.copy()
|
|
637
|
+
local_n = n
|
|
638
|
+
if local_n is None and "group" in pos_group.columns:
|
|
639
|
+
local_n = pos_group["group"].nunique()
|
|
640
|
+
if local_n is None or local_n <= 1:
|
|
641
|
+
parts.append(pos_group)
|
|
642
|
+
continue
|
|
643
|
+
|
|
644
|
+
original_width = pos_group["xmax"] - pos_group["xmin"]
|
|
645
|
+
new_width = original_width / local_n
|
|
646
|
+
|
|
647
|
+
if "group" in pos_group.columns:
|
|
648
|
+
unique_groups = np.sort(pos_group["group"].unique())
|
|
649
|
+
group_map = {g: i for i, g in enumerate(unique_groups)}
|
|
650
|
+
group_idx = pos_group["group"].map(group_map).values
|
|
651
|
+
else:
|
|
652
|
+
group_idx = np.zeros(len(pos_group), dtype=int)
|
|
653
|
+
|
|
654
|
+
pos_center = (pos_group["xmin"] + pos_group["xmax"]) / 2
|
|
655
|
+
total_width = new_width * local_n
|
|
656
|
+
start = pos_center - total_width / 2
|
|
657
|
+
|
|
658
|
+
pos_group["xmin"] = start + group_idx * new_width
|
|
659
|
+
pos_group["xmax"] = pos_group["xmin"] + new_width
|
|
660
|
+
pos_group["x"] = (pos_group["xmin"] + pos_group["xmax"]) / 2
|
|
661
|
+
|
|
662
|
+
if padding > 0:
|
|
663
|
+
pad_width = new_width * (1 - padding)
|
|
664
|
+
pos_group["xmin"] = pos_group["x"] - pad_width / 2
|
|
665
|
+
pos_group["xmax"] = pos_group["x"] + pad_width / 2
|
|
666
|
+
|
|
667
|
+
parts.append(pos_group)
|
|
668
|
+
|
|
669
|
+
df = pd.concat(parts, ignore_index=False)
|
|
670
|
+
df.drop(columns=["_x_pos"], inplace=True, errors="ignore")
|
|
671
|
+
return df
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
# ---------------------------------------------------------------------------
|
|
675
|
+
# PositionJitter
|
|
676
|
+
# ---------------------------------------------------------------------------
|
|
677
|
+
|
|
678
|
+
class PositionJitter(Position):
|
|
679
|
+
"""Random jitter.
|
|
680
|
+
|
|
681
|
+
Attributes
|
|
682
|
+
----------
|
|
683
|
+
width : float or None
|
|
684
|
+
Jitter width (each direction).
|
|
685
|
+
height : float or None
|
|
686
|
+
Jitter height (each direction).
|
|
687
|
+
seed : int or None
|
|
688
|
+
Random seed for reproducibility.
|
|
689
|
+
"""
|
|
690
|
+
|
|
691
|
+
width: Optional[float] = None
|
|
692
|
+
height: Optional[float] = None
|
|
693
|
+
seed: Any = None # NA -> random, None -> don't reset
|
|
694
|
+
required_aes: Tuple[str, ...] = ("x", "y")
|
|
695
|
+
|
|
696
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
697
|
+
for k, v in kwargs.items():
|
|
698
|
+
setattr(self, k, v)
|
|
699
|
+
|
|
700
|
+
def setup_params(self, data: pd.DataFrame) -> Dict[str, Any]:
|
|
701
|
+
seed = self.seed
|
|
702
|
+
if seed is not None and (isinstance(seed, float) and np.isnan(seed)):
|
|
703
|
+
seed = np.random.randint(0, 2 ** 31)
|
|
704
|
+
return {
|
|
705
|
+
"width": self.width,
|
|
706
|
+
"height": self.height,
|
|
707
|
+
"seed": seed,
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
def compute_panel(
|
|
711
|
+
self,
|
|
712
|
+
data: pd.DataFrame,
|
|
713
|
+
params: Dict[str, Any],
|
|
714
|
+
scales: Any = None,
|
|
715
|
+
) -> pd.DataFrame:
|
|
716
|
+
return _compute_jitter(
|
|
717
|
+
data,
|
|
718
|
+
width=params.get("width"),
|
|
719
|
+
height=params.get("height"),
|
|
720
|
+
seed=params.get("seed"),
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _compute_jitter(
|
|
725
|
+
data: pd.DataFrame,
|
|
726
|
+
width: Optional[float] = None,
|
|
727
|
+
height: Optional[float] = None,
|
|
728
|
+
seed: Any = None,
|
|
729
|
+
) -> pd.DataFrame:
|
|
730
|
+
"""Apply jitter to data.
|
|
731
|
+
|
|
732
|
+
Parameters
|
|
733
|
+
----------
|
|
734
|
+
data : pd.DataFrame
|
|
735
|
+
width, height : float or None
|
|
736
|
+
seed : int or None
|
|
737
|
+
|
|
738
|
+
Returns
|
|
739
|
+
-------
|
|
740
|
+
pd.DataFrame
|
|
741
|
+
"""
|
|
742
|
+
data = data.copy()
|
|
743
|
+
n = len(data)
|
|
744
|
+
|
|
745
|
+
if width is None:
|
|
746
|
+
width = _resolution(data["x"].values, zero=False) * 0.4 if "x" in data.columns else 0.0
|
|
747
|
+
if height is None:
|
|
748
|
+
height = _resolution(data["y"].values, zero=False) * 0.4 if "y" in data.columns else 0.0
|
|
749
|
+
|
|
750
|
+
rng = np.random.RandomState(seed) if seed is not None else np.random
|
|
751
|
+
|
|
752
|
+
if width > 0 and "x" in data.columns:
|
|
753
|
+
x_jit = rng.uniform(-width, width, size=n)
|
|
754
|
+
data["x"] = data["x"].values + x_jit
|
|
755
|
+
for c in ("xmin", "xmax", "xend"):
|
|
756
|
+
if c in data.columns:
|
|
757
|
+
data[c] = data[c].values + x_jit
|
|
758
|
+
|
|
759
|
+
if height > 0 and "y" in data.columns:
|
|
760
|
+
y_jit = rng.uniform(-height, height, size=n)
|
|
761
|
+
data["y"] = data["y"].values + y_jit
|
|
762
|
+
for c in ("ymin", "ymax", "yend"):
|
|
763
|
+
if c in data.columns:
|
|
764
|
+
data[c] = data[c].values + y_jit
|
|
765
|
+
|
|
766
|
+
return data
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
# ---------------------------------------------------------------------------
|
|
770
|
+
# PositionJitterdodge
|
|
771
|
+
# ---------------------------------------------------------------------------
|
|
772
|
+
|
|
773
|
+
class PositionJitterdodge(Position):
|
|
774
|
+
"""Simultaneously dodge and jitter.
|
|
775
|
+
|
|
776
|
+
Attributes
|
|
777
|
+
----------
|
|
778
|
+
jitter_width : float or None
|
|
779
|
+
jitter_height : float
|
|
780
|
+
dodge_width : float
|
|
781
|
+
preserve : str
|
|
782
|
+
reverse : bool
|
|
783
|
+
seed : int or None
|
|
784
|
+
"""
|
|
785
|
+
|
|
786
|
+
jitter_width: Optional[float] = None
|
|
787
|
+
jitter_height: float = 0.0
|
|
788
|
+
dodge_width: float = 0.75
|
|
789
|
+
preserve: str = "total"
|
|
790
|
+
reverse: bool = False
|
|
791
|
+
seed: Any = None
|
|
792
|
+
required_aes: Tuple[str, ...] = ("x", "y")
|
|
793
|
+
|
|
794
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
795
|
+
for k, v in kwargs.items():
|
|
796
|
+
setattr(self, k, v)
|
|
797
|
+
|
|
798
|
+
def setup_params(self, data: pd.DataFrame) -> Dict[str, Any]:
|
|
799
|
+
n = None
|
|
800
|
+
if self.preserve == "single" and "group" in data.columns and "x" in data.columns:
|
|
801
|
+
n = int(data.groupby(["PANEL", "x"])["group"].nunique().max())
|
|
802
|
+
|
|
803
|
+
jw = self.jitter_width
|
|
804
|
+
if jw is None and "x" in data.columns:
|
|
805
|
+
jw = _resolution(data["x"].values, zero=False) * 0.4
|
|
806
|
+
if jw is None:
|
|
807
|
+
jw = 0.0
|
|
808
|
+
jw = jw / max(n or 1, 1)
|
|
809
|
+
|
|
810
|
+
return {
|
|
811
|
+
"dodge_width": self.dodge_width,
|
|
812
|
+
"jitter_width": jw,
|
|
813
|
+
"jitter_height": self.jitter_height,
|
|
814
|
+
"n": n,
|
|
815
|
+
"seed": self.seed,
|
|
816
|
+
"reverse": self.reverse,
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
def setup_data(
|
|
820
|
+
self, data: pd.DataFrame, params: Dict[str, Any]
|
|
821
|
+
) -> pd.DataFrame:
|
|
822
|
+
data = data.copy()
|
|
823
|
+
if "x" not in data.columns and "xmin" in data.columns and "xmax" in data.columns:
|
|
824
|
+
data["x"] = (data["xmin"] + data["xmax"]) / 2
|
|
825
|
+
return data
|
|
826
|
+
|
|
827
|
+
def compute_panel(
|
|
828
|
+
self,
|
|
829
|
+
data: pd.DataFrame,
|
|
830
|
+
params: Dict[str, Any],
|
|
831
|
+
scales: Any = None,
|
|
832
|
+
) -> pd.DataFrame:
|
|
833
|
+
# First dodge
|
|
834
|
+
data = _pos_dodge(data, params.get("dodge_width"), n=params.get("n"))
|
|
835
|
+
# Then jitter
|
|
836
|
+
data = _compute_jitter(
|
|
837
|
+
data,
|
|
838
|
+
width=params.get("jitter_width"),
|
|
839
|
+
height=params.get("jitter_height"),
|
|
840
|
+
seed=params.get("seed"),
|
|
841
|
+
)
|
|
842
|
+
return data
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
# ---------------------------------------------------------------------------
|
|
846
|
+
# PositionNudge
|
|
847
|
+
# ---------------------------------------------------------------------------
|
|
848
|
+
|
|
849
|
+
class PositionNudge(Position):
|
|
850
|
+
"""Constant offset in x and/or y.
|
|
851
|
+
|
|
852
|
+
Attributes
|
|
853
|
+
----------
|
|
854
|
+
x : float or None
|
|
855
|
+
Horizontal nudge amount.
|
|
856
|
+
y : float or None
|
|
857
|
+
Vertical nudge amount.
|
|
858
|
+
"""
|
|
859
|
+
|
|
860
|
+
x: Optional[float] = None
|
|
861
|
+
y: Optional[float] = None
|
|
862
|
+
default_aes: Dict[str, Any] = {"nudge_x": 0, "nudge_y": 0}
|
|
863
|
+
|
|
864
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
865
|
+
for k, v in kwargs.items():
|
|
866
|
+
setattr(self, k, v)
|
|
867
|
+
|
|
868
|
+
def setup_params(self, data: pd.DataFrame) -> Dict[str, Any]:
|
|
869
|
+
nx = self.x
|
|
870
|
+
ny = self.y
|
|
871
|
+
if nx is None:
|
|
872
|
+
nx = data["nudge_x"].values if "nudge_x" in data.columns else 0.0
|
|
873
|
+
if ny is None:
|
|
874
|
+
ny = data["nudge_y"].values if "nudge_y" in data.columns else 0.0
|
|
875
|
+
return {"x": nx, "y": ny}
|
|
876
|
+
|
|
877
|
+
def compute_layer(
|
|
878
|
+
self,
|
|
879
|
+
data: pd.DataFrame,
|
|
880
|
+
params: Dict[str, Any],
|
|
881
|
+
layout: Any,
|
|
882
|
+
) -> pd.DataFrame:
|
|
883
|
+
px = params.get("x", 0)
|
|
884
|
+
py = params.get("y", 0)
|
|
885
|
+
trans_x = (lambda v: v + px) if np.any(np.asarray(px) != 0) else None
|
|
886
|
+
trans_y = (lambda v: v + py) if np.any(np.asarray(py) != 0) else None
|
|
887
|
+
return _transform_position(data, trans_x, trans_y)
|
|
888
|
+
|
|
889
|
+
def compute_panel(
|
|
890
|
+
self,
|
|
891
|
+
data: pd.DataFrame,
|
|
892
|
+
params: Dict[str, Any],
|
|
893
|
+
scales: Any = None,
|
|
894
|
+
) -> pd.DataFrame:
|
|
895
|
+
# Nudge is handled at compute_layer level
|
|
896
|
+
return data
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
# ---------------------------------------------------------------------------
|
|
900
|
+
# PositionStack / PositionFill
|
|
901
|
+
# ---------------------------------------------------------------------------
|
|
902
|
+
|
|
903
|
+
class PositionStack(Position):
|
|
904
|
+
"""Stack overlapping elements on top of each other.
|
|
905
|
+
|
|
906
|
+
Attributes
|
|
907
|
+
----------
|
|
908
|
+
vjust : float
|
|
909
|
+
Vertical justification (0 = bottom, 0.5 = middle, 1 = top).
|
|
910
|
+
fill : bool
|
|
911
|
+
If True, normalise stacks to fill [0, 1].
|
|
912
|
+
reverse : bool
|
|
913
|
+
Whether to reverse stacking order.
|
|
914
|
+
"""
|
|
915
|
+
|
|
916
|
+
vjust: float = 1.0
|
|
917
|
+
fill: bool = False
|
|
918
|
+
reverse: bool = False
|
|
919
|
+
|
|
920
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
921
|
+
for k, v in kwargs.items():
|
|
922
|
+
setattr(self, k, v)
|
|
923
|
+
|
|
924
|
+
def setup_params(self, data: pd.DataFrame) -> Dict[str, Any]:
|
|
925
|
+
var = _stack_var(data)
|
|
926
|
+
return {
|
|
927
|
+
"var": var,
|
|
928
|
+
"fill": self.fill,
|
|
929
|
+
"vjust": self.vjust,
|
|
930
|
+
"reverse": self.reverse,
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
def setup_data(
|
|
934
|
+
self, data: pd.DataFrame, params: Dict[str, Any]
|
|
935
|
+
) -> pd.DataFrame:
|
|
936
|
+
if params.get("var") is None:
|
|
937
|
+
return data
|
|
938
|
+
data = data.copy()
|
|
939
|
+
var = params["var"]
|
|
940
|
+
if var == "y" and "y" in data.columns:
|
|
941
|
+
data["ymax"] = data["y"]
|
|
942
|
+
elif var == "ymax" and "ymax" in data.columns and "ymin" in data.columns:
|
|
943
|
+
mask = (data["ymax"] == 0)
|
|
944
|
+
data.loc[mask, "ymax"] = data.loc[mask, "ymin"]
|
|
945
|
+
return data
|
|
946
|
+
|
|
947
|
+
def compute_panel(
|
|
948
|
+
self,
|
|
949
|
+
data: pd.DataFrame,
|
|
950
|
+
params: Dict[str, Any],
|
|
951
|
+
scales: Any = None,
|
|
952
|
+
) -> pd.DataFrame:
|
|
953
|
+
if params.get("var") is None:
|
|
954
|
+
return data
|
|
955
|
+
|
|
956
|
+
data = data.copy()
|
|
957
|
+
vjust = params.get("vjust", 1.0)
|
|
958
|
+
fill = params.get("fill", False)
|
|
959
|
+
reverse = params.get("reverse", False)
|
|
960
|
+
|
|
961
|
+
# Split positive and negative
|
|
962
|
+
if "ymax" in data.columns:
|
|
963
|
+
negative_mask = data["ymax"] < 0
|
|
964
|
+
negative_mask = negative_mask.fillna(False)
|
|
965
|
+
else:
|
|
966
|
+
negative_mask = pd.Series([False] * len(data), index=data.index)
|
|
967
|
+
|
|
968
|
+
neg = data[negative_mask].copy()
|
|
969
|
+
pos = data[~negative_mask].copy()
|
|
970
|
+
|
|
971
|
+
if len(neg) > 0:
|
|
972
|
+
neg = _pos_stack(neg, vjust=vjust, fill=fill, reverse=reverse)
|
|
973
|
+
if len(pos) > 0:
|
|
974
|
+
pos = _pos_stack(pos, vjust=vjust, fill=fill, reverse=reverse)
|
|
975
|
+
|
|
976
|
+
# Recombine in original order
|
|
977
|
+
result = pd.concat([neg, pos], ignore_index=False)
|
|
978
|
+
result = result.loc[data.index].reset_index(drop=True)
|
|
979
|
+
return result
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _stack_var(data: pd.DataFrame) -> Optional[str]:
|
|
983
|
+
"""Determine the stacking variable.
|
|
984
|
+
|
|
985
|
+
Parameters
|
|
986
|
+
----------
|
|
987
|
+
data : pd.DataFrame
|
|
988
|
+
|
|
989
|
+
Returns
|
|
990
|
+
-------
|
|
991
|
+
str or None
|
|
992
|
+
"""
|
|
993
|
+
if "ymax" in data.columns:
|
|
994
|
+
return "ymax"
|
|
995
|
+
elif "y" in data.columns:
|
|
996
|
+
return "y"
|
|
997
|
+
else:
|
|
998
|
+
cli_warn("Stacking requires y or ymax aesthetics.")
|
|
999
|
+
return None
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _pos_stack(
|
|
1003
|
+
df: pd.DataFrame,
|
|
1004
|
+
vjust: float = 1.0,
|
|
1005
|
+
fill: bool = False,
|
|
1006
|
+
reverse: bool = False,
|
|
1007
|
+
) -> pd.DataFrame:
|
|
1008
|
+
"""Core stacking algorithm.
|
|
1009
|
+
|
|
1010
|
+
Stacks overlapping bars *within* each x-position group. In R's
|
|
1011
|
+
ggplot2 this corresponds to ``collide()`` + ``stack_var()``, which
|
|
1012
|
+
groups rows sharing the same ``xmin``/``xmax`` interval before
|
|
1013
|
+
cumulating y values.
|
|
1014
|
+
|
|
1015
|
+
Parameters
|
|
1016
|
+
----------
|
|
1017
|
+
df : pd.DataFrame
|
|
1018
|
+
vjust : float
|
|
1019
|
+
fill : bool
|
|
1020
|
+
reverse : bool
|
|
1021
|
+
|
|
1022
|
+
Returns
|
|
1023
|
+
-------
|
|
1024
|
+
pd.DataFrame
|
|
1025
|
+
"""
|
|
1026
|
+
df = df.copy()
|
|
1027
|
+
if "group" in df.columns:
|
|
1028
|
+
if reverse:
|
|
1029
|
+
df = df.sort_values("group", ascending=True)
|
|
1030
|
+
else:
|
|
1031
|
+
df = df.sort_values("group", ascending=False)
|
|
1032
|
+
|
|
1033
|
+
# Determine the x-grouping key. Use xmin if available (matches R's
|
|
1034
|
+
# collide), otherwise fall back to x.
|
|
1035
|
+
if "xmin" in df.columns:
|
|
1036
|
+
x_key = df["xmin"].values
|
|
1037
|
+
elif "x" in df.columns:
|
|
1038
|
+
x_key = df["x"].values
|
|
1039
|
+
else:
|
|
1040
|
+
x_key = np.zeros(len(df))
|
|
1041
|
+
|
|
1042
|
+
y = df["y"].values if "y" in df.columns else np.zeros(len(df))
|
|
1043
|
+
y = np.where(np.isnan(y), 0, y)
|
|
1044
|
+
|
|
1045
|
+
ymin_out = np.zeros(len(df))
|
|
1046
|
+
ymax_out = np.zeros(len(df))
|
|
1047
|
+
|
|
1048
|
+
# Stack within each unique x position
|
|
1049
|
+
for xval in np.unique(x_key):
|
|
1050
|
+
mask = x_key == xval
|
|
1051
|
+
y_group = y[mask]
|
|
1052
|
+
heights = np.concatenate([[0], np.cumsum(y_group)])
|
|
1053
|
+
if fill:
|
|
1054
|
+
total = abs(heights[-1])
|
|
1055
|
+
if total > np.sqrt(np.finfo(float).eps):
|
|
1056
|
+
heights = heights / total
|
|
1057
|
+
n = len(y_group)
|
|
1058
|
+
ymin_out[mask] = np.minimum(heights[:n], heights[1:])
|
|
1059
|
+
ymax_out[mask] = np.maximum(heights[:n], heights[1:])
|
|
1060
|
+
|
|
1061
|
+
df["y"] = (1 - vjust) * ymin_out + vjust * ymax_out
|
|
1062
|
+
df["ymin"] = ymin_out
|
|
1063
|
+
df["ymax"] = ymax_out
|
|
1064
|
+
return df
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
class PositionFill(PositionStack):
|
|
1068
|
+
"""Stack and normalise to fill [0, 1].
|
|
1069
|
+
|
|
1070
|
+
This is ``PositionStack`` with ``fill=True``.
|
|
1071
|
+
"""
|
|
1072
|
+
|
|
1073
|
+
fill: bool = True
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
# ---------------------------------------------------------------------------
|
|
1077
|
+
# Constructor functions
|
|
1078
|
+
# ---------------------------------------------------------------------------
|
|
1079
|
+
|
|
1080
|
+
def position_identity() -> PositionIdentity:
|
|
1081
|
+
"""Create an identity position (no adjustment).
|
|
1082
|
+
|
|
1083
|
+
Returns
|
|
1084
|
+
-------
|
|
1085
|
+
PositionIdentity
|
|
1086
|
+
"""
|
|
1087
|
+
return PositionIdentity()
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def position_dodge(
|
|
1091
|
+
width: Optional[float] = None,
|
|
1092
|
+
preserve: str = "total",
|
|
1093
|
+
orientation: str = "x",
|
|
1094
|
+
reverse: bool = False,
|
|
1095
|
+
) -> PositionDodge:
|
|
1096
|
+
"""Create a dodge position adjustment.
|
|
1097
|
+
|
|
1098
|
+
Parameters
|
|
1099
|
+
----------
|
|
1100
|
+
width : float or None
|
|
1101
|
+
preserve : str
|
|
1102
|
+
``"total"`` or ``"single"``.
|
|
1103
|
+
orientation : str
|
|
1104
|
+
``"x"`` or ``"y"``.
|
|
1105
|
+
reverse : bool
|
|
1106
|
+
|
|
1107
|
+
Returns
|
|
1108
|
+
-------
|
|
1109
|
+
PositionDodge
|
|
1110
|
+
"""
|
|
1111
|
+
return PositionDodge(
|
|
1112
|
+
width=width,
|
|
1113
|
+
preserve=preserve,
|
|
1114
|
+
orientation=orientation,
|
|
1115
|
+
reverse=reverse,
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def position_dodge2(
|
|
1120
|
+
width: Optional[float] = None,
|
|
1121
|
+
preserve: str = "total",
|
|
1122
|
+
padding: float = 0.1,
|
|
1123
|
+
reverse: bool = False,
|
|
1124
|
+
group_row: str = "single",
|
|
1125
|
+
) -> PositionDodge2:
|
|
1126
|
+
"""Create a dodge2 position adjustment.
|
|
1127
|
+
|
|
1128
|
+
Parameters
|
|
1129
|
+
----------
|
|
1130
|
+
width : float or None
|
|
1131
|
+
preserve : str
|
|
1132
|
+
padding : float
|
|
1133
|
+
reverse : bool
|
|
1134
|
+
group_row : str
|
|
1135
|
+
|
|
1136
|
+
Returns
|
|
1137
|
+
-------
|
|
1138
|
+
PositionDodge2
|
|
1139
|
+
"""
|
|
1140
|
+
return PositionDodge2(
|
|
1141
|
+
width=width,
|
|
1142
|
+
preserve=preserve,
|
|
1143
|
+
padding=padding,
|
|
1144
|
+
reverse=reverse,
|
|
1145
|
+
group_row=group_row,
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
def position_jitter(
|
|
1150
|
+
width: Optional[float] = None,
|
|
1151
|
+
height: Optional[float] = None,
|
|
1152
|
+
seed: Any = None,
|
|
1153
|
+
) -> PositionJitter:
|
|
1154
|
+
"""Create a jitter position adjustment.
|
|
1155
|
+
|
|
1156
|
+
Parameters
|
|
1157
|
+
----------
|
|
1158
|
+
width, height : float or None
|
|
1159
|
+
seed : int or None
|
|
1160
|
+
|
|
1161
|
+
Returns
|
|
1162
|
+
-------
|
|
1163
|
+
PositionJitter
|
|
1164
|
+
"""
|
|
1165
|
+
return PositionJitter(width=width, height=height, seed=seed)
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def position_jitterdodge(
|
|
1169
|
+
jitter_width: Optional[float] = None,
|
|
1170
|
+
jitter_height: float = 0.0,
|
|
1171
|
+
dodge_width: float = 0.75,
|
|
1172
|
+
reverse: bool = False,
|
|
1173
|
+
preserve: str = "total",
|
|
1174
|
+
seed: Any = None,
|
|
1175
|
+
) -> PositionJitterdodge:
|
|
1176
|
+
"""Create a jitter+dodge position adjustment.
|
|
1177
|
+
|
|
1178
|
+
Parameters
|
|
1179
|
+
----------
|
|
1180
|
+
jitter_width : float or None
|
|
1181
|
+
jitter_height : float
|
|
1182
|
+
dodge_width : float
|
|
1183
|
+
reverse : bool
|
|
1184
|
+
preserve : str
|
|
1185
|
+
seed : int or None
|
|
1186
|
+
|
|
1187
|
+
Returns
|
|
1188
|
+
-------
|
|
1189
|
+
PositionJitterdodge
|
|
1190
|
+
"""
|
|
1191
|
+
return PositionJitterdodge(
|
|
1192
|
+
jitter_width=jitter_width,
|
|
1193
|
+
jitter_height=jitter_height,
|
|
1194
|
+
dodge_width=dodge_width,
|
|
1195
|
+
reverse=reverse,
|
|
1196
|
+
preserve=preserve,
|
|
1197
|
+
seed=seed,
|
|
1198
|
+
)
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def position_nudge(
|
|
1202
|
+
x: Optional[float] = None,
|
|
1203
|
+
y: Optional[float] = None,
|
|
1204
|
+
) -> PositionNudge:
|
|
1205
|
+
"""Create a nudge position adjustment.
|
|
1206
|
+
|
|
1207
|
+
Parameters
|
|
1208
|
+
----------
|
|
1209
|
+
x, y : float or None
|
|
1210
|
+
|
|
1211
|
+
Returns
|
|
1212
|
+
-------
|
|
1213
|
+
PositionNudge
|
|
1214
|
+
"""
|
|
1215
|
+
return PositionNudge(x=x or 0.0, y=y or 0.0)
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def position_stack(
|
|
1219
|
+
vjust: float = 1.0,
|
|
1220
|
+
reverse: bool = False,
|
|
1221
|
+
) -> PositionStack:
|
|
1222
|
+
"""Create a stack position adjustment.
|
|
1223
|
+
|
|
1224
|
+
Parameters
|
|
1225
|
+
----------
|
|
1226
|
+
vjust : float
|
|
1227
|
+
reverse : bool
|
|
1228
|
+
|
|
1229
|
+
Returns
|
|
1230
|
+
-------
|
|
1231
|
+
PositionStack
|
|
1232
|
+
"""
|
|
1233
|
+
return PositionStack(vjust=vjust, reverse=reverse)
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def position_fill(
|
|
1237
|
+
vjust: float = 1.0,
|
|
1238
|
+
reverse: bool = False,
|
|
1239
|
+
) -> PositionFill:
|
|
1240
|
+
"""Create a fill position adjustment (stack + normalise).
|
|
1241
|
+
|
|
1242
|
+
Parameters
|
|
1243
|
+
----------
|
|
1244
|
+
vjust : float
|
|
1245
|
+
reverse : bool
|
|
1246
|
+
|
|
1247
|
+
Returns
|
|
1248
|
+
-------
|
|
1249
|
+
PositionFill
|
|
1250
|
+
"""
|
|
1251
|
+
return PositionFill(vjust=vjust, reverse=reverse, fill=True)
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
# ---------------------------------------------------------------------------
|
|
1255
|
+
# Predicate
|
|
1256
|
+
# ---------------------------------------------------------------------------
|
|
1257
|
+
|
|
1258
|
+
def is_position(x: Any) -> bool:
|
|
1259
|
+
"""Test whether *x* is a Position.
|
|
1260
|
+
|
|
1261
|
+
Parameters
|
|
1262
|
+
----------
|
|
1263
|
+
x : object
|
|
1264
|
+
|
|
1265
|
+
Returns
|
|
1266
|
+
-------
|
|
1267
|
+
bool
|
|
1268
|
+
"""
|
|
1269
|
+
return isinstance(x, Position)
|