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.
Files changed (54) hide show
  1. ggplot2_py/__init__.py +852 -0
  2. ggplot2_py/_compat.py +475 -0
  3. ggplot2_py/_plugins.py +129 -0
  4. ggplot2_py/_utils.py +544 -0
  5. ggplot2_py/aes.py +586 -0
  6. ggplot2_py/annotation.py +540 -0
  7. ggplot2_py/coord.py +2108 -0
  8. ggplot2_py/coords/__init__.py +49 -0
  9. ggplot2_py/datasets.py +265 -0
  10. ggplot2_py/draw_key.py +454 -0
  11. ggplot2_py/facet.py +1456 -0
  12. ggplot2_py/fortify.py +95 -0
  13. ggplot2_py/geom.py +4516 -0
  14. ggplot2_py/geoms/__init__.py +12 -0
  15. ggplot2_py/ggproto.py +279 -0
  16. ggplot2_py/guide.py +2925 -0
  17. ggplot2_py/guide_axis.py +615 -0
  18. ggplot2_py/guide_colourbar.py +657 -0
  19. ggplot2_py/guide_legend.py +1061 -0
  20. ggplot2_py/guides/__init__.py +8 -0
  21. ggplot2_py/labeller.py +296 -0
  22. ggplot2_py/labels.py +309 -0
  23. ggplot2_py/layer.py +954 -0
  24. ggplot2_py/layout.py +754 -0
  25. ggplot2_py/limits.py +314 -0
  26. ggplot2_py/plot.py +1401 -0
  27. ggplot2_py/plot_render.py +866 -0
  28. ggplot2_py/position.py +1269 -0
  29. ggplot2_py/protocols.py +171 -0
  30. ggplot2_py/py.typed +0 -0
  31. ggplot2_py/qplot.py +233 -0
  32. ggplot2_py/resources/diamonds.csv +53941 -0
  33. ggplot2_py/resources/economics.csv +575 -0
  34. ggplot2_py/resources/economics_long.csv +2871 -0
  35. ggplot2_py/resources/faithfuld.csv +5626 -0
  36. ggplot2_py/resources/luv_colours.csv +658 -0
  37. ggplot2_py/resources/midwest.csv +438 -0
  38. ggplot2_py/resources/mpg.csv +235 -0
  39. ggplot2_py/resources/msleep.csv +84 -0
  40. ggplot2_py/resources/presidential.csv +13 -0
  41. ggplot2_py/resources/seals.csv +1156 -0
  42. ggplot2_py/resources/txhousing.csv +8603 -0
  43. ggplot2_py/save.py +316 -0
  44. ggplot2_py/scale.py +2727 -0
  45. ggplot2_py/scales/__init__.py +4252 -0
  46. ggplot2_py/stat.py +6071 -0
  47. ggplot2_py/stats/__init__.py +9 -0
  48. ggplot2_py/theme.py +490 -0
  49. ggplot2_py/theme_defaults.py +1350 -0
  50. ggplot2_py/theme_elements.py +2052 -0
  51. ggplot2_python-4.0.2.9000.dist-info/METADATA +179 -0
  52. ggplot2_python-4.0.2.9000.dist-info/RECORD +54 -0
  53. ggplot2_python-4.0.2.9000.dist-info/WHEEL +4 -0
  54. 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)