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/geom.py ADDED
@@ -0,0 +1,4516 @@
1
+ """
2
+ Geom classes and constructor functions for ggplot2_py.
3
+
4
+ This module contains the base ``Geom`` class (a ``GGProto`` subclass) and all
5
+ concrete geom implementations (``GeomPoint``, ``GeomPath``, ``GeomBar``, etc.)
6
+ together with their user-facing constructor functions (``geom_point``,
7
+ ``geom_path``, ``geom_bar``, etc.).
8
+
9
+ Each ``Geom*`` class defines:
10
+
11
+ * ``required_aes`` -- tuple of required aesthetic names.
12
+ * ``non_missing_aes`` -- aesthetics whose ``NA`` values trigger row removal.
13
+ * ``optional_aes`` -- aesthetics accepted but not required.
14
+ * ``default_aes`` -- a :class:`Mapping` with default values.
15
+ * ``extra_params`` -- extra non-aesthetic parameter names.
16
+ * ``draw_key`` -- legend key drawing function.
17
+ * ``setup_params`` / ``setup_data`` -- data/parameter preprocessing.
18
+ * ``draw_panel`` or ``draw_group`` -- the actual grob-creation method.
19
+
20
+ Each ``geom_*()`` function is a thin wrapper that calls ``layer()``.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import warnings
26
+ from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
27
+
28
+ import numpy as np
29
+ import pandas as pd
30
+
31
+ from ggplot2_py.ggproto import GGProto, ggproto, ggproto_parent
32
+ from ggplot2_py._compat import Waiver, is_waiver, waiver, cli_abort, cli_warn
33
+ from ggplot2_py._utils import (
34
+ remove_missing,
35
+ resolution,
36
+ snake_class,
37
+ compact,
38
+ data_frame,
39
+ empty,
40
+ )
41
+ from ggplot2_py.aes import (
42
+ aes, Mapping, standardise_aes_names,
43
+ AfterScale, AfterStat, Stage, eval_aes_value,
44
+ )
45
+
46
+ # Import draw_key functions
47
+ from ggplot2_py.draw_key import (
48
+ draw_key_point,
49
+ draw_key_path,
50
+ draw_key_rect,
51
+ draw_key_polygon,
52
+ draw_key_blank,
53
+ draw_key_boxplot,
54
+ draw_key_crossbar,
55
+ draw_key_dotplot,
56
+ draw_key_label,
57
+ draw_key_linerange,
58
+ draw_key_pointrange,
59
+ draw_key_smooth,
60
+ draw_key_text,
61
+ draw_key_abline,
62
+ draw_key_vline,
63
+ draw_key_timeseries,
64
+ draw_key_vpath,
65
+ )
66
+
67
+ # grid_py grob creation imports
68
+ from grid_py import (
69
+ points_grob,
70
+ rect_grob,
71
+ lines_grob,
72
+ segments_grob,
73
+ polygon_grob,
74
+ polyline_grob,
75
+ text_grob,
76
+ circle_grob,
77
+ raster_grob,
78
+ path_grob,
79
+ curve_grob,
80
+ null_grob,
81
+ Gpar,
82
+ Unit,
83
+ grob_tree,
84
+ GTree,
85
+ GList,
86
+ clip_grob,
87
+ Viewport,
88
+ edit_grob,
89
+ roundrect_grob,
90
+ )
91
+
92
+ from scales import alpha as _scales_alpha_raw
93
+
94
+ import re as _re
95
+
96
+ def _r_col_to_mpl(c):
97
+ """Convert R-style grey names to RGB tuples for matplotlib."""
98
+ if isinstance(c, str):
99
+ m = _re.match(r'^gr[ae]y(\d{1,3})$', c)
100
+ if m:
101
+ v = int(m.group(1)) / 100.0
102
+ return f"#{int(v*255):02x}{int(v*255):02x}{int(v*255):02x}"
103
+ return c
104
+
105
+ def scales_alpha(colour, alpha):
106
+ """Apply alpha to colours, converting R colour names first."""
107
+ if isinstance(colour, (list, np.ndarray)):
108
+ colour = [_r_col_to_mpl(c) for c in colour]
109
+ elif isinstance(colour, str):
110
+ colour = _r_col_to_mpl(colour)
111
+ return _scales_alpha_raw(colour, alpha)
112
+
113
+ __all__ = [
114
+ # Base class
115
+ "Geom",
116
+ # ggproto classes
117
+ "GeomPoint", "GeomPath", "GeomLine", "GeomStep",
118
+ "GeomBar", "GeomCol", "GeomRect", "GeomTile", "GeomRaster",
119
+ "GeomText", "GeomLabel",
120
+ "GeomBoxplot", "GeomViolin", "GeomDotplot",
121
+ "GeomRibbon", "GeomArea", "GeomSmooth",
122
+ "GeomPolygon",
123
+ "GeomErrorbar", "GeomErrorbarh", "GeomCrossbar", "GeomLinerange", "GeomPointrange",
124
+ "GeomSegment", "GeomCurve", "GeomSpoke",
125
+ "GeomDensity", "GeomDensity2d", "GeomDensity2dFilled",
126
+ "GeomContour", "GeomContourFilled",
127
+ "GeomHex", "GeomBin2d",
128
+ "GeomAbline", "GeomHline", "GeomVline",
129
+ "GeomRug",
130
+ "GeomBlank",
131
+ "GeomFunction",
132
+ "GeomFreqpoly", "GeomHistogram",
133
+ "GeomCount",
134
+ "GeomMap",
135
+ "GeomQuantile",
136
+ "GeomJitter",
137
+ "GeomSf", "GeomAnnotationMap", "GeomCustomAnn", "GeomRasterAnn", "GeomLogticks",
138
+ # Constructor functions
139
+ "geom_point", "geom_path", "geom_line", "geom_step",
140
+ "geom_bar", "geom_col", "geom_rect", "geom_tile", "geom_raster",
141
+ "geom_text", "geom_label",
142
+ "geom_boxplot", "geom_violin", "geom_dotplot",
143
+ "geom_ribbon", "geom_area", "geom_smooth",
144
+ "geom_polygon",
145
+ "geom_errorbar", "geom_errorbarh", "geom_crossbar", "geom_linerange", "geom_pointrange",
146
+ "geom_segment", "geom_curve", "geom_spoke",
147
+ "geom_density", "geom_density2d", "geom_density2d_filled",
148
+ "geom_density_2d", "geom_density_2d_filled",
149
+ "geom_contour", "geom_contour_filled",
150
+ "geom_hex", "geom_bin2d", "geom_bin_2d",
151
+ "geom_abline", "geom_hline", "geom_vline",
152
+ "geom_rug",
153
+ "geom_blank",
154
+ "geom_function",
155
+ "geom_freqpoly", "geom_histogram",
156
+ "geom_count",
157
+ "geom_map",
158
+ "geom_quantile",
159
+ "geom_jitter",
160
+ "geom_sf", "geom_sf_label", "geom_sf_text",
161
+ "geom_qq", "geom_qq_line",
162
+ # Utility
163
+ "is_geom",
164
+ "translate_shape_string",
165
+ ]
166
+
167
+
168
+ # ===========================================================================
169
+ # from_theme — theme-aware default aesthetics (R: properties.R / aes-delayed-eval.R)
170
+ # ===========================================================================
171
+
172
+ class FromTheme:
173
+ """Marker for a default aesthetic that should be resolved from the theme.
174
+
175
+ R's ``from_theme()`` records an expression over the
176
+ ``element_geom`` properties and re-evaluates it at draw time,
177
+ e.g. ``from_theme(fill %||% col_mix(ink, paper, 0.35))``.
178
+
179
+ Python equivalents::
180
+
181
+ FromTheme("pointsize") # R: from_theme(pointsize)
182
+ FromTheme("colour", fallback="ink") # R: from_theme(colour %||% ink)
183
+ FromTheme("fill", fallback=lambda g: col_mix(g.ink, g.paper, 0.35))
184
+
185
+ Any callable passed as ``fallback`` receives the resolved
186
+ ``element_geom`` and must return the final value. ``%||%``
187
+ semantics apply — the fallback is only used when the primary
188
+ property is ``None``.
189
+ """
190
+
191
+ __slots__ = ("_prop", "_fallback")
192
+
193
+ def __init__(self, prop: str, fallback: Any = None):
194
+ self._prop = prop
195
+ # fallback: None | str | callable(element_geom) -> value
196
+ self._fallback = fallback
197
+
198
+ def resolve(self, geom_el: Any) -> Any:
199
+ """Evaluate against an ``element_geom``.
200
+
201
+ R semantics: ``x %||% y`` uses ``y`` only if ``x`` is ``NULL``.
202
+ When ``prop`` is a callable, it is invoked with the
203
+ ``element_geom`` directly — used for expressions that are not
204
+ plain property lookups, e.g. ``from_theme(2 * linewidth)``
205
+ (geom-smooth.R:55).
206
+ """
207
+ if callable(self._prop):
208
+ return self._prop(geom_el)
209
+ val = getattr(geom_el, self._prop, None)
210
+ if val is not None:
211
+ return val
212
+ fb = self._fallback
213
+ if fb is None:
214
+ return None
215
+ if callable(fb):
216
+ return fb(geom_el)
217
+ # String-name fallback: another property on the element_geom
218
+ return getattr(geom_el, fb, None)
219
+
220
+ def __repr__(self) -> str:
221
+ return f"FromTheme({self._prop!r})"
222
+
223
+
224
+ # Default element_geom properties (R: theme-elements.R:356-363
225
+ # `.default_geom_element`). These are the *exact* values hardcoded
226
+ # in R; we match them verbatim.
227
+ _DEFAULT_GEOM_PROPS = {
228
+ "ink": "black",
229
+ "paper": "white",
230
+ "accent": "#3366FF",
231
+ "linewidth": 0.5,
232
+ "borderwidth": 0.5,
233
+ "linetype": 1,
234
+ "bordertype": 1,
235
+ "family": "",
236
+ "fontsize": 11,
237
+ "pointsize": 1.5,
238
+ "pointshape": 19,
239
+ "fill": None,
240
+ "colour": None,
241
+ }
242
+
243
+
244
+ class _DefaultGeomElement:
245
+ """Fallback geom element when theme has no ``"geom"`` entry.
246
+
247
+ Mirrors R's ``.default_geom_element`` (theme-elements.R:356-363).
248
+ """
249
+
250
+ def __getattr__(self, name: str) -> Any:
251
+ if name in _DEFAULT_GEOM_PROPS:
252
+ return _DEFAULT_GEOM_PROPS[name]
253
+ raise AttributeError(name)
254
+
255
+
256
+ def _eval_from_theme(default_aes: "Mapping", theme: Any) -> "Mapping":
257
+ """Resolve any ``FromTheme`` markers in *default_aes* using *theme*.
258
+
259
+ Mirrors R's ``eval_from_theme()`` (geom-.R:471-498). Falls back
260
+ to ``_DefaultGeomElement`` when ``theme$geom`` is absent — matching
261
+ R which uses ``.default_geom_element`` in that case.
262
+ """
263
+ has_themed = any(isinstance(v, FromTheme) for v in default_aes.values())
264
+ if not has_themed:
265
+ return default_aes
266
+
267
+ # Get the geom element from theme, else fall back to R's default
268
+ from ggplot2_py.theme_elements import calc_element, ElementGeom
269
+ theme_geom_el = None
270
+ if theme is not None:
271
+ theme_geom_el = calc_element("geom", theme)
272
+ # calc_element may return a partially-populated ElementGeom (user
273
+ # only set some props). Wrap it with defaults for missing props.
274
+ default_el = _DefaultGeomElement()
275
+ if theme_geom_el is None:
276
+ geom_el = default_el
277
+ elif isinstance(theme_geom_el, ElementGeom):
278
+ # Proxy: read from theme first, then .default_geom_element.
279
+ # Capturing ``theme_geom_el`` (not ``geom_el``) avoids the
280
+ # re-assignment masking the closure cell and recursing.
281
+ _src = theme_geom_el
282
+ _defaults = default_el
283
+ class _MergedGeomElement:
284
+ def __getattr__(self, name):
285
+ v = getattr(_src, name, None)
286
+ if v is not None:
287
+ return v
288
+ return getattr(_defaults, name)
289
+ geom_el = _MergedGeomElement()
290
+ else:
291
+ geom_el = theme_geom_el
292
+
293
+ resolved = {}
294
+ for key, val in default_aes.items():
295
+ if isinstance(val, FromTheme):
296
+ resolved[key] = val.resolve(geom_el)
297
+ else:
298
+ resolved[key] = val
299
+ return Mapping(**resolved)
300
+
301
+
302
+ # ===========================================================================
303
+ # Graphical-unit constants
304
+ # ===========================================================================
305
+
306
+ #: Points per mm (``72.27 / 25.4``)
307
+ PT: float = 72.27 / 25.4
308
+ #: Stroke scale factor (``96 / 25.4``)
309
+ STROKE: float = 96 / 25.4
310
+
311
+
312
+ # ===========================================================================
313
+ # Utilities
314
+ # ===========================================================================
315
+
316
+ def _fill_alpha(fill: Any, alpha_val: Any) -> Any:
317
+ """Apply alpha to a fill colour, passing through ``None``."""
318
+ if fill is None:
319
+ return None
320
+ try:
321
+ return scales_alpha(fill, alpha_val)
322
+ except Exception:
323
+ return fill
324
+
325
+
326
+ def _gg_par(**kwargs: Any) -> Gpar:
327
+ """Build a :class:`Gpar` filtering out ``None`` entries and converting
328
+ ``linewidth`` (mm) to ``lwd`` (pts) when needed."""
329
+ # Convert lwd from mm to pts
330
+ if "lwd" in kwargs and kwargs["lwd"] is not None:
331
+ try:
332
+ kwargs["lwd"] = np.asarray(kwargs["lwd"], dtype=float) * PT
333
+ except (TypeError, ValueError):
334
+ pass
335
+ filtered = {k: v for k, v in kwargs.items() if v is not None}
336
+ return Gpar(**filtered)
337
+
338
+
339
+ def _ggname(prefix: str, grob: Any) -> Any:
340
+ """Attach a name prefix to a grob (used for identification in grob trees)."""
341
+ try:
342
+ grob.name = prefix
343
+ except AttributeError:
344
+ pass
345
+ return grob
346
+
347
+
348
+ # ---------------------------------------------------------------------------
349
+ # Shape translation
350
+ # ---------------------------------------------------------------------------
351
+
352
+ _PCH_TABLE: Dict[str, int] = {
353
+ "square open": 0,
354
+ "circle open": 1,
355
+ "triangle open": 2,
356
+ "plus": 3,
357
+ "cross": 4,
358
+ "diamond open": 5,
359
+ "triangle down open": 6,
360
+ "square cross": 7,
361
+ "asterisk": 8,
362
+ "diamond plus": 9,
363
+ "circle plus": 10,
364
+ "star": 11,
365
+ "square plus": 12,
366
+ "circle cross": 13,
367
+ "square triangle": 14,
368
+ "triangle square": 14,
369
+ "square": 15,
370
+ "circle small": 16,
371
+ "triangle": 17,
372
+ "diamond": 18,
373
+ "circle": 19,
374
+ "bullet": 20,
375
+ "circle filled": 21,
376
+ "square filled": 22,
377
+ "diamond filled": 23,
378
+ "triangle filled": 24,
379
+ "triangle down filled": 25,
380
+ }
381
+
382
+
383
+ def translate_shape_string(shape: Any) -> Any:
384
+ """Translate point shape names to integer pch codes.
385
+
386
+ Parameters
387
+ ----------
388
+ shape : str or array-like of str, or numeric
389
+ Shape specification. If numeric or single-character strings,
390
+ returned as-is.
391
+
392
+ Returns
393
+ -------
394
+ int or array-like
395
+ Integer pch values.
396
+ """
397
+ if shape is None:
398
+ return 19 # default circle
399
+ if isinstance(shape, (int, float, np.integer, np.floating)):
400
+ return int(shape)
401
+ if isinstance(shape, str):
402
+ if len(shape) <= 1:
403
+ return shape
404
+ lower = shape.lower()
405
+ for name, code in _PCH_TABLE.items():
406
+ if name.startswith(lower):
407
+ return code
408
+ cli_abort(f"Shape aesthetic contains invalid value: {shape!r}.")
409
+ # array-like
410
+ if hasattr(shape, "__iter__"):
411
+ return np.array([translate_shape_string(s) for s in shape])
412
+ return shape
413
+
414
+
415
+ def is_geom(x: Any) -> bool:
416
+ """Return ``True`` if *x* is a ``Geom`` subclass or instance."""
417
+ if isinstance(x, type):
418
+ return issubclass(x, Geom)
419
+ return isinstance(x, Geom)
420
+
421
+
422
+ # ===========================================================================
423
+ # Base Geom class
424
+ # ===========================================================================
425
+
426
+ class Geom(GGProto):
427
+ """Base class for all geometry objects.
428
+
429
+ Subclasses must override at least ``draw_panel`` or ``draw_group``.
430
+
431
+ Attributes
432
+ ----------
433
+ required_aes : tuple of str
434
+ Aesthetics that *must* be present.
435
+ non_missing_aes : tuple of str
436
+ Aesthetics that trigger row-removal if ``NA``.
437
+ optional_aes : tuple of str
438
+ Extra accepted aesthetics.
439
+ default_aes : Mapping
440
+ Default aesthetic values.
441
+ extra_params : tuple of str
442
+ Extra non-aesthetic parameters (e.g. ``"na_rm"``).
443
+ draw_key : callable
444
+ Legend key drawing function.
445
+ rename_size : bool
446
+ Whether to rename ``size`` to ``linewidth``.
447
+ """
448
+
449
+ # --- Auto-registration registry (Python-exclusive) -------------------
450
+ _registry: Dict[str, Any] = {}
451
+
452
+ required_aes: Tuple[str, ...] = ()
453
+ non_missing_aes: Tuple[str, ...] = ()
454
+ optional_aes: Tuple[str, ...] = ()
455
+ default_aes: Mapping = Mapping()
456
+ extra_params: Tuple[str, ...] = ("na_rm",)
457
+ draw_key = draw_key_point
458
+ rename_size: bool = False
459
+
460
+ def __init_subclass__(cls, **kwargs: Any) -> None:
461
+ super().__init_subclass__(**kwargs)
462
+ # Auto-register: GeomPoint -> "point", GeomBar -> "bar", etc.
463
+ name = cls.__name__
464
+ if name.startswith("Geom") and len(name) > 4:
465
+ key = name[4:] # strip "Geom" prefix
466
+ # Store both CamelCase and lower-case keys
467
+ Geom._registry[key] = cls
468
+ Geom._registry[key.lower()] = cls
469
+
470
+ # -----------------------------------------------------------------------
471
+ # Setup hooks (run before position adjustments)
472
+ # -----------------------------------------------------------------------
473
+
474
+ def setup_params(self, data: pd.DataFrame, params: Dict[str, Any]) -> Dict[str, Any]:
475
+ """Modify or validate parameters given the data.
476
+
477
+ Parameters
478
+ ----------
479
+ data : DataFrame
480
+ Layer data.
481
+ params : dict
482
+ Current parameters.
483
+
484
+ Returns
485
+ -------
486
+ dict
487
+ Possibly modified parameters.
488
+ """
489
+ return params
490
+
491
+ def setup_data(self, data: pd.DataFrame, params: Dict[str, Any]) -> pd.DataFrame:
492
+ """Modify or validate data before defaults are applied.
493
+
494
+ Parameters
495
+ ----------
496
+ data : DataFrame
497
+ Layer data.
498
+ params : dict
499
+ Parameters from ``setup_params``.
500
+
501
+ Returns
502
+ -------
503
+ DataFrame
504
+ """
505
+ return data
506
+
507
+ # -----------------------------------------------------------------------
508
+ # Missing-value handling
509
+ # -----------------------------------------------------------------------
510
+
511
+ def handle_na(self, data: pd.DataFrame, params: Dict[str, Any]) -> pd.DataFrame:
512
+ """Remove rows with missing values in required aesthetics.
513
+
514
+ Parameters
515
+ ----------
516
+ data : DataFrame
517
+ params : dict
518
+
519
+ Returns
520
+ -------
521
+ DataFrame
522
+ """
523
+ na_rm = params.get("na_rm", params.get("na.rm", False))
524
+ check_vars = list(self.required_aes) + list(self.non_missing_aes)
525
+ return remove_missing(data, vars=check_vars, na_rm=na_rm, name=snake_class(self))
526
+
527
+ # -----------------------------------------------------------------------
528
+ # use_defaults -- fill in default aesthetics
529
+ # -----------------------------------------------------------------------
530
+
531
+ def use_defaults(
532
+ self,
533
+ data: pd.DataFrame,
534
+ params: Optional[Dict[str, Any]] = None,
535
+ modifiers: Optional[Mapping] = None,
536
+ default_aes: Optional[Mapping] = None,
537
+ theme: Any = None,
538
+ ) -> pd.DataFrame:
539
+ """Fill missing aesthetics with defaults and apply parameter overrides.
540
+
541
+ Parameters
542
+ ----------
543
+ data : DataFrame
544
+ params : dict, optional
545
+ modifiers : Mapping, optional
546
+ default_aes : Mapping, optional
547
+ theme : optional
548
+
549
+ Returns
550
+ -------
551
+ DataFrame
552
+ """
553
+ if params is None:
554
+ params = {}
555
+ if modifiers is None:
556
+ modifiers = Mapping()
557
+ if default_aes is None:
558
+ default_aes = self.default_aes
559
+
560
+ # Resolve FromTheme markers using the theme (R: eval_from_theme)
561
+ default_aes = _eval_from_theme(default_aes, theme)
562
+
563
+ # Inherit size as linewidth when applicable
564
+ if self.rename_size:
565
+ if data is not None and "linewidth" not in data.columns and "size" in data.columns:
566
+ data = data.copy()
567
+ data["linewidth"] = data["size"]
568
+ if "linewidth" not in params and "size" in params:
569
+ params["linewidth"] = params["size"]
570
+
571
+ # Fill in missing aesthetics with their defaults
572
+ if data is not None and not data.empty:
573
+ for aes_name, default_val in default_aes.items():
574
+ if aes_name not in data.columns:
575
+ data[aes_name] = default_val
576
+
577
+ # Override with params
578
+ aes_params = set(self.aesthetics()) & set(params.keys())
579
+ if data is not None and not data.empty:
580
+ for ap in aes_params:
581
+ data[ap] = params[ap]
582
+
583
+ # Evaluate after_scale modifiers (R ref: geom-.R:243-265).
584
+ # R calls eval_aesthetics(substitute_aes(modifiers), data,
585
+ # mask=list(stage=stage_scaled)).
586
+ # In Python, modifiers is a dict of AfterScale/Stage objects whose
587
+ # after_scale slot should be evaluated against the now-complete data.
588
+ if modifiers and data is not None and not data.empty:
589
+ for aes_name, mod_val in modifiers.items():
590
+ target = None
591
+ if isinstance(mod_val, AfterScale):
592
+ target = mod_val.x
593
+ elif isinstance(mod_val, Stage) and mod_val.after_scale is not None:
594
+ as_obj = mod_val.after_scale
595
+ target = as_obj.x if isinstance(as_obj, AfterScale) else as_obj
596
+ if target is not None:
597
+ try:
598
+ result = eval_aes_value(target, data)
599
+ if result is not None:
600
+ data[aes_name] = result
601
+ except Exception:
602
+ # R: cli::cli_warn("Unable to apply staged modifications.")
603
+ import warnings
604
+ warnings.warn(
605
+ f"Unable to apply after_scale modifier for '{aes_name}'.",
606
+ stacklevel=2,
607
+ )
608
+
609
+ return data
610
+
611
+ # -----------------------------------------------------------------------
612
+ # Drawing
613
+ # -----------------------------------------------------------------------
614
+
615
+ def draw_layer(
616
+ self,
617
+ data: pd.DataFrame,
618
+ params: Dict[str, Any],
619
+ layout: Any,
620
+ coord: Any,
621
+ ) -> List[Any]:
622
+ """Orchestrate drawing for all panels.
623
+
624
+ Parameters
625
+ ----------
626
+ data : DataFrame
627
+ params : dict
628
+ layout : Layout
629
+ coord : Coord
630
+
631
+ Returns
632
+ -------
633
+ list of grobs
634
+ """
635
+ if data is None or (hasattr(data, "empty") and data.empty):
636
+ return [null_grob()]
637
+
638
+ # Split by PANEL
639
+ if "PANEL" in data.columns:
640
+ panels = {k: v for k, v in data.groupby("PANEL", observed=True)}
641
+ else:
642
+ panels = {1: data}
643
+
644
+ grobs = []
645
+ for panel_id, panel_data in panels.items():
646
+ if panel_data.empty:
647
+ grobs.append(null_grob())
648
+ continue
649
+ # PANEL is 1-based, panel_params list is 0-based
650
+ idx = int(panel_id) - 1 if isinstance(panel_id, (int, np.integer)) else panel_id
651
+ panel_params = layout.panel_params[idx]
652
+ grobs.append(self.draw_panel(panel_data, panel_params, coord, **params))
653
+ return grobs
654
+
655
+ def draw_panel(
656
+ self,
657
+ data: pd.DataFrame,
658
+ panel_params: Any,
659
+ coord: Any,
660
+ **params: Any,
661
+ ) -> Any:
662
+ """Draw the geom for a single panel.
663
+
664
+ The default implementation splits on ``group`` and delegates to
665
+ ``draw_group``.
666
+
667
+ Parameters
668
+ ----------
669
+ data : DataFrame
670
+ panel_params : panel parameters
671
+ coord : Coord
672
+
673
+ Returns
674
+ -------
675
+ grob
676
+ """
677
+ if "group" not in data.columns:
678
+ return self.draw_group(data, panel_params, coord, **params)
679
+
680
+ groups = {k: v for k, v in data.groupby("group")}
681
+ grobs = []
682
+ for _, group_data in groups.items():
683
+ grobs.append(self.draw_group(group_data, panel_params, coord, **params))
684
+
685
+ return _ggname(
686
+ snake_class(self),
687
+ grob_tree(*grobs) if grobs else null_grob(),
688
+ )
689
+
690
+ def draw_group(
691
+ self,
692
+ data: pd.DataFrame,
693
+ panel_params: Any,
694
+ coord: Any,
695
+ **params: Any,
696
+ ) -> Any:
697
+ """Draw the geom for a single group.
698
+
699
+ Must be overridden by subclasses that need per-group drawing.
700
+
701
+ Parameters
702
+ ----------
703
+ data : DataFrame
704
+ panel_params : panel parameters
705
+ coord : Coord
706
+
707
+ Returns
708
+ -------
709
+ grob
710
+ """
711
+ cli_abort(f"{snake_class(self)} has not implemented a draw_group method")
712
+
713
+ # -----------------------------------------------------------------------
714
+ # Utility methods
715
+ # -----------------------------------------------------------------------
716
+
717
+ def parameters(self, extra: bool = False) -> List[str]:
718
+ """List acceptable parameters for this geom.
719
+
720
+ Parameters
721
+ ----------
722
+ extra : bool
723
+ Whether to include ``extra_params``.
724
+
725
+ Returns
726
+ -------
727
+ list of str
728
+ """
729
+ import inspect
730
+ sig = inspect.signature(self.draw_panel)
731
+ args = [p for p in sig.parameters if p not in ("self", "data", "panel_params", "coord")]
732
+ if extra:
733
+ args = list(set(args) | set(self.extra_params))
734
+ return args
735
+
736
+ def aesthetics(self) -> List[str]:
737
+ """List all accepted aesthetics.
738
+
739
+ Returns
740
+ -------
741
+ list of str
742
+ """
743
+ required = []
744
+ for aes_name in self.required_aes:
745
+ required.extend(aes_name.split("|"))
746
+
747
+ aes_names = list(dict.fromkeys(required + list(self.default_aes.keys())))
748
+ aes_names.extend(a for a in self.optional_aes if a not in aes_names)
749
+ if "group" not in aes_names:
750
+ aes_names.append("group")
751
+ return aes_names
752
+
753
+
754
+ # ===========================================================================
755
+ # Helper: _coord_transform
756
+ # ===========================================================================
757
+
758
+ def _coord_transform(coord: Any, data: pd.DataFrame, panel_params: Any) -> pd.DataFrame:
759
+ """Safely apply coordinate transformation."""
760
+ if coord is not None and hasattr(coord, "transform"):
761
+ return coord.transform(data, panel_params)
762
+ return data
763
+
764
+
765
+ # ===========================================================================
766
+ # GeomPoint
767
+ # ===========================================================================
768
+
769
+ class GeomPoint(Geom):
770
+ """Point geom (scatterplot)."""
771
+
772
+ required_aes: Tuple[str, ...] = ("x", "y")
773
+ non_missing_aes: Tuple[str, ...] = ("size", "shape", "colour")
774
+ # R: aes(shape=from_theme(pointshape), colour=from_theme(colour %||% ink),
775
+ # fill=from_theme(fill %||% NA), size=from_theme(pointsize),
776
+ # alpha=NA, stroke=from_theme(borderwidth))
777
+ default_aes: Mapping = Mapping(
778
+ shape=FromTheme("pointshape"),
779
+ colour=FromTheme("colour", fallback="ink"),
780
+ fill=FromTheme("fill"),
781
+ size=FromTheme("pointsize"),
782
+ alpha=None,
783
+ stroke=FromTheme("borderwidth"),
784
+ )
785
+ draw_key = draw_key_point
786
+
787
+ def draw_panel(
788
+ self,
789
+ data: pd.DataFrame,
790
+ panel_params: Any,
791
+ coord: Any,
792
+ na_rm: bool = False,
793
+ **params: Any,
794
+ ) -> Any:
795
+ """Draw points.
796
+
797
+ Parameters
798
+ ----------
799
+ data : DataFrame
800
+ panel_params : panel parameters
801
+ coord : Coord
802
+ na_rm : bool
803
+
804
+ Returns
805
+ -------
806
+ grob
807
+ """
808
+ data = data.copy()
809
+ if "shape" in data.columns:
810
+ data["shape"] = data["shape"].apply(translate_shape_string)
811
+ coords = _coord_transform(coord, data, panel_params)
812
+
813
+ # R (utilities-grid.R:35-43 gg_par):
814
+ # args$lwd = stroke * .stroke / 2
815
+ # args$fontsize = pointsize * .pt + stroke * .stroke / 2
816
+ size_arr = coords["size"].values if "size" in coords.columns else 1.5
817
+ stroke_arr = coords["stroke"].values if "stroke" in coords.columns else 0.5
818
+ return _ggname(
819
+ "geom_point",
820
+ points_grob(
821
+ x=coords["x"].values,
822
+ y=coords["y"].values,
823
+ pch=coords["shape"].values if "shape" in coords.columns else 19,
824
+ gp=Gpar(
825
+ col=scales_alpha(
826
+ coords["colour"].values if "colour" in coords.columns else "black",
827
+ coords["alpha"].values if "alpha" in coords.columns else None,
828
+ ),
829
+ fill=_fill_alpha(
830
+ coords["fill"].values if "fill" in coords.columns else None,
831
+ coords["alpha"].values if "alpha" in coords.columns else None,
832
+ ),
833
+ fontsize=size_arr * PT + stroke_arr * STROKE / 2,
834
+ lwd=stroke_arr * STROKE / 2,
835
+ ),
836
+ ),
837
+ )
838
+
839
+
840
+ # ===========================================================================
841
+ # GeomPath / GeomLine / GeomStep
842
+ # ===========================================================================
843
+
844
+ class GeomPath(Geom):
845
+ """Path geom -- connects observations in data order."""
846
+
847
+ required_aes: Tuple[str, ...] = ("x", "y")
848
+ non_missing_aes: Tuple[str, ...] = ("linewidth", "colour", "linetype")
849
+ # R: aes(colour=from_theme(colour %||% ink), linewidth=from_theme(linewidth),
850
+ # linetype=from_theme(linetype), alpha=NA)
851
+ default_aes: Mapping = Mapping(
852
+ colour=FromTheme("colour", fallback="ink"),
853
+ linewidth=FromTheme("linewidth"),
854
+ linetype=FromTheme("linetype"),
855
+ alpha=None,
856
+ )
857
+ draw_key = draw_key_path
858
+ rename_size: bool = True
859
+
860
+ def draw_panel(
861
+ self,
862
+ data: pd.DataFrame,
863
+ panel_params: Any,
864
+ coord: Any,
865
+ arrow: Any = None,
866
+ lineend: str = "butt",
867
+ linejoin: str = "round",
868
+ linemitre: float = 10,
869
+ na_rm: bool = False,
870
+ **params: Any,
871
+ ) -> Any:
872
+ """Draw connected paths.
873
+
874
+ R splits data by group and draws one polyline per group so
875
+ that each group can have its own colour/linetype/linewidth.
876
+ """
877
+ coords = _coord_transform(coord, data, panel_params)
878
+
879
+ if coords.empty or len(coords) < 2:
880
+ return null_grob()
881
+
882
+ # R semantics: split by group, draw each separately
883
+ # so per-group colour/lwd/lty are respected.
884
+ if "group" not in coords.columns:
885
+ coords["group"] = 0
886
+
887
+ children = []
888
+ for gid, gdata in coords.groupby("group", sort=True, observed=True):
889
+ if len(gdata) < 2:
890
+ continue
891
+ # Take first-row aesthetics for the whole group
892
+ row0 = gdata.iloc[0]
893
+ col_val = row0.get("colour", "black")
894
+ alpha_val = row0.get("alpha", None)
895
+ lwd_val = float(row0.get("linewidth", 0.5)) * PT
896
+ lty_val = row0.get("linetype", 1)
897
+
898
+ col_str = scales_alpha(col_val, alpha_val)
899
+
900
+ children.append(polyline_grob(
901
+ x=gdata["x"].values,
902
+ y=gdata["y"].values,
903
+ default_units="native",
904
+ gp=Gpar(
905
+ col=col_str,
906
+ lwd=lwd_val,
907
+ lty=lty_val,
908
+ lineend=lineend,
909
+ linejoin=linejoin,
910
+ linemitre=linemitre,
911
+ ),
912
+ arrow=arrow,
913
+ name=f"path.{gid}",
914
+ ))
915
+
916
+ if not children:
917
+ return null_grob()
918
+ return _ggname("geom_path", grob_tree(*children))
919
+
920
+
921
+ class GeomLine(GeomPath):
922
+ """Line geom -- like path but sorted by x."""
923
+
924
+ extra_params: Tuple[str, ...] = ("na_rm", "orientation")
925
+
926
+ def setup_params(self, data: pd.DataFrame, params: Dict[str, Any]) -> Dict[str, Any]:
927
+ params["flipped_aes"] = params.get("flipped_aes", False)
928
+ return params
929
+
930
+ def setup_data(self, data: pd.DataFrame, params: Dict[str, Any]) -> pd.DataFrame:
931
+ data = data.copy()
932
+ flipped = params.get("flipped_aes", False)
933
+ sort_col = "y" if flipped else "x"
934
+ group_cols = ["PANEL", "group"] if "group" in data.columns else ["PANEL"]
935
+ group_cols = [c for c in group_cols if c in data.columns]
936
+ if sort_col in data.columns:
937
+ data = data.sort_values(group_cols + [sort_col])
938
+ return data
939
+
940
+
941
+ class GeomStep(GeomPath):
942
+ """Step geom -- stairstep connections."""
943
+
944
+ extra_params: Tuple[str, ...] = ("na_rm", "orientation")
945
+
946
+ def setup_params(self, data: pd.DataFrame, params: Dict[str, Any]) -> Dict[str, Any]:
947
+ params["flipped_aes"] = params.get("flipped_aes", False)
948
+ return params
949
+
950
+ def draw_panel(
951
+ self,
952
+ data: pd.DataFrame,
953
+ panel_params: Any,
954
+ coord: Any,
955
+ direction: str = "hv",
956
+ lineend: str = "butt",
957
+ linejoin: str = "round",
958
+ linemitre: float = 10,
959
+ arrow: Any = None,
960
+ flipped_aes: bool = False,
961
+ **params: Any,
962
+ ) -> Any:
963
+ """Draw step connections."""
964
+ data = data.copy()
965
+ data = _stairstep(data, direction=direction)
966
+ return GeomPath.draw_panel(
967
+ self, data, panel_params, coord,
968
+ lineend=lineend, linejoin=linejoin, linemitre=linemitre,
969
+ arrow=arrow,
970
+ )
971
+
972
+
973
+ def _stairstep(data: pd.DataFrame, direction: str = "hv") -> pd.DataFrame:
974
+ """Calculate stairstep coordinates for :class:`GeomStep`."""
975
+ if direction not in ("hv", "vh", "mid"):
976
+ cli_abort(f"direction must be 'hv', 'vh', or 'mid', not {direction!r}")
977
+ data = data.sort_values("x").reset_index(drop=True)
978
+ n = len(data)
979
+ if n <= 1:
980
+ return data.iloc[:0]
981
+
982
+ x = data["x"].values
983
+ y = data["y"].values
984
+
985
+ if direction == "hv":
986
+ xs = np.repeat(x, 2)[1:]
987
+ ys = np.repeat(y, 2)[:-1]
988
+ elif direction == "vh":
989
+ xs = np.repeat(x, 2)[:-1]
990
+ ys = np.repeat(y, 2)[1:]
991
+ else: # mid
992
+ gaps = np.diff(x)
993
+ mid_x = x[:-1] + gaps / 2
994
+ xs_idx = np.repeat(np.arange(n - 1), 2)
995
+ ys_idx = np.repeat(np.arange(n), 2)
996
+ xs_arr = np.concatenate([[x[0]], mid_x[xs_idx], [x[-1]]])
997
+ ys_arr = y[ys_idx]
998
+ result = data.iloc[[0]].copy()
999
+ result = pd.DataFrame({"x": xs_arr, "y": ys_arr})
1000
+ # carry forward other columns
1001
+ for col in data.columns:
1002
+ if col not in ("x", "y"):
1003
+ result[col] = data[col].iloc[0]
1004
+ return result
1005
+
1006
+ result = pd.DataFrame({"x": xs, "y": ys})
1007
+ for col in data.columns:
1008
+ if col not in ("x", "y"):
1009
+ result[col] = data[col].iloc[0]
1010
+ return result
1011
+
1012
+
1013
+ # ===========================================================================
1014
+ # GeomRect / GeomTile / GeomRaster
1015
+ # ===========================================================================
1016
+
1017
+ def _mix_ink_paper(ratio: float):
1018
+ """Build a fallback callable that computes ``col_mix(ink, paper, ratio)``.
1019
+
1020
+ Mirrors R's inline expressions such as ``col_mix(ink, paper, 0.35)``
1021
+ used in geom default aesthetics (geom-rect.R, geom-ribbon.R, etc.).
1022
+ """
1023
+ from scales import col_mix as _cm
1024
+ def _fn(g):
1025
+ return _cm(g.ink, g.paper, ratio)
1026
+ return _fn
1027
+
1028
+
1029
+ class GeomRect(Geom):
1030
+ """Rectangle geom (defined by xmin, xmax, ymin, ymax)."""
1031
+
1032
+ required_aes: Tuple[str, ...] = ("xmin", "xmax", "ymin", "ymax")
1033
+ # R (geom-rect.R:6-11):
1034
+ # colour = from_theme(colour %||% NA),
1035
+ # fill = from_theme(fill %||% col_mix(ink, paper, 0.35)),
1036
+ # linewidth = from_theme(borderwidth),
1037
+ # linetype = from_theme(bordertype),
1038
+ default_aes: Mapping = Mapping(
1039
+ colour=FromTheme("colour"),
1040
+ fill=FromTheme("fill", fallback=_mix_ink_paper(0.35)),
1041
+ linewidth=FromTheme("borderwidth"),
1042
+ linetype=FromTheme("bordertype"),
1043
+ alpha=None,
1044
+ )
1045
+ draw_key = draw_key_polygon
1046
+ rename_size: bool = True
1047
+
1048
+ def setup_data(self, data: pd.DataFrame, params: Dict[str, Any]) -> pd.DataFrame:
1049
+ """Resolve rect aesthetics from center + size if corners are missing."""
1050
+ if all(c in data.columns for c in ("xmin", "xmax", "ymin", "ymax")):
1051
+ return data
1052
+ data = data.copy()
1053
+ # Resolve x-dimension
1054
+ if "xmin" not in data.columns or "xmax" not in data.columns:
1055
+ if "x" in data.columns and "width" in data.columns:
1056
+ data["xmin"] = data["x"] - data["width"] / 2
1057
+ data["xmax"] = data["x"] + data["width"] / 2
1058
+ elif "x" in data.columns:
1059
+ w = params.get("width", 0.9)
1060
+ data["xmin"] = data["x"] - w / 2
1061
+ data["xmax"] = data["x"] + w / 2
1062
+ # Resolve y-dimension
1063
+ if "ymin" not in data.columns or "ymax" not in data.columns:
1064
+ if "y" in data.columns and "height" in data.columns:
1065
+ data["ymin"] = data["y"] - data["height"] / 2
1066
+ data["ymax"] = data["y"] + data["height"] / 2
1067
+ elif "y" in data.columns:
1068
+ h = params.get("height", 0.9)
1069
+ data["ymin"] = data["y"] - h / 2
1070
+ data["ymax"] = data["y"] + h / 2
1071
+ return data
1072
+
1073
+ def draw_panel(
1074
+ self,
1075
+ data: pd.DataFrame,
1076
+ panel_params: Any,
1077
+ coord: Any,
1078
+ lineend: str = "butt",
1079
+ linejoin: str = "mitre",
1080
+ **params: Any,
1081
+ ) -> Any:
1082
+ """Draw rectangles."""
1083
+ coords = _coord_transform(coord, data, panel_params)
1084
+
1085
+ return _ggname(
1086
+ "geom_rect",
1087
+ rect_grob(
1088
+ x=coords["xmin"].values,
1089
+ y=coords["ymax"].values,
1090
+ width=coords["xmax"].values - coords["xmin"].values,
1091
+ height=coords["ymax"].values - coords["ymin"].values,
1092
+ default_units="native",
1093
+ just=("left", "top"),
1094
+ gp=Gpar(
1095
+ col=coords["colour"].values if "colour" in coords.columns else None,
1096
+ fill=_fill_alpha(
1097
+ coords["fill"].values if "fill" in coords.columns else "grey35",
1098
+ coords["alpha"].values if "alpha" in coords.columns else None,
1099
+ ),
1100
+ lwd=(
1101
+ coords["linewidth"].values * PT
1102
+ if "linewidth" in coords.columns
1103
+ else 0.5 * PT
1104
+ ),
1105
+ lty=coords["linetype"].values if "linetype" in coords.columns else 1,
1106
+ linejoin=linejoin,
1107
+ lineend=lineend,
1108
+ ),
1109
+ ),
1110
+ )
1111
+
1112
+
1113
+ class GeomTile(GeomRect):
1114
+ """Tile geom -- rectangles parameterised by center and size."""
1115
+
1116
+ required_aes: Tuple[str, ...] = ("x", "y")
1117
+ non_missing_aes: Tuple[str, ...] = ("xmin", "xmax", "ymin", "ymax")
1118
+ # R (geom-tile.R:26-35):
1119
+ # fill = from_theme(fill %||% col_mix(ink, paper, 0.2)),
1120
+ # colour = from_theme(colour %||% NA),
1121
+ # linewidth = from_theme(linewidth), linetype = from_theme(linetype)
1122
+ default_aes: Mapping = Mapping(
1123
+ fill=FromTheme("fill", fallback=_mix_ink_paper(0.2)),
1124
+ colour=FromTheme("colour"),
1125
+ linewidth=FromTheme("linewidth"),
1126
+ linetype=FromTheme("linetype"),
1127
+ alpha=None,
1128
+ width=1,
1129
+ height=1,
1130
+ )
1131
+ draw_key = draw_key_polygon
1132
+ extra_params: Tuple[str, ...] = ("na_rm",)
1133
+
1134
+ def setup_data(self, data: pd.DataFrame, params: Dict[str, Any]) -> pd.DataFrame:
1135
+ data = data.copy()
1136
+ w = data["width"].values if "width" in data.columns else params.get("width", 1)
1137
+ h = data["height"].values if "height" in data.columns else params.get("height", 1)
1138
+ data["xmin"] = data["x"] - np.asarray(w) / 2
1139
+ data["xmax"] = data["x"] + np.asarray(w) / 2
1140
+ data["ymin"] = data["y"] - np.asarray(h) / 2
1141
+ data["ymax"] = data["y"] + np.asarray(h) / 2
1142
+ return data
1143
+
1144
+
1145
+ class GeomRaster(Geom):
1146
+ """Raster geom -- high-performance uniform tiles."""
1147
+
1148
+ required_aes: Tuple[str, ...] = ("x", "y")
1149
+ non_missing_aes: Tuple[str, ...] = ("fill",)
1150
+ default_aes: Mapping = Mapping(fill="grey35", alpha=None)
1151
+ draw_key = draw_key_polygon
1152
+
1153
+ def setup_data(self, data: pd.DataFrame, params: Dict[str, Any]) -> pd.DataFrame:
1154
+ hjust = params.get("hjust", 0.5)
1155
+ vjust = params.get("vjust", 0.5)
1156
+ data = data.copy()
1157
+
1158
+ x_vals = data["x"].values.astype(float)
1159
+ y_vals = data["y"].values.astype(float)
1160
+ x_diff = np.diff(np.sort(np.unique(x_vals)))
1161
+ y_diff = np.diff(np.sort(np.unique(y_vals)))
1162
+ w = x_diff[0] if len(x_diff) > 0 else 1
1163
+ h = y_diff[0] if len(y_diff) > 0 else 1
1164
+
1165
+ data["xmin"] = data["x"] - w * (1 - hjust)
1166
+ data["xmax"] = data["x"] + w * hjust
1167
+ data["ymin"] = data["y"] - h * (1 - vjust)
1168
+ data["ymax"] = data["y"] + h * vjust
1169
+ return data
1170
+
1171
+ def draw_panel(
1172
+ self,
1173
+ data: pd.DataFrame,
1174
+ panel_params: Any,
1175
+ coord: Any,
1176
+ interpolate: bool = False,
1177
+ hjust: float = 0.5,
1178
+ vjust: float = 0.5,
1179
+ **params: Any,
1180
+ ) -> Any:
1181
+ """Draw raster tiles."""
1182
+ coords = _coord_transform(coord, data, panel_params)
1183
+
1184
+ x_rng = (coords["xmin"].min(), coords["xmax"].max())
1185
+ y_rng = (coords["ymin"].min(), coords["ymax"].max())
1186
+
1187
+ return raster_grob(
1188
+ image=_fill_alpha(
1189
+ coords["fill"].values if "fill" in coords.columns else "grey35",
1190
+ coords["alpha"].values if "alpha" in coords.columns else None,
1191
+ ),
1192
+ x=np.mean(x_rng),
1193
+ y=np.mean(y_rng),
1194
+ width=x_rng[1] - x_rng[0],
1195
+ height=y_rng[1] - y_rng[0],
1196
+ default_units="native",
1197
+ interpolate=interpolate,
1198
+ )
1199
+
1200
+
1201
+ # ===========================================================================
1202
+ # GeomBar / GeomCol
1203
+ # ===========================================================================
1204
+
1205
+ class GeomBar(GeomRect):
1206
+ """Bar geom -- rectangles with y anchored at zero."""
1207
+
1208
+ required_aes: Tuple[str, ...] = ("x", "y")
1209
+ non_missing_aes: Tuple[str, ...] = ("xmin", "xmax", "ymin", "ymax")
1210
+ # R (geom-bar.R:15):
1211
+ # default_aes = aes(!!!GeomRect$default_aes, width = 0.9)
1212
+ default_aes: Mapping = Mapping(
1213
+ colour=FromTheme("colour"),
1214
+ fill=FromTheme("fill", fallback=_mix_ink_paper(0.35)),
1215
+ linewidth=FromTheme("borderwidth"),
1216
+ linetype=FromTheme("bordertype"),
1217
+ alpha=None,
1218
+ width=0.9,
1219
+ )
1220
+ extra_params: Tuple[str, ...] = ("just", "na_rm", "orientation")
1221
+ rename_size: bool = False
1222
+
1223
+ def setup_params(self, data: pd.DataFrame, params: Dict[str, Any]) -> Dict[str, Any]:
1224
+ params["flipped_aes"] = params.get("flipped_aes", False)
1225
+ return params
1226
+
1227
+ def setup_data(self, data: pd.DataFrame, params: Dict[str, Any]) -> pd.DataFrame:
1228
+ data = data.copy()
1229
+ width = params.get("width") or (data["width"].values if "width" in data.columns else 0.9)
1230
+ just = params.get("just", 0.5)
1231
+
1232
+ if isinstance(width, (int, float)):
1233
+ data["width"] = width
1234
+ data["ymin"] = np.minimum(data["y"].values, 0)
1235
+ data["ymax"] = np.maximum(data["y"].values, 0)
1236
+ data["xmin"] = data["x"] - data["width"] * just
1237
+ data["xmax"] = data["x"] + data["width"] * (1 - just)
1238
+ return data
1239
+
1240
+
1241
+ class GeomCol(GeomBar):
1242
+ """Column geom -- alias for GeomBar."""
1243
+ pass
1244
+
1245
+
1246
+ # ===========================================================================
1247
+ # GeomText / GeomLabel
1248
+ # ===========================================================================
1249
+
1250
+ class GeomText(Geom):
1251
+ """Text geom."""
1252
+
1253
+ required_aes: Tuple[str, ...] = ("x", "y", "label")
1254
+ non_missing_aes: Tuple[str, ...] = ("angle",)
1255
+ default_aes: Mapping = Mapping(
1256
+ colour=FromTheme("colour", fallback="ink"),
1257
+ family="",
1258
+ size=3.88, # R GeomText$default_aes: literal 3.88 (mm), ≈ 11 pt when ×.pt
1259
+ angle=0,
1260
+ hjust=0.5,
1261
+ vjust=0.5,
1262
+ alpha=None,
1263
+ fontface=1,
1264
+ lineheight=1.2,
1265
+ )
1266
+ draw_key = draw_key_text
1267
+
1268
+ def draw_panel(
1269
+ self,
1270
+ data: pd.DataFrame,
1271
+ panel_params: Any,
1272
+ coord: Any,
1273
+ parse: bool = False,
1274
+ na_rm: bool = False,
1275
+ check_overlap: bool = False,
1276
+ size_unit: str = "mm",
1277
+ **params: Any,
1278
+ ) -> Any:
1279
+ """Draw text labels."""
1280
+ coords = _coord_transform(coord, data, panel_params)
1281
+
1282
+ size_mul = PT # default mm
1283
+ if size_unit == "pt":
1284
+ size_mul = 1
1285
+ elif size_unit == "cm":
1286
+ size_mul = PT * 10
1287
+ elif size_unit == "in":
1288
+ size_mul = 72.27
1289
+ elif size_unit == "pc":
1290
+ size_mul = 12
1291
+
1292
+ # R's textGrob handles vectorised parameters natively.
1293
+ # Our text_grob expects scalars, so we create one per row.
1294
+ children = []
1295
+ colours = scales_alpha(
1296
+ coords["colour"].values if "colour" in coords.columns else "black",
1297
+ coords["alpha"].values if "alpha" in coords.columns else None,
1298
+ )
1299
+ if isinstance(colours, str):
1300
+ colours = [colours] * len(coords)
1301
+
1302
+ for i in range(len(coords)):
1303
+ row = coords.iloc[i]
1304
+ col_i = colours[i] if i < len(colours) else "black"
1305
+ children.append(text_grob(
1306
+ label=str(row.get("label", "")),
1307
+ x=float(row["x"]),
1308
+ y=float(row["y"]),
1309
+ default_units="native",
1310
+ hjust=float(row.get("hjust", 0.5)),
1311
+ vjust=float(row.get("vjust", 0.5)),
1312
+ rot=float(row.get("angle", 0)),
1313
+ gp=Gpar(
1314
+ col=col_i,
1315
+ fontsize=float(row.get("size", 3.88)) * size_mul,
1316
+ ),
1317
+ name=f"text.{i}",
1318
+ ))
1319
+
1320
+ return _ggname("geom_text", grob_tree(*children))
1321
+
1322
+
1323
+ class GeomLabel(Geom):
1324
+ """Label geom -- text with background rectangle."""
1325
+
1326
+ required_aes: Tuple[str, ...] = ("x", "y", "label")
1327
+ default_aes: Mapping = Mapping(
1328
+ colour=FromTheme("colour", fallback="ink"),
1329
+ fill="white",
1330
+ family="",
1331
+ size=3.88, # R GeomLabel$default_aes: literal 3.88 (mm)
1332
+ angle=0,
1333
+ hjust=0.5,
1334
+ vjust=0.5,
1335
+ alpha=None,
1336
+ fontface=1,
1337
+ lineheight=1.2,
1338
+ linewidth=FromTheme("linewidth"),
1339
+ linetype=FromTheme("linetype"),
1340
+ )
1341
+ draw_key = draw_key_label
1342
+
1343
+ def draw_panel(
1344
+ self,
1345
+ data: pd.DataFrame,
1346
+ panel_params: Any,
1347
+ coord: Any,
1348
+ parse: bool = False,
1349
+ na_rm: bool = False,
1350
+ label_padding: Any = None,
1351
+ label_r: Any = None,
1352
+ size_unit: str = "mm",
1353
+ **params: Any,
1354
+ ) -> Any:
1355
+ """Draw labelled text."""
1356
+ coords = _coord_transform(coord, data, panel_params)
1357
+ size_mul = PT
1358
+
1359
+ grobs = []
1360
+ for i in range(len(coords)):
1361
+ row = coords.iloc[i]
1362
+ label = str(row["label"])
1363
+ x_val = row["x"]
1364
+ y_val = row["y"]
1365
+
1366
+ bg_grob = roundrect_grob(
1367
+ x=x_val,
1368
+ y=y_val,
1369
+ gp=Gpar(
1370
+ col=row.get("colour", "black"),
1371
+ fill=_fill_alpha(row.get("fill", "white"), row.get("alpha")),
1372
+ lwd=row.get("linewidth", 0.25) * PT,
1373
+ lty=row.get("linetype", 1),
1374
+ ),
1375
+ )
1376
+ txt_grob = text_grob(
1377
+ label=label,
1378
+ x=x_val,
1379
+ y=y_val,
1380
+ gp=Gpar(
1381
+ col=scales_alpha(row.get("colour", "black"), row.get("alpha")),
1382
+ fontsize=row.get("size", 3.88) * size_mul,
1383
+ fontfamily=row.get("family", ""),
1384
+ fontface=row.get("fontface", 1),
1385
+ ),
1386
+ )
1387
+ grobs.extend([bg_grob, txt_grob])
1388
+
1389
+ return _ggname("geom_label", grob_tree(*grobs) if grobs else null_grob())
1390
+
1391
+
1392
+ # ===========================================================================
1393
+ # GeomPolygon
1394
+ # ===========================================================================
1395
+
1396
+ class GeomPolygon(Geom):
1397
+ """Polygon geom."""
1398
+
1399
+ required_aes: Tuple[str, ...] = ("x", "y")
1400
+ # R (geom-polygon.R:73-80):
1401
+ # fill = from_theme(fill %||% col_mix(ink, paper, 0.2))
1402
+ default_aes: Mapping = Mapping(
1403
+ colour=FromTheme("colour"),
1404
+ fill=FromTheme("fill", fallback=_mix_ink_paper(0.2)),
1405
+ linewidth=FromTheme("linewidth"),
1406
+ linetype=FromTheme("linetype"),
1407
+ alpha=None,
1408
+ )
1409
+ draw_key = draw_key_polygon
1410
+ rename_size: bool = True
1411
+
1412
+ def handle_na(self, data: pd.DataFrame, params: Dict[str, Any]) -> pd.DataFrame:
1413
+ return data
1414
+
1415
+ def draw_panel(
1416
+ self,
1417
+ data: pd.DataFrame,
1418
+ panel_params: Any,
1419
+ coord: Any,
1420
+ rule: str = "evenodd",
1421
+ lineend: str = "butt",
1422
+ linejoin: str = "round",
1423
+ linemitre: float = 10,
1424
+ **params: Any,
1425
+ ) -> Any:
1426
+ """Draw filled polygons."""
1427
+ if len(data) <= 1:
1428
+ return null_grob()
1429
+
1430
+ coords = _coord_transform(coord, data, panel_params)
1431
+ # R does NOT sort by group here — group is only used as
1432
+ # polygon sub-id. Sorting would scramble vertex order.
1433
+ group_id = coords["group"].values if "group" in coords.columns else None
1434
+
1435
+ # Take first value per group for gpar
1436
+ return _ggname(
1437
+ "geom_polygon",
1438
+ polygon_grob(
1439
+ x=coords["x"].values,
1440
+ y=coords["y"].values,
1441
+ id=group_id,
1442
+ default_units="native",
1443
+ gp=Gpar(
1444
+ col=coords["colour"].iloc[0] if "colour" in coords.columns else None,
1445
+ fill=_fill_alpha(
1446
+ coords["fill"].iloc[0] if "fill" in coords.columns else "grey35",
1447
+ coords["alpha"].iloc[0] if "alpha" in coords.columns else None,
1448
+ ),
1449
+ lwd=(
1450
+ coords["linewidth"].iloc[0] * PT
1451
+ if "linewidth" in coords.columns
1452
+ else 0.5 * PT
1453
+ ),
1454
+ lty=coords["linetype"].iloc[0] if "linetype" in coords.columns else 1,
1455
+ lineend=lineend,
1456
+ linejoin=linejoin,
1457
+ linemitre=linemitre,
1458
+ ),
1459
+ ),
1460
+ )
1461
+
1462
+
1463
+ # ===========================================================================
1464
+ # GeomRibbon / GeomArea
1465
+ # ===========================================================================
1466
+
1467
+ class GeomRibbon(Geom):
1468
+ """Ribbon geom -- shaded region between ymin and ymax."""
1469
+
1470
+ required_aes: Tuple[str, ...] = ("x", "ymin", "ymax")
1471
+ # R (geom-ribbon.R:6-13):
1472
+ # fill = from_theme(fill %||% col_mix(ink, paper, 0.2))
1473
+ default_aes: Mapping = Mapping(
1474
+ colour=FromTheme("colour"),
1475
+ fill=FromTheme("fill", fallback=_mix_ink_paper(0.2)),
1476
+ linewidth=FromTheme("linewidth"),
1477
+ linetype=FromTheme("linetype"),
1478
+ alpha=None,
1479
+ )
1480
+ extra_params: Tuple[str, ...] = ("na_rm", "orientation")
1481
+ draw_key = draw_key_polygon
1482
+ rename_size: bool = True
1483
+
1484
+ def setup_params(self, data: pd.DataFrame, params: Dict[str, Any]) -> Dict[str, Any]:
1485
+ params["flipped_aes"] = params.get("flipped_aes", False)
1486
+ return params
1487
+
1488
+ def setup_data(self, data: pd.DataFrame, params: Dict[str, Any]) -> pd.DataFrame:
1489
+ data = data.copy()
1490
+ sort_cols = ["PANEL", "group", "x"]
1491
+ sort_cols = [c for c in sort_cols if c in data.columns]
1492
+ if sort_cols:
1493
+ data = data.sort_values(sort_cols)
1494
+ if "y" not in data.columns:
1495
+ data["y"] = data.get("ymin", data.get("ymax", 0))
1496
+ return data
1497
+
1498
+ def draw_group(
1499
+ self,
1500
+ data: pd.DataFrame,
1501
+ panel_params: Any,
1502
+ coord: Any,
1503
+ lineend: str = "butt",
1504
+ linejoin: str = "round",
1505
+ linemitre: float = 10,
1506
+ na_rm: bool = False,
1507
+ flipped_aes: bool = False,
1508
+ outline_type: str = "both",
1509
+ **params: Any,
1510
+ ) -> Any:
1511
+ """Draw ribbon."""
1512
+ data = data.copy()
1513
+
1514
+ # Build polygon from upper + reversed lower
1515
+ upper = pd.DataFrame({"x": data["x"].values, "y": data["ymax"].values})
1516
+ lower = pd.DataFrame({"x": data["x"].values[::-1], "y": data["ymin"].values[::-1]})
1517
+ poly_data = pd.concat([upper, lower], ignore_index=True)
1518
+
1519
+ # Copy aesthetics
1520
+ for col in ("colour", "fill", "linewidth", "linetype", "alpha"):
1521
+ if col in data.columns:
1522
+ poly_data[col] = data[col].iloc[0]
1523
+
1524
+ coords = _coord_transform(coord, poly_data, panel_params)
1525
+
1526
+ fill_val = data["fill"].iloc[0] if "fill" in data.columns else "grey35"
1527
+ alpha_val = data["alpha"].iloc[0] if "alpha" in data.columns else None
1528
+ colour_val = data["colour"].iloc[0] if "colour" in data.columns else None
1529
+ lwd = data["linewidth"].iloc[0] * PT if "linewidth" in data.columns else 0.5 * PT
1530
+ lty = data["linetype"].iloc[0] if "linetype" in data.columns else 1
1531
+
1532
+ g_poly = polygon_grob(
1533
+ x=coords["x"].values,
1534
+ y=coords["y"].values,
1535
+ default_units="native",
1536
+ gp=Gpar(
1537
+ fill=_fill_alpha(fill_val, alpha_val),
1538
+ col=colour_val if outline_type == "full" else None,
1539
+ lwd=lwd if outline_type == "full" else 0,
1540
+ lty=lty if outline_type == "full" else 1,
1541
+ lineend=lineend,
1542
+ linejoin=linejoin,
1543
+ ),
1544
+ )
1545
+
1546
+ if outline_type == "full":
1547
+ return _ggname("geom_ribbon", g_poly)
1548
+
1549
+ # R (geom-ribbon.R:187-198): polylineGrob with col=aes$colour.
1550
+ # When colour is NA (Python ``None``), R's gpar produces no
1551
+ # stroke — we must skip the outline grobs entirely, otherwise
1552
+ # Python's grid default fills in black (manifesting as the
1553
+ # double-line artifact on geom_smooth's confidence ribbon).
1554
+ if colour_val is None:
1555
+ return _ggname("geom_ribbon", g_poly)
1556
+
1557
+ # Draw outline lines
1558
+ upper_coords = _coord_transform(coord, pd.DataFrame({"x": data["x"].values, "y": data["ymax"].values}), panel_params)
1559
+ lower_coords = _coord_transform(coord, pd.DataFrame({"x": data["x"].values[::-1], "y": data["ymin"].values[::-1]}), panel_params)
1560
+
1561
+ line_gp = Gpar(col=colour_val, lwd=lwd, lty=lty, lineend=lineend, linejoin=linejoin)
1562
+
1563
+ line_grobs = []
1564
+ if outline_type in ("both", "upper"):
1565
+ line_grobs.append(
1566
+ lines_grob(x=upper_coords["x"].values, y=upper_coords["y"].values, default_units="native", gp=line_gp)
1567
+ )
1568
+ if outline_type in ("both", "lower"):
1569
+ line_grobs.append(
1570
+ lines_grob(x=lower_coords["x"].values, y=lower_coords["y"].values, default_units="native", gp=line_gp)
1571
+ )
1572
+
1573
+ return _ggname("geom_ribbon", grob_tree(g_poly, *line_grobs))
1574
+
1575
+
1576
+ class GeomArea(GeomRibbon):
1577
+ """Area geom -- ribbon anchored at y=0."""
1578
+
1579
+ required_aes: Tuple[str, ...] = ("x", "y")
1580
+
1581
+ def setup_params(self, data: pd.DataFrame, params: Dict[str, Any]) -> Dict[str, Any]:
1582
+ params["flipped_aes"] = params.get("flipped_aes", False)
1583
+ # R semantics: GeomArea uses outline.type = "upper" (not "both")
1584
+ params.setdefault("outline_type", "upper")
1585
+ return params
1586
+
1587
+ def setup_data(self, data: pd.DataFrame, params: Dict[str, Any]) -> pd.DataFrame:
1588
+ data = data.copy()
1589
+ sort_cols = [c for c in ["PANEL", "group", "x"] if c in data.columns]
1590
+ if sort_cols:
1591
+ data = data.sort_values(sort_cols)
1592
+ data["ymin"] = 0
1593
+ data["ymax"] = data["y"]
1594
+ return data
1595
+
1596
+
1597
+ # ===========================================================================
1598
+ # GeomSmooth
1599
+ # ===========================================================================
1600
+
1601
+ class GeomSmooth(Geom):
1602
+ """Smooth geom -- fitted line + optional confidence ribbon."""
1603
+
1604
+ required_aes: Tuple[str, ...] = ("x", "y")
1605
+ optional_aes: Tuple[str, ...] = ("ymin", "ymax")
1606
+ # R (geom-smooth.R:52-58):
1607
+ # colour = from_theme(colour %||% accent),
1608
+ # fill = from_theme(fill %||% col_mix(ink, paper, 0.6)),
1609
+ # linewidth = from_theme(2 * linewidth),
1610
+ # linetype = from_theme(linetype), weight = 1, alpha = 0.4
1611
+ default_aes: Mapping = Mapping(
1612
+ colour=FromTheme("colour", fallback="accent"),
1613
+ fill=FromTheme("fill", fallback=_mix_ink_paper(0.6)),
1614
+ linewidth=FromTheme(lambda g: 2.0 * g.linewidth),
1615
+ linetype=FromTheme("linetype"),
1616
+ weight=1,
1617
+ alpha=0.4,
1618
+ )
1619
+ extra_params: Tuple[str, ...] = ("na_rm", "orientation")
1620
+ draw_key = draw_key_smooth
1621
+ rename_size: bool = True
1622
+
1623
+ def setup_params(self, data: pd.DataFrame, params: Dict[str, Any]) -> Dict[str, Any]:
1624
+ params["flipped_aes"] = params.get("flipped_aes", False)
1625
+ if "se" not in params:
1626
+ params["se"] = all(c in data.columns for c in ("ymin", "ymax"))
1627
+ return params
1628
+
1629
+ def setup_data(self, data: pd.DataFrame, params: Dict[str, Any]) -> pd.DataFrame:
1630
+ return GeomLine.setup_data(GeomLine(), data, params)
1631
+
1632
+ def draw_group(
1633
+ self,
1634
+ data: pd.DataFrame,
1635
+ panel_params: Any,
1636
+ coord: Any,
1637
+ lineend: str = "butt",
1638
+ linejoin: str = "round",
1639
+ linemitre: float = 10,
1640
+ se: bool = False,
1641
+ flipped_aes: bool = False,
1642
+ **params: Any,
1643
+ ) -> Any:
1644
+ """Draw smooth line + optional ribbon."""
1645
+ ribbon_data = data.copy()
1646
+ if "colour" in ribbon_data.columns:
1647
+ ribbon_data["colour"] = None
1648
+
1649
+ path_data = data.copy()
1650
+ path_data["alpha"] = None
1651
+
1652
+ grobs = []
1653
+ has_ribbon = se and "ymin" in data.columns and "ymax" in data.columns
1654
+ if has_ribbon:
1655
+ grobs.append(
1656
+ GeomRibbon.draw_group(
1657
+ GeomRibbon(), ribbon_data, panel_params, coord,
1658
+ flipped_aes=flipped_aes,
1659
+ )
1660
+ )
1661
+ grobs.append(
1662
+ GeomLine.draw_panel(
1663
+ GeomLine(), path_data, panel_params, coord,
1664
+ lineend=lineend, linejoin=linejoin, linemitre=linemitre,
1665
+ )
1666
+ )
1667
+ return grob_tree(*grobs)
1668
+
1669
+
1670
+ # ===========================================================================
1671
+ # GeomSegment / GeomCurve / GeomSpoke
1672
+ # ===========================================================================
1673
+
1674
+ class GeomSegment(Geom):
1675
+ """Segment geom -- straight line between two points."""
1676
+
1677
+ required_aes: Tuple[str, ...] = ("x", "y", "xend", "yend")
1678
+ non_missing_aes: Tuple[str, ...] = ("linetype", "linewidth")
1679
+ default_aes: Mapping = Mapping(
1680
+ colour=FromTheme("colour", fallback="ink"),
1681
+ linewidth=FromTheme("linewidth"),
1682
+ linetype=FromTheme("linetype"),
1683
+ alpha=None,
1684
+ )
1685
+ draw_key = draw_key_path
1686
+ rename_size: bool = True
1687
+
1688
+ def draw_panel(
1689
+ self,
1690
+ data: pd.DataFrame,
1691
+ panel_params: Any,
1692
+ coord: Any,
1693
+ arrow: Any = None,
1694
+ lineend: str = "butt",
1695
+ linejoin: str = "round",
1696
+ na_rm: bool = False,
1697
+ **params: Any,
1698
+ ) -> Any:
1699
+ """Draw line segments."""
1700
+ data = data.copy()
1701
+ if "xend" not in data.columns:
1702
+ data["xend"] = data["x"]
1703
+ if "yend" not in data.columns:
1704
+ data["yend"] = data["y"]
1705
+
1706
+ coords = _coord_transform(coord, data, panel_params)
1707
+
1708
+ if coords.empty:
1709
+ return null_grob()
1710
+
1711
+ return segments_grob(
1712
+ x0=coords["x"].values,
1713
+ y0=coords["y"].values,
1714
+ x1=coords["xend"].values,
1715
+ y1=coords["yend"].values,
1716
+ default_units="native",
1717
+ gp=Gpar(
1718
+ col=scales_alpha(
1719
+ coords["colour"].values if "colour" in coords.columns else "black",
1720
+ coords["alpha"].values if "alpha" in coords.columns else None,
1721
+ ),
1722
+ lwd=(
1723
+ coords["linewidth"].values * PT
1724
+ if "linewidth" in coords.columns
1725
+ else 0.5 * PT
1726
+ ),
1727
+ lty=coords["linetype"].values if "linetype" in coords.columns else 1,
1728
+ lineend=lineend,
1729
+ linejoin=linejoin,
1730
+ ),
1731
+ arrow=arrow,
1732
+ )
1733
+
1734
+
1735
+ class GeomCurve(GeomSegment):
1736
+ """Curve geom -- curved line between two points."""
1737
+
1738
+ def draw_panel(
1739
+ self,
1740
+ data: pd.DataFrame,
1741
+ panel_params: Any,
1742
+ coord: Any,
1743
+ curvature: float = 0.5,
1744
+ angle: float = 90,
1745
+ ncp: int = 5,
1746
+ shape: float = 0.5,
1747
+ arrow: Any = None,
1748
+ lineend: str = "butt",
1749
+ na_rm: bool = False,
1750
+ **params: Any,
1751
+ ) -> Any:
1752
+ """Draw curved segments."""
1753
+ coords = _coord_transform(coord, data, panel_params)
1754
+
1755
+ if coords.empty:
1756
+ return null_grob()
1757
+
1758
+ return curve_grob(
1759
+ x1=coords["x"].values,
1760
+ y1=coords["y"].values,
1761
+ x2=coords["xend"].values if "xend" in coords.columns else coords["x"].values,
1762
+ y2=coords["yend"].values if "yend" in coords.columns else coords["y"].values,
1763
+ default_units="native",
1764
+ curvature=curvature,
1765
+ angle=angle,
1766
+ ncp=ncp,
1767
+ shape=shape,
1768
+ gp=Gpar(
1769
+ col=scales_alpha(
1770
+ coords["colour"].values if "colour" in coords.columns else "black",
1771
+ coords["alpha"].values if "alpha" in coords.columns else None,
1772
+ ),
1773
+ lwd=(
1774
+ coords["linewidth"].values * PT
1775
+ if "linewidth" in coords.columns
1776
+ else 0.5 * PT
1777
+ ),
1778
+ lty=coords["linetype"].values if "linetype" in coords.columns else 1,
1779
+ lineend=lineend,
1780
+ ),
1781
+ arrow=arrow,
1782
+ )
1783
+
1784
+
1785
+ class GeomSpoke(GeomSegment):
1786
+ """Spoke geom -- segment parameterised by location, angle, and radius."""
1787
+
1788
+ required_aes: Tuple[str, ...] = ("x", "y", "angle", "radius")
1789
+
1790
+ def setup_data(self, data: pd.DataFrame, params: Dict[str, Any]) -> pd.DataFrame:
1791
+ data = data.copy()
1792
+ if "radius" not in data.columns:
1793
+ data["radius"] = params.get("radius", 1)
1794
+ if "angle" not in data.columns:
1795
+ data["angle"] = params.get("angle", 0)
1796
+ data["xend"] = data["x"] + np.cos(data["angle"]) * data["radius"]
1797
+ data["yend"] = data["y"] + np.sin(data["angle"]) * data["radius"]
1798
+ return data
1799
+
1800
+
1801
+ # ===========================================================================
1802
+ # GeomErrorbar / GeomErrorbarh
1803
+ # ===========================================================================
1804
+
1805
+ class GeomErrorbar(Geom):
1806
+ """Errorbar geom -- T-shaped error bars."""
1807
+
1808
+ required_aes: Tuple[str, ...] = ("x", "ymin", "ymax")
1809
+ default_aes: Mapping = Mapping(
1810
+ colour=FromTheme("colour", fallback="ink"),
1811
+ linewidth=FromTheme("linewidth"),
1812
+ linetype=FromTheme("linetype"),
1813
+ width=0.9,
1814
+ alpha=None,
1815
+ )
1816
+ extra_params: Tuple[str, ...] = ("na_rm", "orientation")
1817
+ draw_key = draw_key_path
1818
+ rename_size: bool = True
1819
+
1820
+ def setup_params(self, data: pd.DataFrame, params: Dict[str, Any]) -> Dict[str, Any]:
1821
+ params["flipped_aes"] = params.get("flipped_aes", False)
1822
+ return params
1823
+
1824
+ def setup_data(self, data: pd.DataFrame, params: Dict[str, Any]) -> pd.DataFrame:
1825
+ data = data.copy()
1826
+ width = params.get("width") or (data["width"].values if "width" in data.columns else 0.9)
1827
+ if isinstance(width, (int, float)):
1828
+ data["width"] = width
1829
+ data["xmin"] = data["x"] - data["width"] / 2
1830
+ data["xmax"] = data["x"] + data["width"] / 2
1831
+ return data
1832
+
1833
+ def draw_panel(
1834
+ self,
1835
+ data: pd.DataFrame,
1836
+ panel_params: Any,
1837
+ coord: Any,
1838
+ lineend: str = "butt",
1839
+ width: Optional[float] = None,
1840
+ flipped_aes: bool = False,
1841
+ **params: Any,
1842
+ ) -> Any:
1843
+ """Draw error bars as T-shapes."""
1844
+ n = len(data)
1845
+ # Build the three segments per bar:
1846
+ # top cap, vertical, bottom cap
1847
+ x_vals = np.concatenate([
1848
+ np.column_stack([data["xmin"].values, data["xmax"].values, np.full(n, np.nan),
1849
+ data["x"].values, data["x"].values, np.full(n, np.nan),
1850
+ data["xmin"].values, data["xmax"].values]).ravel()
1851
+ ])
1852
+ y_vals = np.concatenate([
1853
+ np.column_stack([data["ymax"].values, data["ymax"].values, np.full(n, np.nan),
1854
+ data["ymax"].values, data["ymin"].values, np.full(n, np.nan),
1855
+ data["ymin"].values, data["ymin"].values]).ravel()
1856
+ ])
1857
+
1858
+ # Create a path-like data frame
1859
+ path_data = pd.DataFrame({
1860
+ "x": x_vals,
1861
+ "y": y_vals,
1862
+ "colour": np.repeat(data["colour"].values if "colour" in data.columns else "black", 8),
1863
+ "alpha": np.repeat(data["alpha"].values if "alpha" in data.columns else np.nan, 8),
1864
+ "linewidth": np.repeat(data["linewidth"].values if "linewidth" in data.columns else 0.5, 8),
1865
+ "linetype": np.repeat(data["linetype"].values if "linetype" in data.columns else 1, 8),
1866
+ "group": np.repeat(np.arange(n), 8),
1867
+ })
1868
+
1869
+ return GeomPath.draw_panel(GeomPath(), path_data, panel_params, coord, lineend=lineend)
1870
+
1871
+
1872
+ class GeomErrorbarh(GeomErrorbar):
1873
+ """Horizontal errorbar geom (deprecated -- use ``geom_errorbar(orientation='y')``)."""
1874
+
1875
+ def setup_params(self, data: pd.DataFrame, params: Dict[str, Any]) -> Dict[str, Any]:
1876
+ warnings.warn(
1877
+ "geom_errorbarh() is deprecated. Use geom_errorbar(orientation='y').",
1878
+ FutureWarning,
1879
+ stacklevel=2,
1880
+ )
1881
+ return super().setup_params(data, params)
1882
+
1883
+
1884
+ # ===========================================================================
1885
+ # GeomCrossbar
1886
+ # ===========================================================================
1887
+
1888
+ class GeomCrossbar(Geom):
1889
+ """Crossbar geom -- box with median line."""
1890
+
1891
+ required_aes: Tuple[str, ...] = ("x", "y", "ymin", "ymax")
1892
+ default_aes: Mapping = Mapping(
1893
+ colour=FromTheme("colour", fallback="ink"),
1894
+ fill=FromTheme("fill"),
1895
+ linewidth=FromTheme("linewidth"),
1896
+ linetype=FromTheme("linetype"),
1897
+ alpha=None,
1898
+ )
1899
+ extra_params: Tuple[str, ...] = ("na_rm", "orientation")
1900
+ draw_key = draw_key_crossbar
1901
+ rename_size: bool = True
1902
+
1903
+ def setup_params(self, data: pd.DataFrame, params: Dict[str, Any]) -> Dict[str, Any]:
1904
+ params.setdefault("fatten", 2.5)
1905
+ params["flipped_aes"] = params.get("flipped_aes", False)
1906
+ return params
1907
+
1908
+ def setup_data(self, data: pd.DataFrame, params: Dict[str, Any]) -> pd.DataFrame:
1909
+ return GeomErrorbar.setup_data(GeomErrorbar(), data, params)
1910
+
1911
+ def draw_panel(
1912
+ self,
1913
+ data: pd.DataFrame,
1914
+ panel_params: Any,
1915
+ coord: Any,
1916
+ lineend: str = "butt",
1917
+ linejoin: str = "mitre",
1918
+ fatten: float = 2.5,
1919
+ width: Optional[float] = None,
1920
+ flipped_aes: bool = False,
1921
+ middle_gp: Optional[Dict] = None,
1922
+ box_gp: Optional[Dict] = None,
1923
+ **params: Any,
1924
+ ) -> Any:
1925
+ """Draw crossbar."""
1926
+ # Build box polygon
1927
+ n = len(data)
1928
+ boxes = []
1929
+ middles = []
1930
+
1931
+ for i in range(n):
1932
+ row = data.iloc[i]
1933
+ xmin = row.get("xmin", row["x"] - 0.45)
1934
+ xmax = row.get("xmax", row["x"] + 0.45)
1935
+ ymin_val = row["ymin"]
1936
+ ymax_val = row["ymax"]
1937
+ y_mid = row["y"]
1938
+
1939
+ box_df = pd.DataFrame({
1940
+ "x": [xmin, xmin, xmax, xmax, xmin],
1941
+ "y": [ymax_val, ymin_val, ymin_val, ymax_val, ymax_val],
1942
+ "colour": row.get("colour", "black"),
1943
+ "fill": row.get("fill"),
1944
+ "linewidth": row.get("linewidth", 0.5),
1945
+ "linetype": row.get("linetype", 1),
1946
+ "alpha": row.get("alpha"),
1947
+ "group": i,
1948
+ })
1949
+ boxes.append(box_df)
1950
+
1951
+ mid_df = pd.DataFrame({
1952
+ "x": [xmin],
1953
+ "y": [y_mid],
1954
+ "xend": [xmax],
1955
+ "yend": [y_mid],
1956
+ "colour": row.get("colour", "black"),
1957
+ "linewidth": row.get("linewidth", 0.5) * fatten,
1958
+ "linetype": row.get("linetype", 1),
1959
+ "alpha": [np.nan],
1960
+ })
1961
+ middles.append(mid_df)
1962
+
1963
+ box_data = pd.concat(boxes, ignore_index=True)
1964
+ mid_data = pd.concat(middles, ignore_index=True)
1965
+
1966
+ box_grob = GeomPolygon.draw_panel(
1967
+ GeomPolygon(), box_data, panel_params, coord,
1968
+ lineend=lineend, linejoin=linejoin,
1969
+ )
1970
+ mid_grob = GeomSegment.draw_panel(
1971
+ GeomSegment(), mid_data, panel_params, coord, lineend=lineend,
1972
+ )
1973
+
1974
+ return _ggname("geom_crossbar", grob_tree(box_grob, mid_grob))
1975
+
1976
+
1977
+ # ===========================================================================
1978
+ # GeomLinerange / GeomPointrange
1979
+ # ===========================================================================
1980
+
1981
+ class GeomLinerange(Geom):
1982
+ """Linerange geom -- vertical line segments."""
1983
+
1984
+ required_aes: Tuple[str, ...] = ("x", "ymin", "ymax")
1985
+ default_aes: Mapping = Mapping(
1986
+ colour=FromTheme("colour", fallback="ink"),
1987
+ linewidth=FromTheme("linewidth"),
1988
+ linetype=FromTheme("linetype"),
1989
+ alpha=None,
1990
+ )
1991
+ extra_params: Tuple[str, ...] = ("na_rm", "orientation")
1992
+ draw_key = draw_key_linerange
1993
+ rename_size: bool = True
1994
+
1995
+ def setup_params(self, data: pd.DataFrame, params: Dict[str, Any]) -> Dict[str, Any]:
1996
+ params["flipped_aes"] = params.get("flipped_aes", False)
1997
+ return params
1998
+
1999
+ def setup_data(self, data: pd.DataFrame, params: Dict[str, Any]) -> pd.DataFrame:
2000
+ data = data.copy()
2001
+ data["flipped_aes"] = params.get("flipped_aes", False)
2002
+ return data
2003
+
2004
+ def draw_panel(
2005
+ self,
2006
+ data: pd.DataFrame,
2007
+ panel_params: Any,
2008
+ coord: Any,
2009
+ lineend: str = "butt",
2010
+ flipped_aes: bool = False,
2011
+ na_rm: bool = False,
2012
+ arrow: Any = None,
2013
+ **params: Any,
2014
+ ) -> Any:
2015
+ """Draw line ranges."""
2016
+ seg_data = data.copy()
2017
+ seg_data["xend"] = seg_data["x"]
2018
+ seg_data["yend"] = seg_data["ymax"]
2019
+ seg_data["y"] = seg_data["ymin"]
2020
+ grob = GeomSegment.draw_panel(
2021
+ GeomSegment(), seg_data, panel_params, coord,
2022
+ lineend=lineend, na_rm=na_rm, arrow=arrow,
2023
+ )
2024
+ return _ggname("geom_linerange", grob)
2025
+
2026
+
2027
+ class GeomPointrange(Geom):
2028
+ """Pointrange geom -- line range with point at y."""
2029
+
2030
+ required_aes: Tuple[str, ...] = ("x", "y", "ymin", "ymax")
2031
+ default_aes: Mapping = Mapping(
2032
+ colour=FromTheme("colour", fallback="ink"),
2033
+ size=0.5,
2034
+ linewidth=FromTheme("linewidth"),
2035
+ linetype=FromTheme("linetype"),
2036
+ shape=FromTheme("pointshape"),
2037
+ fill=FromTheme("fill"),
2038
+ alpha=None,
2039
+ stroke=FromTheme("borderwidth"),
2040
+ )
2041
+ extra_params: Tuple[str, ...] = ("na_rm", "orientation")
2042
+ draw_key = draw_key_pointrange
2043
+
2044
+ def setup_params(self, data: pd.DataFrame, params: Dict[str, Any]) -> Dict[str, Any]:
2045
+ params.setdefault("fatten", 4)
2046
+ return GeomLinerange.setup_params(GeomLinerange(), data, params)
2047
+
2048
+ def setup_data(self, data: pd.DataFrame, params: Dict[str, Any]) -> pd.DataFrame:
2049
+ return GeomLinerange.setup_data(GeomLinerange(), data, params)
2050
+
2051
+ def draw_panel(
2052
+ self,
2053
+ data: pd.DataFrame,
2054
+ panel_params: Any,
2055
+ coord: Any,
2056
+ lineend: str = "butt",
2057
+ fatten: float = 4,
2058
+ flipped_aes: bool = False,
2059
+ na_rm: bool = False,
2060
+ arrow: Any = None,
2061
+ **params: Any,
2062
+ ) -> Any:
2063
+ """Draw point + range."""
2064
+ line_grob = GeomLinerange.draw_panel(
2065
+ GeomLinerange(), data, panel_params, coord,
2066
+ lineend=lineend, flipped_aes=flipped_aes, na_rm=na_rm, arrow=arrow,
2067
+ )
2068
+ pt_data = data.copy()
2069
+ if "size" in pt_data.columns:
2070
+ pt_data["size"] = pt_data["size"] * fatten
2071
+ point_grob = GeomPoint.draw_panel(GeomPoint(), pt_data, panel_params, coord, na_rm=na_rm)
2072
+ return _ggname("geom_pointrange", grob_tree(line_grob, point_grob))
2073
+
2074
+
2075
+ # ===========================================================================
2076
+ # GeomBoxplot
2077
+ # ===========================================================================
2078
+
2079
+ class GeomBoxplot(Geom):
2080
+ """Boxplot geom."""
2081
+
2082
+ required_aes: Tuple[str, ...] = ("x", "lower", "upper", "middle", "ymin", "ymax")
2083
+ default_aes: Mapping = Mapping(
2084
+ weight=1,
2085
+ colour="grey20",
2086
+ fill="white",
2087
+ size=FromTheme("pointsize"),
2088
+ alpha=None,
2089
+ shape=FromTheme("pointshape"),
2090
+ linetype=FromTheme("linetype"),
2091
+ linewidth=FromTheme("linewidth"),
2092
+ width=0.9,
2093
+ )
2094
+ extra_params: Tuple[str, ...] = ("na_rm", "orientation", "outliers")
2095
+ draw_key = draw_key_boxplot
2096
+ rename_size: bool = True
2097
+
2098
+ def setup_params(self, data: pd.DataFrame, params: Dict[str, Any]) -> Dict[str, Any]:
2099
+ params.setdefault("fatten", 2)
2100
+ params["flipped_aes"] = params.get("flipped_aes", False)
2101
+ return params
2102
+
2103
+ def setup_data(self, data: pd.DataFrame, params: Dict[str, Any]) -> pd.DataFrame:
2104
+ """Prepare boxplot data: compute box width and outlier-inclusive ranges.
2105
+
2106
+ Mirrors R's ``GeomBoxplot$setup_data`` (geom-boxplot.R:257-286).
2107
+ Adds ``ymin_final``/``ymax_final`` columns that include outlier
2108
+ values, ensuring the y-scale is trained on the full data extent.
2109
+ """
2110
+ data = data.copy()
2111
+ width = params.get("width") or (data["width"].values if "width" in data.columns else 0.9)
2112
+ if isinstance(width, (int, float)):
2113
+ data["width"] = width
2114
+
2115
+ # Compute ymin_final / ymax_final from outliers
2116
+ # (R: geom-boxplot.R:266-274)
2117
+ if "outliers" in data.columns:
2118
+ ymin_final = []
2119
+ ymax_final = []
2120
+ for _, row in data.iterrows():
2121
+ outliers = row.get("outliers", [])
2122
+ if outliers is None or (isinstance(outliers, float) and np.isnan(outliers)):
2123
+ outliers = []
2124
+ if isinstance(outliers, np.ndarray):
2125
+ outliers = outliers.tolist()
2126
+ if len(outliers) > 0:
2127
+ ymin_final.append(min(min(outliers), row.get("ymin", np.inf)))
2128
+ ymax_final.append(max(max(outliers), row.get("ymax", -np.inf)))
2129
+ else:
2130
+ ymin_final.append(row.get("ymin", np.nan))
2131
+ ymax_final.append(row.get("ymax", np.nan))
2132
+ data["ymin_final"] = ymin_final
2133
+ data["ymax_final"] = ymax_final
2134
+
2135
+ data["xmin"] = data["x"] - data["width"] / 2
2136
+ data["xmax"] = data["x"] + data["width"] / 2
2137
+ return data
2138
+
2139
+ def draw_group(
2140
+ self,
2141
+ data: pd.DataFrame,
2142
+ panel_params: Any,
2143
+ coord: Any,
2144
+ lineend: str = "butt",
2145
+ linejoin: str = "mitre",
2146
+ fatten: float = 2,
2147
+ outlier_gp: Optional[Dict] = None,
2148
+ whisker_gp: Optional[Dict] = None,
2149
+ staple_gp: Optional[Dict] = None,
2150
+ median_gp: Optional[Dict] = None,
2151
+ box_gp: Optional[Dict] = None,
2152
+ notch: bool = False,
2153
+ notchwidth: float = 0.5,
2154
+ staplewidth: float = 0,
2155
+ varwidth: bool = False,
2156
+ flipped_aes: bool = False,
2157
+ **params: Any,
2158
+ ) -> Any:
2159
+ """Draw a single boxplot."""
2160
+ if outlier_gp is None:
2161
+ outlier_gp = {}
2162
+ if whisker_gp is None:
2163
+ whisker_gp = {}
2164
+
2165
+ row = data.iloc[0] if len(data) > 0 else data
2166
+
2167
+ # Whiskers
2168
+ whisker_data = pd.DataFrame({
2169
+ "x": [row["x"], row["x"]],
2170
+ "y": [row["upper"], row["lower"]],
2171
+ "xend": [row["x"], row["x"]],
2172
+ "yend": [row["ymax"], row["ymin"]],
2173
+ "colour": whisker_gp.get("colour", row.get("colour", "grey20")),
2174
+ "linewidth": whisker_gp.get("linewidth", row.get("linewidth", 0.5)),
2175
+ "linetype": whisker_gp.get("linetype", row.get("linetype", 1)),
2176
+ "alpha": [np.nan, np.nan],
2177
+ })
2178
+
2179
+ # Box (simple rectangle)
2180
+ xmin = row.get("xmin", row["x"] - 0.45)
2181
+ xmax = row.get("xmax", row["x"] + 0.45)
2182
+ box_data = pd.DataFrame({
2183
+ "x": [xmin, xmin, xmax, xmax, xmin],
2184
+ "y": [row["upper"], row["lower"], row["lower"], row["upper"], row["upper"]],
2185
+ "colour": row.get("colour", "grey20"),
2186
+ "fill": row.get("fill", "white"),
2187
+ "linewidth": row.get("linewidth", 0.5),
2188
+ "linetype": row.get("linetype", 1),
2189
+ "alpha": row.get("alpha"),
2190
+ "group": 1,
2191
+ })
2192
+
2193
+ # Median line
2194
+ median_data = pd.DataFrame({
2195
+ "x": [xmin],
2196
+ "y": [row["middle"]],
2197
+ "xend": [xmax],
2198
+ "yend": [row["middle"]],
2199
+ "colour": (median_gp or {}).get("colour", row.get("colour", "grey20")),
2200
+ "linewidth": (median_gp or {}).get("linewidth", row.get("linewidth", 0.5)) * fatten,
2201
+ "linetype": (median_gp or {}).get("linetype", row.get("linetype", 1)),
2202
+ "alpha": [np.nan],
2203
+ })
2204
+
2205
+ grobs = [
2206
+ GeomSegment.draw_panel(GeomSegment(), whisker_data, panel_params, coord, lineend=lineend),
2207
+ GeomPolygon.draw_panel(GeomPolygon(), box_data, panel_params, coord, lineend=lineend, linejoin=linejoin),
2208
+ GeomSegment.draw_panel(GeomSegment(), median_data, panel_params, coord, lineend=lineend),
2209
+ ]
2210
+
2211
+ # Outliers
2212
+ if "outliers" in data.columns and data["outliers"].iloc[0] is not None:
2213
+ outliers_list = data["outliers"].iloc[0]
2214
+ if hasattr(outliers_list, "__len__") and len(outliers_list) > 0:
2215
+ outlier_data = pd.DataFrame({
2216
+ "x": row["x"],
2217
+ "y": outliers_list,
2218
+ "colour": outlier_gp.get("colour", row.get("colour", "grey20")),
2219
+ "fill": None,
2220
+ "shape": outlier_gp.get("shape", 19),
2221
+ "size": outlier_gp.get("size", 1.5),
2222
+ "stroke": outlier_gp.get("stroke", 0.5),
2223
+ "alpha": outlier_gp.get("alpha", row.get("alpha")),
2224
+ })
2225
+ grobs.insert(0, GeomPoint.draw_panel(GeomPoint(), outlier_data, panel_params, coord))
2226
+
2227
+ return _ggname("geom_boxplot", grob_tree(*grobs))
2228
+
2229
+
2230
+ # ===========================================================================
2231
+ # GeomViolin
2232
+ # ===========================================================================
2233
+
2234
+ class GeomViolin(Geom):
2235
+ """Violin geom."""
2236
+
2237
+ required_aes: Tuple[str, ...] = ("x", "y")
2238
+ default_aes: Mapping = Mapping(
2239
+ weight=1,
2240
+ colour="grey20",
2241
+ fill="white",
2242
+ linewidth=FromTheme("linewidth"),
2243
+ linetype=FromTheme("linetype"),
2244
+ alpha=None,
2245
+ width=0.9,
2246
+ )
2247
+ extra_params: Tuple[str, ...] = ("na_rm", "orientation")
2248
+ draw_key = draw_key_polygon
2249
+ rename_size: bool = True
2250
+
2251
+ def setup_params(self, data: pd.DataFrame, params: Dict[str, Any]) -> Dict[str, Any]:
2252
+ params["flipped_aes"] = params.get("flipped_aes", False)
2253
+ return params
2254
+
2255
+ def setup_data(self, data: pd.DataFrame, params: Dict[str, Any]) -> pd.DataFrame:
2256
+ data = data.copy()
2257
+ width = params.get("width") or (data["width"].values if "width" in data.columns else 0.9)
2258
+ if isinstance(width, (int, float)):
2259
+ data["width"] = width
2260
+ if "group" in data.columns:
2261
+ for grp, idx in data.groupby("group").groups.items():
2262
+ data.loc[idx, "xmin"] = data.loc[idx, "x"] - data.loc[idx, "width"] / 2
2263
+ data.loc[idx, "xmax"] = data.loc[idx, "x"] + data.loc[idx, "width"] / 2
2264
+ return data
2265
+
2266
+ def draw_group(
2267
+ self,
2268
+ data: pd.DataFrame,
2269
+ panel_params: Any,
2270
+ coord: Any,
2271
+ quantile_gp: Optional[Dict] = None,
2272
+ flipped_aes: bool = False,
2273
+ **params: Any,
2274
+ ) -> Any:
2275
+ """Draw a single violin."""
2276
+ data = data.copy()
2277
+
2278
+ # R semantics: filter out quantile marker rows (they have
2279
+ # non-NaN 'quantile' values) — only the density curve rows
2280
+ # form the violin polygon shape.
2281
+ if "quantile" in data.columns:
2282
+ data = data[data["quantile"].isna()].copy()
2283
+
2284
+ if "violinwidth" in data.columns:
2285
+ data["xminv"] = data["x"] - data["violinwidth"] * (data["x"] - data.get("xmin", data["x"] - 0.45))
2286
+ data["xmaxv"] = data["x"] + data["violinwidth"] * (data.get("xmax", data["x"] + 0.45) - data["x"])
2287
+ else:
2288
+ data["xminv"] = data.get("xmin", data["x"] - 0.45)
2289
+ data["xmaxv"] = data.get("xmax", data["x"] + 0.45)
2290
+
2291
+ # Build polygon: left side (sorted y ascending) + right side (descending)
2292
+ sorted_data = data.sort_values("y")
2293
+ upper = pd.DataFrame({"y": sorted_data["y"].values, "x": sorted_data["xminv"].values})
2294
+ lower = pd.DataFrame({"y": sorted_data["y"].values[::-1], "x": sorted_data["xmaxv"].values[::-1]})
2295
+
2296
+ newdata = pd.concat([upper, lower], ignore_index=True)
2297
+ newdata = pd.concat([newdata, newdata.iloc[:1]], ignore_index=True)
2298
+
2299
+ for col in ("colour", "fill", "linewidth", "linetype", "alpha"):
2300
+ if col in data.columns:
2301
+ newdata[col] = data[col].iloc[0]
2302
+ newdata["group"] = 1
2303
+
2304
+ return _ggname(
2305
+ "geom_violin",
2306
+ GeomPolygon.draw_panel(GeomPolygon(), newdata, panel_params, coord),
2307
+ )
2308
+
2309
+
2310
+ # ===========================================================================
2311
+ # GeomDotplot
2312
+ # ===========================================================================
2313
+
2314
+ class GeomDotplot(Geom):
2315
+ """Dotplot geom."""
2316
+
2317
+ required_aes: Tuple[str, ...] = ("x", "y")
2318
+ non_missing_aes: Tuple[str, ...] = ("size", "shape")
2319
+ default_aes: Mapping = Mapping(
2320
+ colour=FromTheme("colour", fallback="ink"),
2321
+ fill="black",
2322
+ alpha=None,
2323
+ stroke=1.0,
2324
+ linetype=FromTheme("linetype"),
2325
+ weight=1,
2326
+ width=0.9,
2327
+ )
2328
+ draw_key = draw_key_dotplot
2329
+
2330
+ def draw_group(
2331
+ self,
2332
+ data: pd.DataFrame,
2333
+ panel_params: Any,
2334
+ coord: Any,
2335
+ lineend: str = "butt",
2336
+ na_rm: bool = False,
2337
+ binaxis: str = "x",
2338
+ stackdir: str = "up",
2339
+ stackratio: float = 1,
2340
+ dotsize: float = 1,
2341
+ stackgroups: bool = False,
2342
+ **params: Any,
2343
+ ) -> Any:
2344
+ """Draw dotplot."""
2345
+ coords = _coord_transform(coord, data, panel_params)
2346
+ return _ggname(
2347
+ "geom_dotplot",
2348
+ points_grob(
2349
+ x=coords["x"].values,
2350
+ y=coords["y"].values,
2351
+ pch=21,
2352
+ gp=Gpar(
2353
+ col=scales_alpha(
2354
+ coords["colour"].values if "colour" in coords.columns else "black",
2355
+ coords["alpha"].values if "alpha" in coords.columns else None,
2356
+ ),
2357
+ fill=_fill_alpha(
2358
+ coords["fill"].values if "fill" in coords.columns else "black",
2359
+ coords["alpha"].values if "alpha" in coords.columns else None,
2360
+ ),
2361
+ ),
2362
+ ),
2363
+ )
2364
+
2365
+
2366
+ # ===========================================================================
2367
+ # GeomDensity
2368
+ # ===========================================================================
2369
+
2370
+ class GeomDensity(GeomArea):
2371
+ """Density geom -- smoothed histogram."""
2372
+
2373
+ default_aes: Mapping = Mapping(
2374
+ colour=FromTheme("colour", fallback="ink"),
2375
+ fill=FromTheme("fill"),
2376
+ weight=1,
2377
+ alpha=None,
2378
+ linewidth=FromTheme("linewidth"),
2379
+ linetype=FromTheme("linetype"),
2380
+ )
2381
+
2382
+
2383
+ # ===========================================================================
2384
+ # GeomHistogram / GeomFreqpoly
2385
+ # ===========================================================================
2386
+
2387
+ # GeomHistogram is just GeomBar with stat="bin"
2388
+ GeomHistogram = GeomBar # alias
2389
+
2390
+ # GeomFreqpoly is just GeomPath with stat="bin"
2391
+ GeomFreqpoly = GeomPath # alias
2392
+
2393
+
2394
+ # ===========================================================================
2395
+ # GeomAbline / GeomHline / GeomVline
2396
+ # ===========================================================================
2397
+
2398
+ class GeomAbline(Geom):
2399
+ """Abline geom -- diagonal reference line."""
2400
+
2401
+ required_aes: Tuple[str, ...] = ("slope", "intercept")
2402
+ default_aes: Mapping = Mapping(
2403
+ colour=FromTheme("colour", fallback="ink"),
2404
+ linewidth=FromTheme("linewidth"),
2405
+ linetype=FromTheme("linetype"),
2406
+ alpha=None,
2407
+ )
2408
+ draw_key = draw_key_abline
2409
+ rename_size: bool = True
2410
+
2411
+ def draw_panel(
2412
+ self,
2413
+ data: pd.DataFrame,
2414
+ panel_params: Any,
2415
+ coord: Any,
2416
+ lineend: str = "butt",
2417
+ **params: Any,
2418
+ ) -> Any:
2419
+ """Draw diagonal lines."""
2420
+ # Get x-range from panel_params
2421
+ if hasattr(panel_params, "x") and hasattr(panel_params.x, "range"):
2422
+ x_rng = panel_params.x.range
2423
+ elif isinstance(panel_params, dict) and "x_range" in panel_params:
2424
+ x_rng = panel_params["x_range"]
2425
+ else:
2426
+ x_rng = (0, 1)
2427
+
2428
+ seg_data = data.copy()
2429
+ seg_data["x"] = x_rng[0]
2430
+ seg_data["xend"] = x_rng[1]
2431
+ seg_data["y"] = seg_data["slope"] * x_rng[0] + seg_data["intercept"]
2432
+ seg_data["yend"] = seg_data["slope"] * x_rng[1] + seg_data["intercept"]
2433
+
2434
+ return GeomSegment.draw_panel(GeomSegment(), seg_data, panel_params, coord, lineend=lineend)
2435
+
2436
+
2437
+ class GeomHline(Geom):
2438
+ """Horizontal line geom."""
2439
+
2440
+ required_aes: Tuple[str, ...] = ("yintercept",)
2441
+ default_aes: Mapping = Mapping(
2442
+ colour=FromTheme("colour", fallback="ink"),
2443
+ linewidth=FromTheme("linewidth"),
2444
+ linetype=FromTheme("linetype"),
2445
+ alpha=None,
2446
+ )
2447
+ draw_key = draw_key_path
2448
+ rename_size: bool = True
2449
+
2450
+ def draw_panel(
2451
+ self,
2452
+ data: pd.DataFrame,
2453
+ panel_params: Any,
2454
+ coord: Any,
2455
+ lineend: str = "butt",
2456
+ **params: Any,
2457
+ ) -> Any:
2458
+ """Draw horizontal lines."""
2459
+ x_rng = (0, 1)
2460
+ if hasattr(panel_params, "x") and hasattr(panel_params.x, "range"):
2461
+ x_rng = panel_params.x.range
2462
+ elif isinstance(panel_params, dict) and "x_range" in panel_params:
2463
+ x_rng = panel_params["x_range"]
2464
+
2465
+ seg_data = data.copy()
2466
+ seg_data["x"] = x_rng[0]
2467
+ seg_data["xend"] = x_rng[1]
2468
+ seg_data["y"] = seg_data["yintercept"]
2469
+ seg_data["yend"] = seg_data["yintercept"]
2470
+
2471
+ return GeomSegment.draw_panel(GeomSegment(), seg_data, panel_params, coord, lineend=lineend)
2472
+
2473
+
2474
+ class GeomVline(Geom):
2475
+ """Vertical line geom."""
2476
+
2477
+ required_aes: Tuple[str, ...] = ("xintercept",)
2478
+ default_aes: Mapping = Mapping(
2479
+ colour=FromTheme("colour", fallback="ink"),
2480
+ linewidth=FromTheme("linewidth"),
2481
+ linetype=FromTheme("linetype"),
2482
+ alpha=None,
2483
+ )
2484
+ draw_key = draw_key_vline
2485
+ rename_size: bool = True
2486
+
2487
+ def draw_panel(
2488
+ self,
2489
+ data: pd.DataFrame,
2490
+ panel_params: Any,
2491
+ coord: Any,
2492
+ lineend: str = "butt",
2493
+ **params: Any,
2494
+ ) -> Any:
2495
+ """Draw vertical lines."""
2496
+ y_rng = (0, 1)
2497
+ if hasattr(panel_params, "y") and hasattr(panel_params.y, "range"):
2498
+ y_rng = panel_params.y.range
2499
+ elif isinstance(panel_params, dict) and "y_range" in panel_params:
2500
+ y_rng = panel_params["y_range"]
2501
+
2502
+ seg_data = data.copy()
2503
+ seg_data["x"] = seg_data["xintercept"]
2504
+ seg_data["xend"] = seg_data["xintercept"]
2505
+ seg_data["y"] = y_rng[0]
2506
+ seg_data["yend"] = y_rng[1]
2507
+
2508
+ return GeomSegment.draw_panel(GeomSegment(), seg_data, panel_params, coord, lineend=lineend)
2509
+
2510
+
2511
+ # ===========================================================================
2512
+ # GeomRug
2513
+ # ===========================================================================
2514
+
2515
+ class GeomRug(Geom):
2516
+ """Rug geom -- marginal tick marks."""
2517
+
2518
+ optional_aes: Tuple[str, ...] = ("x", "y")
2519
+ default_aes: Mapping = Mapping(
2520
+ colour=FromTheme("colour", fallback="ink"),
2521
+ linewidth=FromTheme("linewidth"),
2522
+ linetype=FromTheme("linetype"),
2523
+ alpha=None,
2524
+ )
2525
+ draw_key = draw_key_path
2526
+ rename_size: bool = True
2527
+
2528
+ def setup_params(self, data: pd.DataFrame, params: Dict[str, Any]) -> Dict[str, Any]:
2529
+ params.setdefault("sides", "bl")
2530
+ return params
2531
+
2532
+ def draw_panel(
2533
+ self,
2534
+ data: pd.DataFrame,
2535
+ panel_params: Any,
2536
+ coord: Any,
2537
+ lineend: str = "butt",
2538
+ sides: str = "bl",
2539
+ outside: bool = False,
2540
+ length: Any = None,
2541
+ **params: Any,
2542
+ ) -> Any:
2543
+ """Draw rug marks."""
2544
+ coords = _coord_transform(coord, data, panel_params)
2545
+
2546
+ gp = Gpar(
2547
+ col=scales_alpha(
2548
+ coords["colour"].values if "colour" in coords.columns else "black",
2549
+ coords["alpha"].values if "alpha" in coords.columns else None,
2550
+ ),
2551
+ lty=coords["linetype"].values if "linetype" in coords.columns else 1,
2552
+ lwd=coords["linewidth"].values * PT if "linewidth" in coords.columns else 0.5 * PT,
2553
+ lineend=lineend,
2554
+ )
2555
+
2556
+ rug_len = 0.03 # fraction of npc
2557
+ grobs = []
2558
+
2559
+ if "x" in coords.columns:
2560
+ x_vals = coords["x"].values
2561
+ if "b" in sides:
2562
+ grobs.append(
2563
+ segments_grob(
2564
+ x0=x_vals, y0=np.zeros_like(x_vals),
2565
+ x1=x_vals, y1=np.full_like(x_vals, rug_len),
2566
+ default_units="native", gp=gp,
2567
+ )
2568
+ )
2569
+ if "t" in sides:
2570
+ grobs.append(
2571
+ segments_grob(
2572
+ x0=x_vals, y0=np.ones_like(x_vals),
2573
+ x1=x_vals, y1=np.full_like(x_vals, 1 - rug_len),
2574
+ default_units="native", gp=gp,
2575
+ )
2576
+ )
2577
+
2578
+ if "y" in coords.columns:
2579
+ y_vals = coords["y"].values
2580
+ if "l" in sides:
2581
+ grobs.append(
2582
+ segments_grob(
2583
+ x0=np.zeros_like(y_vals), y0=y_vals,
2584
+ x1=np.full_like(y_vals, rug_len), y1=y_vals,
2585
+ default_units="native", gp=gp,
2586
+ )
2587
+ )
2588
+ if "r" in sides:
2589
+ grobs.append(
2590
+ segments_grob(
2591
+ x0=np.ones_like(y_vals), y0=y_vals,
2592
+ x1=np.full_like(y_vals, 1 - rug_len), y1=y_vals,
2593
+ default_units="native", gp=gp,
2594
+ )
2595
+ )
2596
+
2597
+ return grob_tree(*grobs) if grobs else null_grob()
2598
+
2599
+
2600
+ # ===========================================================================
2601
+ # GeomBlank
2602
+ # ===========================================================================
2603
+
2604
+ class GeomBlank(Geom):
2605
+ """Blank geom -- draws nothing."""
2606
+
2607
+ default_aes: Mapping = Mapping()
2608
+
2609
+ def handle_na(self, data: pd.DataFrame, params: Dict[str, Any]) -> pd.DataFrame:
2610
+ return data
2611
+
2612
+ def draw_panel(self, data: pd.DataFrame = None, panel_params: Any = None,
2613
+ coord: Any = None, **params: Any) -> Any:
2614
+ return null_grob()
2615
+
2616
+
2617
+ # ===========================================================================
2618
+ # GeomContour / GeomContourFilled
2619
+ # ===========================================================================
2620
+
2621
+ class GeomContour(GeomPath):
2622
+ """Contour geom -- contour lines of a 3D surface."""
2623
+
2624
+ default_aes: Mapping = Mapping(
2625
+ weight=1,
2626
+ colour="blue",
2627
+ linewidth=FromTheme("linewidth"),
2628
+ linetype=FromTheme("linetype"),
2629
+ alpha=None,
2630
+ )
2631
+
2632
+
2633
+ class GeomContourFilled(GeomPolygon):
2634
+ """Filled contour geom."""
2635
+ pass
2636
+
2637
+
2638
+ # ===========================================================================
2639
+ # GeomDensity2d / GeomDensity2dFilled
2640
+ # ===========================================================================
2641
+
2642
+ class GeomDensity2d(GeomPath):
2643
+ """2D density contour lines."""
2644
+
2645
+ default_aes: Mapping = Mapping(
2646
+ colour="blue",
2647
+ linewidth=FromTheme("linewidth"),
2648
+ linetype=FromTheme("linetype"),
2649
+ alpha=None,
2650
+ )
2651
+
2652
+
2653
+ class GeomDensity2dFilled(GeomPolygon):
2654
+ """Filled 2D density contours."""
2655
+ pass
2656
+
2657
+
2658
+ # ===========================================================================
2659
+ # GeomHex
2660
+ # ===========================================================================
2661
+
2662
+ class GeomHex(Geom):
2663
+ """Hexagonal bin geom."""
2664
+
2665
+ required_aes: Tuple[str, ...] = ("x", "y")
2666
+ default_aes: Mapping = Mapping(
2667
+ colour=FromTheme("colour"),
2668
+ fill="grey50",
2669
+ linewidth=FromTheme("linewidth"),
2670
+ linetype=FromTheme("linetype"),
2671
+ alpha=None,
2672
+ )
2673
+ draw_key = draw_key_polygon
2674
+ rename_size: bool = True
2675
+
2676
+ def draw_group(
2677
+ self,
2678
+ data: pd.DataFrame,
2679
+ panel_params: Any,
2680
+ coord: Any,
2681
+ lineend: str = "butt",
2682
+ linejoin: str = "mitre",
2683
+ linemitre: float = 10,
2684
+ **params: Any,
2685
+ ) -> Any:
2686
+ """Draw hexagons."""
2687
+ if data.empty:
2688
+ return null_grob()
2689
+
2690
+ # R semantics: stat_bin_hex maps fill=after_stat(count).
2691
+ # Apply count→fill mapping when fill is uniform (default).
2692
+ if "count" in data.columns and "fill" in data.columns:
2693
+ fills = data["fill"].values
2694
+ if len(set(str(f) for f in fills)) <= 1:
2695
+ # Map count to a blue gradient (matching R's default)
2696
+ counts = data["count"].values.astype(float)
2697
+ mn, mx = counts.min(), counts.max()
2698
+ if mx > mn:
2699
+ t = (counts - mn) / (mx - mn)
2700
+ else:
2701
+ t = np.full_like(counts, 0.5)
2702
+ # Viridis-like: dark blue → yellow
2703
+ from matplotlib.cm import viridis
2704
+ data = data.copy()
2705
+ data["fill"] = [
2706
+ f"#{int(c[0]*255):02x}{int(c[1]*255):02x}{int(c[2]*255):02x}"
2707
+ for c in viridis(t)
2708
+ ]
2709
+
2710
+ # R semantics (geom-hex.R:14-29): GeomHex builds hex vertices in
2711
+ # data coords using the stat's binwidth, then transforms to NPC.
2712
+ n = len(data)
2713
+
2714
+ # Use width/height from stat output (R: data$width, data$height).
2715
+ # Fall back to resolution-based estimate if not available.
2716
+ if "width" in data.columns:
2717
+ dx = float(data["width"].iloc[0]) / 2
2718
+ else:
2719
+ dx = resolution(data["x"].values, zero=False)
2720
+ if "height" in data.columns:
2721
+ dy = float(data["height"].iloc[0]) / np.sqrt(3) / 2
2722
+ else:
2723
+ dy = resolution(data["y"].values, zero=False) / np.sqrt(3) / 2 * 1.15
2724
+
2725
+ # R: hexbin::hexcoords(dx, dy) returns
2726
+ # x = c( dx, dx, 0, -dx, -dx, 0)
2727
+ # y = c( dy, -dy, -2dy, -dy, dy, 2dy)
2728
+ # (no ``/2`` divisor). With the divisor the hexagons render
2729
+ # at exactly HALF the intended size, leaving visible gaps
2730
+ # between neighbours — the user-reported "hex not aligned".
2731
+ hex_x = dx * np.array([1.0, 1.0, 0.0, -1.0, -1.0, 0.0])
2732
+ hex_y = dy * np.array([1.0, -1.0, -2.0, -1.0, 1.0, 2.0])
2733
+
2734
+ all_x = np.repeat(data["x"].values, 6) + np.tile(hex_x, n)
2735
+ all_y = np.repeat(data["y"].values, 6) + np.tile(hex_y, n)
2736
+
2737
+ hex_data = pd.DataFrame({"x": all_x, "y": all_y})
2738
+ hex_data["group"] = np.repeat(np.arange(n), 6)
2739
+
2740
+ for col in ("colour", "fill", "linewidth", "linetype", "alpha"):
2741
+ if col in data.columns:
2742
+ hex_data[col] = np.repeat(data[col].values, 6)
2743
+
2744
+ coords = _coord_transform(coord, hex_data, panel_params)
2745
+
2746
+ return _ggname(
2747
+ "geom_hex",
2748
+ polygon_grob(
2749
+ x=coords["x"].values,
2750
+ y=coords["y"].values,
2751
+ id=coords["group"].values,
2752
+ default_units="native",
2753
+ gp=Gpar(
2754
+ col=data["colour"].values if "colour" in data.columns else None,
2755
+ fill=_fill_alpha(
2756
+ data["fill"].values if "fill" in data.columns else "grey50",
2757
+ data["alpha"].values if "alpha" in data.columns else None,
2758
+ ),
2759
+ lwd=data["linewidth"].values * PT if "linewidth" in data.columns else 0.5 * PT,
2760
+ lty=data["linetype"].values if "linetype" in data.columns else 1,
2761
+ ),
2762
+ ),
2763
+ )
2764
+
2765
+
2766
+ # ===========================================================================
2767
+ # GeomBin2d
2768
+ # ===========================================================================
2769
+
2770
+ class GeomBin2d(GeomTile):
2771
+ """2D bin heatmap geom."""
2772
+ pass
2773
+
2774
+
2775
+ # ===========================================================================
2776
+ # GeomFunction
2777
+ # ===========================================================================
2778
+
2779
+ class GeomFunction(GeomPath):
2780
+ """Function geom -- draw a mathematical function as a path."""
2781
+
2782
+ def draw_panel(
2783
+ self,
2784
+ data: pd.DataFrame,
2785
+ panel_params: Any,
2786
+ coord: Any,
2787
+ arrow: Any = None,
2788
+ lineend: str = "butt",
2789
+ linejoin: str = "round",
2790
+ linemitre: float = 10,
2791
+ na_rm: bool = False,
2792
+ **params: Any,
2793
+ ) -> Any:
2794
+ return GeomPath.draw_panel(
2795
+ self, data, panel_params, coord,
2796
+ arrow=arrow, lineend=lineend, linejoin=linejoin,
2797
+ linemitre=linemitre, na_rm=na_rm,
2798
+ )
2799
+
2800
+
2801
+ # ===========================================================================
2802
+ # GeomMap
2803
+ # ===========================================================================
2804
+
2805
+ class GeomMap(GeomPolygon):
2806
+ """Map polygon geom."""
2807
+
2808
+ required_aes: Tuple[str, ...] = ("map_id",)
2809
+
2810
+ def draw_panel(
2811
+ self,
2812
+ data: pd.DataFrame,
2813
+ panel_params: Any,
2814
+ coord: Any,
2815
+ lineend: str = "butt",
2816
+ linejoin: str = "round",
2817
+ linemitre: float = 10,
2818
+ map: Optional[pd.DataFrame] = None,
2819
+ **params: Any,
2820
+ ) -> Any:
2821
+ """Draw map polygons."""
2822
+ if map is None:
2823
+ return null_grob()
2824
+
2825
+ map_df = map.copy()
2826
+ if "lat" in map_df.columns:
2827
+ map_df["y"] = map_df["lat"]
2828
+ if "long" in map_df.columns:
2829
+ map_df["x"] = map_df["long"]
2830
+ if "region" in map_df.columns:
2831
+ map_df["id"] = map_df["region"]
2832
+
2833
+ # Merge aesthetics
2834
+ common = set(data["map_id"]) & set(map_df["id"])
2835
+ map_df = map_df[map_df["id"].isin(common)]
2836
+ data_subset = data[data["map_id"].isin(common)]
2837
+
2838
+ if map_df.empty:
2839
+ return null_grob()
2840
+
2841
+ # Assign aesthetics from data to map
2842
+ for col in ("colour", "fill", "linewidth", "linetype", "alpha"):
2843
+ if col in data_subset.columns:
2844
+ id_to_val = dict(zip(data_subset["map_id"], data_subset[col]))
2845
+ map_df[col] = map_df["id"].map(id_to_val)
2846
+
2847
+ map_df["group"] = map_df["id"]
2848
+
2849
+ return GeomPolygon.draw_panel(
2850
+ GeomPolygon(), map_df, panel_params, coord,
2851
+ lineend=lineend, linejoin=linejoin, linemitre=linemitre,
2852
+ )
2853
+
2854
+
2855
+ # ===========================================================================
2856
+ # GeomQuantile
2857
+ # ===========================================================================
2858
+
2859
+ class GeomQuantile(GeomPath):
2860
+ """Quantile regression lines."""
2861
+
2862
+ default_aes: Mapping = Mapping(
2863
+ weight=1,
2864
+ colour="blue",
2865
+ linewidth=FromTheme("linewidth"),
2866
+ linetype=FromTheme("linetype"),
2867
+ alpha=None,
2868
+ )
2869
+
2870
+
2871
+ # ===========================================================================
2872
+ # GeomSf and related
2873
+ # ===========================================================================
2874
+
2875
+ # ---------------------------------------------------------------------------
2876
+ # sf geometry type mapping (mirrors R's sf_types vector)
2877
+ # ---------------------------------------------------------------------------
2878
+
2879
+ _SF_TYPES: Dict[str, str] = {
2880
+ "Point": "point", "MultiPoint": "point",
2881
+ "LineString": "line", "MultiLineString": "line",
2882
+ "CircularString": "line", "CompoundCurve": "line",
2883
+ "MultiCurve": "line", "Curve": "line",
2884
+ "Polygon": "other", "MultiPolygon": "other",
2885
+ "CurvePolygon": "other", "MultiSurface": "other",
2886
+ "Surface": "other", "PolyhedralSurface": "other",
2887
+ "TIN": "other", "Triangle": "other",
2888
+ "GeometryCollection": "collection",
2889
+ "Geometry": "other",
2890
+ }
2891
+
2892
+ # R's .pt and .stroke constants
2893
+ _PT = 72.27 / 25.4 # ≈ 2.845
2894
+ _STROKE = 96 / 25.4 # ≈ 3.78
2895
+
2896
+
2897
+ def _sf_geometry_to_grobs(
2898
+ geometry_series: Any,
2899
+ colour: Any,
2900
+ fill: Any,
2901
+ linewidth: Any,
2902
+ linetype: Any,
2903
+ point_size: Any,
2904
+ pch: Any,
2905
+ lineend: str = "butt",
2906
+ linejoin: str = "round",
2907
+ ) -> Any:
2908
+ """Convert a Series of shapely geometries to grid_py grobs.
2909
+
2910
+ This reimplements R's ``sf::st_as_grob()`` using shapely + grid_py.
2911
+ Each geometry is rendered as the appropriate grob type:
2912
+ - Point/MultiPoint → points_grob
2913
+ - LineString/MultiLineString → polyline_grob / lines_grob
2914
+ - Polygon/MultiPolygon → polygon_grob / path_grob
2915
+
2916
+ Returns a GTree containing all grobs.
2917
+ """
2918
+ from shapely.geometry import (
2919
+ Point as ShapelyPoint,
2920
+ MultiPoint as ShapelyMultiPoint,
2921
+ LineString as ShapelyLineString,
2922
+ MultiLineString as ShapelyMultiLineString,
2923
+ Polygon as ShapelyPolygon,
2924
+ MultiPolygon as ShapelyMultiPolygon,
2925
+ GeometryCollection as ShapelyGeometryCollection,
2926
+ )
2927
+
2928
+ children = []
2929
+
2930
+ for i, geom in enumerate(geometry_series):
2931
+ if geom is None or geom.is_empty:
2932
+ continue
2933
+
2934
+ # Per-row graphical parameters
2935
+ col_i = colour[i] if hasattr(colour, "__getitem__") and len(colour) > i else colour
2936
+ fill_i = fill[i] if hasattr(fill, "__getitem__") and len(fill) > i else fill
2937
+ lwd_i = linewidth[i] if hasattr(linewidth, "__getitem__") and len(linewidth) > i else linewidth
2938
+ lty_i = linetype[i] if hasattr(linetype, "__getitem__") and len(linetype) > i else linetype
2939
+ sz_i = point_size[i] if hasattr(point_size, "__getitem__") and len(point_size) > i else point_size
2940
+ pch_i = pch[i] if hasattr(pch, "__getitem__") and len(pch) > i else pch
2941
+
2942
+ gp = Gpar(
2943
+ col=col_i, fill=fill_i, lwd=lwd_i, lty=lty_i,
2944
+ lineend=lineend, linejoin=linejoin,
2945
+ )
2946
+
2947
+ if isinstance(geom, (ShapelyPoint,)):
2948
+ x, y = geom.x, geom.y
2949
+ children.append(points_grob(
2950
+ x=[x], y=[y], pch=int(pch_i) if pch_i is not None else 19,
2951
+ size=Unit(float(sz_i) if sz_i is not None else 1, "char"),
2952
+ gp=gp, name=f"sf_point_{i}",
2953
+ ))
2954
+
2955
+ elif isinstance(geom, (ShapelyMultiPoint,)):
2956
+ xs = [p.x for p in geom.geoms]
2957
+ ys = [p.y for p in geom.geoms]
2958
+ children.append(points_grob(
2959
+ x=xs, y=ys, pch=int(pch_i) if pch_i is not None else 19,
2960
+ size=Unit(float(sz_i) if sz_i is not None else 1, "char"),
2961
+ gp=gp, name=f"sf_mpoint_{i}",
2962
+ ))
2963
+
2964
+ elif isinstance(geom, (ShapelyLineString,)):
2965
+ xs, ys = zip(*geom.coords) if len(geom.coords) > 0 else ([], [])
2966
+ children.append(lines_grob(
2967
+ x=list(xs), y=list(ys), gp=gp, name=f"sf_line_{i}",
2968
+ ))
2969
+
2970
+ elif isinstance(geom, (ShapelyMultiLineString,)):
2971
+ for j, line in enumerate(geom.geoms):
2972
+ xs, ys = zip(*line.coords) if len(line.coords) > 0 else ([], [])
2973
+ children.append(lines_grob(
2974
+ x=list(xs), y=list(ys), gp=gp,
2975
+ name=f"sf_mline_{i}_{j}",
2976
+ ))
2977
+
2978
+ elif isinstance(geom, (ShapelyPolygon,)):
2979
+ # Exterior ring
2980
+ xs, ys = geom.exterior.coords.xy
2981
+ children.append(polygon_grob(
2982
+ x=list(xs), y=list(ys), gp=gp, name=f"sf_poly_{i}",
2983
+ ))
2984
+
2985
+ elif isinstance(geom, (ShapelyMultiPolygon,)):
2986
+ for j, poly in enumerate(geom.geoms):
2987
+ xs, ys = poly.exterior.coords.xy
2988
+ children.append(polygon_grob(
2989
+ x=list(xs), y=list(ys), gp=gp,
2990
+ name=f"sf_mpoly_{i}_{j}",
2991
+ ))
2992
+
2993
+ elif isinstance(geom, (ShapelyGeometryCollection,)):
2994
+ # Recurse into collection
2995
+ sub_grobs = _sf_geometry_to_grobs(
2996
+ list(geom.geoms),
2997
+ colour=[col_i] * len(geom.geoms),
2998
+ fill=[fill_i] * len(geom.geoms),
2999
+ linewidth=[lwd_i] * len(geom.geoms),
3000
+ linetype=[lty_i] * len(geom.geoms),
3001
+ point_size=[sz_i] * len(geom.geoms),
3002
+ pch=[pch_i] * len(geom.geoms),
3003
+ lineend=lineend, linejoin=linejoin,
3004
+ )
3005
+ children.append(sub_grobs)
3006
+
3007
+ if not children:
3008
+ return null_grob()
3009
+
3010
+ return grob_tree(*children, name="sf_geometries")
3011
+
3012
+
3013
+ class GeomSf(Geom):
3014
+ """Simple features geom.
3015
+
3016
+ Draws different geometric objects depending on the geometry type:
3017
+ points, lines, or polygons — mirroring R's ``geom_sf()``.
3018
+ """
3019
+
3020
+ required_aes: Tuple[str, ...] = ("geometry",)
3021
+ default_aes: Mapping = Mapping(
3022
+ shape=None,
3023
+ colour=FromTheme("colour"),
3024
+ fill=FromTheme("fill"),
3025
+ size=None,
3026
+ linewidth=None,
3027
+ linetype=None,
3028
+ alpha=None,
3029
+ stroke=FromTheme("borderwidth"),
3030
+ )
3031
+
3032
+ def draw_panel(
3033
+ self,
3034
+ data: pd.DataFrame,
3035
+ panel_params: Any,
3036
+ coord: Any,
3037
+ legend: Any = None,
3038
+ lineend: str = "butt",
3039
+ linejoin: str = "round",
3040
+ linemitre: float = 10,
3041
+ arrow: Any = None,
3042
+ na_rm: bool = True,
3043
+ **params: Any,
3044
+ ) -> Any:
3045
+ """Draw sf geometries.
3046
+
3047
+ Mirrors R's ``GeomSf$draw_panel``: classifies each geometry
3048
+ as point/line/other, computes per-type graphical parameters,
3049
+ and renders via ``_sf_geometry_to_grobs``.
3050
+ """
3051
+ coords = _coord_transform(coord, data, panel_params)
3052
+
3053
+ if "geometry" not in coords.columns:
3054
+ return null_grob()
3055
+
3056
+ import shapely
3057
+
3058
+ n = len(coords)
3059
+
3060
+ # Classify geometry types (mirrors R's sf_types vector)
3061
+ types = coords["geometry"].apply(
3062
+ lambda g: _SF_TYPES.get(g.geom_type, "other") if g is not None else "other"
3063
+ )
3064
+ is_point = types == "point"
3065
+ is_line = types == "line"
3066
+ is_collection = types == "collection"
3067
+
3068
+ # Shape translation
3069
+ shape = coords.get("shape", pd.Series([19] * n))
3070
+ shape = shape.apply(
3071
+ lambda s: translate_shape_string(s) if isinstance(s, str) else (s if s is not None else 19)
3072
+ )
3073
+
3074
+ # Fill with alpha (mirrors R: fill_alpha for all, arrow.fill for lines)
3075
+ fill_raw = coords.get("fill", pd.Series([np.nan] * n))
3076
+ alpha_raw = coords.get("alpha", pd.Series([1.0] * n)).fillna(1.0)
3077
+ fill_vals = _fill_alpha(fill_raw, alpha_raw)
3078
+
3079
+ # Colour with alpha for points and lines
3080
+ colour = coords.get("colour", pd.Series(["black"] * n))
3081
+
3082
+ # Point size vs linewidth (R: point_size for points/collections,
3083
+ # linewidth for everything else)
3084
+ size_raw = coords.get("size", pd.Series([1.5] * n)).fillna(1.5)
3085
+ lw_raw = coords.get("linewidth", pd.Series([0.5] * n)).fillna(0.5)
3086
+ point_size = size_raw.copy()
3087
+ point_size[~(is_point | is_collection)] = lw_raw[~(is_point | is_collection)]
3088
+
3089
+ # Stroke
3090
+ stroke_raw = coords.get("stroke", pd.Series([0.5] * n)).fillna(0.5)
3091
+ stroke_vals = stroke_raw * _STROKE / 2
3092
+ font_size = point_size * _PT + stroke_vals
3093
+
3094
+ # Linewidth
3095
+ linewidth = lw_raw * _PT
3096
+ linewidth[is_point] = stroke_vals[is_point]
3097
+
3098
+ linetype = coords.get("linetype", pd.Series([1] * n))
3099
+
3100
+ return _sf_geometry_to_grobs(
3101
+ coords["geometry"],
3102
+ colour=colour.values,
3103
+ fill=fill_vals if hasattr(fill_vals, '__len__') else [fill_vals] * n,
3104
+ linewidth=linewidth.values,
3105
+ linetype=linetype.values,
3106
+ point_size=font_size.values,
3107
+ pch=shape.values,
3108
+ lineend=lineend,
3109
+ linejoin=linejoin,
3110
+ )
3111
+
3112
+ def draw_key(self, data: Any, params: Dict[str, Any], size: Any = None) -> Any:
3113
+ legend_type = params.get("legend", "other")
3114
+ if legend_type == "point":
3115
+ return draw_key_point(data, params, size)
3116
+ elif legend_type == "line":
3117
+ return draw_key_path(data, params, size)
3118
+ return draw_key_polygon(data, params, size)
3119
+
3120
+
3121
+ # Placeholder classes for annotation geoms
3122
+ class GeomAnnotationMap(GeomPolygon):
3123
+ """Annotation map geom."""
3124
+ pass
3125
+
3126
+
3127
+ class GeomCustomAnn(Geom):
3128
+ """Custom annotation geom."""
3129
+
3130
+ def draw_panel(self, data: pd.DataFrame = None, panel_params: Any = None,
3131
+ coord: Any = None, grob: Any = None, **params: Any) -> Any:
3132
+ return grob if grob is not None else null_grob()
3133
+
3134
+
3135
+ class GeomRasterAnn(Geom):
3136
+ """Raster annotation geom."""
3137
+
3138
+ def draw_panel(self, data: pd.DataFrame = None, panel_params: Any = None,
3139
+ coord: Any = None, raster: Any = None, **params: Any) -> Any:
3140
+ if raster is not None:
3141
+ return raster_grob(image=raster)
3142
+ return null_grob()
3143
+
3144
+
3145
+ def _calc_logticks(
3146
+ base: float = 10,
3147
+ minpow: int = 0,
3148
+ maxpow: int = 1,
3149
+ start: float = 0.0,
3150
+ shortend: float = 0.1,
3151
+ midend: float = 0.2,
3152
+ longend: float = 0.3,
3153
+ ) -> pd.DataFrame:
3154
+ """Compute log tick mark positions and lengths.
3155
+
3156
+ Mirrors R's ``calc_logticks()`` from ``annotation-logticks.R``.
3157
+
3158
+ Returns
3159
+ -------
3160
+ pd.DataFrame
3161
+ Columns: ``value``, ``start``, ``end``.
3162
+ """
3163
+ ticks_per_base = int(base) - 1
3164
+ reps = maxpow - minpow
3165
+
3166
+ if reps <= 0 or ticks_per_base <= 0:
3167
+ return pd.DataFrame({"value": [base ** maxpow], "start": [start], "end": [longend]})
3168
+
3169
+ ticknums = np.tile(np.linspace(1, base - 1, ticks_per_base), reps)
3170
+ powers = np.repeat(np.arange(minpow, maxpow), ticks_per_base)
3171
+ ticks = ticknums * (base ** powers)
3172
+ ticks = np.append(ticks, base ** maxpow)
3173
+
3174
+ tickend = np.full(len(ticks), shortend)
3175
+ cycle_idx = (ticknums - 1).astype(int)
3176
+ cycle_idx = np.append(cycle_idx, 0)
3177
+
3178
+ # Major ticks (at each power of base)
3179
+ tickend[cycle_idx == 0] = longend
3180
+
3181
+ # Mid ticks (at base/2, e.g. 5 for base 10)
3182
+ longtick_after = ticks_per_base // 2
3183
+ tickend[cycle_idx == longtick_after] = midend
3184
+
3185
+ return pd.DataFrame({
3186
+ "value": ticks,
3187
+ "start": np.full(len(ticks), start),
3188
+ "end": tickend,
3189
+ })
3190
+
3191
+
3192
+ class GeomLogticks(Geom):
3193
+ """Log-scale tick marks geom.
3194
+
3195
+ Mirrors R's ``GeomLogticks`` from ``annotation-logticks.R``.
3196
+ Draws diminishing tick marks at log-spaced intervals on specified
3197
+ sides of the plot panel.
3198
+ """
3199
+
3200
+ default_aes: Mapping = Mapping(
3201
+ colour=FromTheme("colour", fallback="ink"),
3202
+ linewidth=FromTheme("linewidth"),
3203
+ linetype=FromTheme("linetype"),
3204
+ alpha=1.0,
3205
+ )
3206
+
3207
+ def draw_panel(
3208
+ self,
3209
+ data: pd.DataFrame = None,
3210
+ panel_params: Any = None,
3211
+ coord: Any = None,
3212
+ base: float = 10,
3213
+ sides: str = "bl",
3214
+ outside: bool = False,
3215
+ scaled: bool = True,
3216
+ short: float = 0.1,
3217
+ mid: float = 0.2,
3218
+ long: float = 0.3,
3219
+ **params: Any,
3220
+ ) -> Any:
3221
+ """Draw log tick marks on panel edges.
3222
+
3223
+ Mirrors R's ``GeomLogticks$draw_panel``.
3224
+ """
3225
+ if panel_params is None:
3226
+ return null_grob()
3227
+
3228
+ x_range = panel_params.get("x_range") or panel_params.get("x.range")
3229
+ y_range = panel_params.get("y_range") or panel_params.get("y.range")
3230
+
3231
+ # Extract gp from data row
3232
+ colour = "black"
3233
+ linewidth_val = 0.5
3234
+ linetype_val = 1
3235
+ alpha_val = 1.0
3236
+ if data is not None and len(data) > 0:
3237
+ row = data.iloc[0]
3238
+ colour = row.get("colour", "black")
3239
+ linewidth_val = float(row.get("linewidth", 0.5))
3240
+ linetype_val = row.get("linetype", 1)
3241
+ alpha_val = float(row.get("alpha", 1.0) or 1.0)
3242
+
3243
+ gp = Gpar(col=colour, lwd=linewidth_val, lty=linetype_val, alpha=alpha_val)
3244
+
3245
+ ticks_grobs = []
3246
+
3247
+ # X-axis ticks (bottom / top)
3248
+ if ("b" in sides or "t" in sides) and x_range is not None:
3249
+ xr = [float(x_range[0]), float(x_range[1])]
3250
+ if all(np.isfinite(xr)):
3251
+ xticks = _calc_logticks(
3252
+ base=base,
3253
+ minpow=int(np.floor(xr[0])),
3254
+ maxpow=int(np.ceil(xr[1])),
3255
+ start=0.0, shortend=short, midend=mid, longend=long,
3256
+ )
3257
+ if scaled:
3258
+ xticks["value"] = np.log(xticks["value"]) / np.log(base)
3259
+
3260
+ # Rescale to [0, 1] NPC
3261
+ span = xr[1] - xr[0]
3262
+ if span > 0:
3263
+ xticks["x"] = (xticks["value"] - xr[0]) / span
3264
+ xticks = xticks[(xticks["x"] >= 0) & (xticks["x"] <= 1)]
3265
+
3266
+ if outside:
3267
+ xticks["end"] = -xticks["end"]
3268
+
3269
+ if "b" in sides and len(xticks) > 0:
3270
+ ticks_grobs.append(segments_grob(
3271
+ x0=xticks["x"].values, y0=np.zeros(len(xticks)),
3272
+ x1=xticks["x"].values, y1=xticks["end"].values * 0.02,
3273
+ gp=gp, name="logtick_x_b",
3274
+ ))
3275
+
3276
+ if "t" in sides and len(xticks) > 0:
3277
+ ticks_grobs.append(segments_grob(
3278
+ x0=xticks["x"].values, y0=np.ones(len(xticks)),
3279
+ x1=xticks["x"].values, y1=1.0 - xticks["end"].values * 0.02,
3280
+ gp=gp, name="logtick_x_t",
3281
+ ))
3282
+
3283
+ # Y-axis ticks (left / right)
3284
+ if ("l" in sides or "r" in sides) and y_range is not None:
3285
+ yr = [float(y_range[0]), float(y_range[1])]
3286
+ if all(np.isfinite(yr)):
3287
+ yticks = _calc_logticks(
3288
+ base=base,
3289
+ minpow=int(np.floor(yr[0])),
3290
+ maxpow=int(np.ceil(yr[1])),
3291
+ start=0.0, shortend=short, midend=mid, longend=long,
3292
+ )
3293
+ if scaled:
3294
+ yticks["value"] = np.log(yticks["value"]) / np.log(base)
3295
+
3296
+ span = yr[1] - yr[0]
3297
+ if span > 0:
3298
+ yticks["y"] = (yticks["value"] - yr[0]) / span
3299
+ yticks = yticks[(yticks["y"] >= 0) & (yticks["y"] <= 1)]
3300
+
3301
+ if outside:
3302
+ yticks["end"] = -yticks["end"]
3303
+
3304
+ if "l" in sides and len(yticks) > 0:
3305
+ ticks_grobs.append(segments_grob(
3306
+ x0=np.zeros(len(yticks)),
3307
+ y0=yticks["y"].values,
3308
+ x1=yticks["end"].values * 0.02,
3309
+ y1=yticks["y"].values,
3310
+ gp=gp, name="logtick_y_l",
3311
+ ))
3312
+
3313
+ if "r" in sides and len(yticks) > 0:
3314
+ ticks_grobs.append(segments_grob(
3315
+ x0=np.ones(len(yticks)),
3316
+ y0=yticks["y"].values,
3317
+ x1=1.0 - yticks["end"].values * 0.02,
3318
+ y1=yticks["y"].values,
3319
+ gp=gp, name="logtick_y_r",
3320
+ ))
3321
+
3322
+ if not ticks_grobs:
3323
+ return null_grob()
3324
+
3325
+ return grob_tree(*ticks_grobs, name="logticks")
3326
+
3327
+
3328
+ # ===========================================================================
3329
+ # GeomCount / GeomJitter (trivial wrappers)
3330
+ # ===========================================================================
3331
+
3332
+ # GeomCount is GeomPoint + stat_sum
3333
+ GeomCount = GeomPoint # alias
3334
+
3335
+ # GeomJitter is GeomPoint + position_jitter
3336
+ GeomJitter = GeomPoint # alias
3337
+
3338
+
3339
+ # ===========================================================================
3340
+ # Constructor functions
3341
+ # ===========================================================================
3342
+
3343
+ def _layer_import():
3344
+ """Lazy import of ``layer`` to avoid circular imports."""
3345
+ from ggplot2_py.layer import layer
3346
+ return layer
3347
+
3348
+
3349
+ # ---------------------------------------------------------------------------
3350
+ # Point / Path / Line / Step
3351
+ # ---------------------------------------------------------------------------
3352
+
3353
+ def geom_point(
3354
+ mapping: Optional[Mapping] = None,
3355
+ data: Any = None,
3356
+ stat: str = "identity",
3357
+ position: str = "identity",
3358
+ na_rm: bool = False,
3359
+ show_legend: Any = None,
3360
+ inherit_aes: bool = True,
3361
+ **kwargs: Any,
3362
+ ) -> Any:
3363
+ """Create a point (scatter) layer.
3364
+
3365
+ Parameters
3366
+ ----------
3367
+ mapping : Mapping, optional
3368
+ data : DataFrame, optional
3369
+ stat, position : str
3370
+ na_rm : bool
3371
+ show_legend : bool or None
3372
+ inherit_aes : bool
3373
+ **kwargs : additional aesthetic or parameter overrides
3374
+
3375
+ Returns
3376
+ -------
3377
+ Layer
3378
+ """
3379
+ layer = _layer_import()
3380
+ return layer(
3381
+ geom=GeomPoint, stat=stat, data=data, mapping=mapping,
3382
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3383
+ params={"na_rm": na_rm, **kwargs},
3384
+ )
3385
+
3386
+
3387
+ def geom_path(
3388
+ mapping: Optional[Mapping] = None,
3389
+ data: Any = None,
3390
+ stat: str = "identity",
3391
+ position: str = "identity",
3392
+ na_rm: bool = False,
3393
+ show_legend: Any = None,
3394
+ inherit_aes: bool = True,
3395
+ **kwargs: Any,
3396
+ ) -> Any:
3397
+ """Create a path layer (connects observations in data order)."""
3398
+ layer = _layer_import()
3399
+ return layer(
3400
+ geom=GeomPath, stat=stat, data=data, mapping=mapping,
3401
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3402
+ params={"na_rm": na_rm, **kwargs},
3403
+ )
3404
+
3405
+
3406
+ def geom_line(
3407
+ mapping: Optional[Mapping] = None,
3408
+ data: Any = None,
3409
+ stat: str = "identity",
3410
+ position: str = "identity",
3411
+ na_rm: bool = False,
3412
+ show_legend: Any = None,
3413
+ inherit_aes: bool = True,
3414
+ orientation: Any = None,
3415
+ **kwargs: Any,
3416
+ ) -> Any:
3417
+ """Create a line layer (connects observations sorted by x)."""
3418
+ layer = _layer_import()
3419
+ return layer(
3420
+ geom=GeomLine, stat=stat, data=data, mapping=mapping,
3421
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3422
+ params={"na_rm": na_rm, "orientation": orientation, **kwargs},
3423
+ )
3424
+
3425
+
3426
+ def geom_step(
3427
+ mapping: Optional[Mapping] = None,
3428
+ data: Any = None,
3429
+ stat: str = "identity",
3430
+ position: str = "identity",
3431
+ na_rm: bool = False,
3432
+ show_legend: Any = None,
3433
+ inherit_aes: bool = True,
3434
+ direction: str = "hv",
3435
+ orientation: Any = None,
3436
+ **kwargs: Any,
3437
+ ) -> Any:
3438
+ """Create a stairstep layer."""
3439
+ layer = _layer_import()
3440
+ return layer(
3441
+ geom=GeomStep, stat=stat, data=data, mapping=mapping,
3442
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3443
+ params={"na_rm": na_rm, "direction": direction, "orientation": orientation, **kwargs},
3444
+ )
3445
+
3446
+
3447
+ # ---------------------------------------------------------------------------
3448
+ # Bar / Col
3449
+ # ---------------------------------------------------------------------------
3450
+
3451
+ def geom_bar(
3452
+ mapping: Optional[Mapping] = None,
3453
+ data: Any = None,
3454
+ stat: str = "count",
3455
+ position: str = "stack",
3456
+ na_rm: bool = False,
3457
+ show_legend: Any = None,
3458
+ inherit_aes: bool = True,
3459
+ just: float = 0.5,
3460
+ orientation: Any = None,
3461
+ **kwargs: Any,
3462
+ ) -> Any:
3463
+ """Create a bar layer."""
3464
+ layer = _layer_import()
3465
+ return layer(
3466
+ geom=GeomBar, stat=stat, data=data, mapping=mapping,
3467
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3468
+ params={"na_rm": na_rm, "just": just, "orientation": orientation, **kwargs},
3469
+ )
3470
+
3471
+
3472
+ def geom_col(
3473
+ mapping: Optional[Mapping] = None,
3474
+ data: Any = None,
3475
+ stat: str = "identity",
3476
+ position: str = "stack",
3477
+ na_rm: bool = False,
3478
+ show_legend: Any = None,
3479
+ inherit_aes: bool = True,
3480
+ just: float = 0.5,
3481
+ **kwargs: Any,
3482
+ ) -> Any:
3483
+ """Create a column layer (bars with stat = identity)."""
3484
+ layer = _layer_import()
3485
+ return layer(
3486
+ geom=GeomCol, stat=stat, data=data, mapping=mapping,
3487
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3488
+ params={"na_rm": na_rm, "just": just, **kwargs},
3489
+ )
3490
+
3491
+
3492
+ # ---------------------------------------------------------------------------
3493
+ # Rect / Tile / Raster
3494
+ # ---------------------------------------------------------------------------
3495
+
3496
+ def geom_rect(
3497
+ mapping: Optional[Mapping] = None,
3498
+ data: Any = None,
3499
+ stat: str = "identity",
3500
+ position: str = "identity",
3501
+ na_rm: bool = False,
3502
+ show_legend: Any = None,
3503
+ inherit_aes: bool = True,
3504
+ **kwargs: Any,
3505
+ ) -> Any:
3506
+ """Create a rectangle layer."""
3507
+ layer = _layer_import()
3508
+ return layer(
3509
+ geom=GeomRect, stat=stat, data=data, mapping=mapping,
3510
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3511
+ params={"na_rm": na_rm, **kwargs},
3512
+ )
3513
+
3514
+
3515
+ def geom_tile(
3516
+ mapping: Optional[Mapping] = None,
3517
+ data: Any = None,
3518
+ stat: str = "identity",
3519
+ position: str = "identity",
3520
+ na_rm: bool = False,
3521
+ show_legend: Any = None,
3522
+ inherit_aes: bool = True,
3523
+ **kwargs: Any,
3524
+ ) -> Any:
3525
+ """Create a tile layer."""
3526
+ layer = _layer_import()
3527
+ return layer(
3528
+ geom=GeomTile, stat=stat, data=data, mapping=mapping,
3529
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3530
+ params={"na_rm": na_rm, **kwargs},
3531
+ )
3532
+
3533
+
3534
+ def geom_raster(
3535
+ mapping: Optional[Mapping] = None,
3536
+ data: Any = None,
3537
+ stat: str = "identity",
3538
+ position: str = "identity",
3539
+ na_rm: bool = False,
3540
+ show_legend: Any = None,
3541
+ inherit_aes: bool = True,
3542
+ hjust: float = 0.5,
3543
+ vjust: float = 0.5,
3544
+ interpolate: bool = False,
3545
+ **kwargs: Any,
3546
+ ) -> Any:
3547
+ """Create a raster layer."""
3548
+ layer = _layer_import()
3549
+ return layer(
3550
+ geom=GeomRaster, stat=stat, data=data, mapping=mapping,
3551
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3552
+ params={"na_rm": na_rm, "hjust": hjust, "vjust": vjust, "interpolate": interpolate, **kwargs},
3553
+ )
3554
+
3555
+
3556
+ # ---------------------------------------------------------------------------
3557
+ # Text / Label
3558
+ # ---------------------------------------------------------------------------
3559
+
3560
+ def geom_text(
3561
+ mapping: Optional[Mapping] = None,
3562
+ data: Any = None,
3563
+ stat: str = "identity",
3564
+ position: str = "nudge",
3565
+ na_rm: bool = False,
3566
+ show_legend: Any = None,
3567
+ inherit_aes: bool = True,
3568
+ parse: bool = False,
3569
+ check_overlap: bool = False,
3570
+ size_unit: str = "mm",
3571
+ **kwargs: Any,
3572
+ ) -> Any:
3573
+ """Create a text layer."""
3574
+ layer = _layer_import()
3575
+ return layer(
3576
+ geom=GeomText, stat=stat, data=data, mapping=mapping,
3577
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3578
+ params={"na_rm": na_rm, "parse": parse, "check_overlap": check_overlap, "size_unit": size_unit, **kwargs},
3579
+ )
3580
+
3581
+
3582
+ def geom_label(
3583
+ mapping: Optional[Mapping] = None,
3584
+ data: Any = None,
3585
+ stat: str = "identity",
3586
+ position: str = "nudge",
3587
+ na_rm: bool = False,
3588
+ show_legend: Any = None,
3589
+ inherit_aes: bool = True,
3590
+ parse: bool = False,
3591
+ size_unit: str = "mm",
3592
+ **kwargs: Any,
3593
+ ) -> Any:
3594
+ """Create a label layer (text with background box)."""
3595
+ layer = _layer_import()
3596
+ return layer(
3597
+ geom=GeomLabel, stat=stat, data=data, mapping=mapping,
3598
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3599
+ params={"na_rm": na_rm, "parse": parse, "size_unit": size_unit, **kwargs},
3600
+ )
3601
+
3602
+
3603
+ # ---------------------------------------------------------------------------
3604
+ # Boxplot / Violin / Dotplot
3605
+ # ---------------------------------------------------------------------------
3606
+
3607
+ def geom_boxplot(
3608
+ mapping: Optional[Mapping] = None,
3609
+ data: Any = None,
3610
+ stat: str = "boxplot",
3611
+ position: str = "dodge2",
3612
+ na_rm: bool = False,
3613
+ show_legend: Any = None,
3614
+ inherit_aes: bool = True,
3615
+ outliers: bool = True,
3616
+ notch: bool = False,
3617
+ notchwidth: float = 0.5,
3618
+ staplewidth: float = 0,
3619
+ varwidth: bool = False,
3620
+ orientation: Any = None,
3621
+ **kwargs: Any,
3622
+ ) -> Any:
3623
+ """Create a boxplot layer."""
3624
+ layer = _layer_import()
3625
+ return layer(
3626
+ geom=GeomBoxplot, stat=stat, data=data, mapping=mapping,
3627
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3628
+ params={
3629
+ "na_rm": na_rm, "outliers": outliers, "notch": notch,
3630
+ "notchwidth": notchwidth, "staplewidth": staplewidth,
3631
+ "varwidth": varwidth, "orientation": orientation, **kwargs,
3632
+ },
3633
+ )
3634
+
3635
+
3636
+ def geom_violin(
3637
+ mapping: Optional[Mapping] = None,
3638
+ data: Any = None,
3639
+ stat: str = "ydensity",
3640
+ position: str = "dodge",
3641
+ na_rm: bool = False,
3642
+ show_legend: Any = None,
3643
+ inherit_aes: bool = True,
3644
+ trim: bool = True,
3645
+ scale: str = "area",
3646
+ orientation: Any = None,
3647
+ **kwargs: Any,
3648
+ ) -> Any:
3649
+ """Create a violin layer."""
3650
+ layer = _layer_import()
3651
+ return layer(
3652
+ geom=GeomViolin, stat=stat, data=data, mapping=mapping,
3653
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3654
+ params={"na_rm": na_rm, "trim": trim, "scale": scale, "orientation": orientation, **kwargs},
3655
+ )
3656
+
3657
+
3658
+ def geom_dotplot(
3659
+ mapping: Optional[Mapping] = None,
3660
+ data: Any = None,
3661
+ stat: str = "bindot",
3662
+ position: str = "identity",
3663
+ na_rm: bool = False,
3664
+ show_legend: Any = None,
3665
+ inherit_aes: bool = True,
3666
+ binaxis: str = "x",
3667
+ method: str = "dotdensity",
3668
+ stackdir: str = "up",
3669
+ stackratio: float = 1,
3670
+ dotsize: float = 1,
3671
+ **kwargs: Any,
3672
+ ) -> Any:
3673
+ """Create a dotplot layer."""
3674
+ layer = _layer_import()
3675
+ return layer(
3676
+ geom=GeomDotplot, stat=stat, data=data, mapping=mapping,
3677
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3678
+ params={
3679
+ "na_rm": na_rm, "binaxis": binaxis, "method": method,
3680
+ "stackdir": stackdir, "stackratio": stackratio, "dotsize": dotsize,
3681
+ **kwargs,
3682
+ },
3683
+ )
3684
+
3685
+
3686
+ # ---------------------------------------------------------------------------
3687
+ # Ribbon / Area / Smooth
3688
+ # ---------------------------------------------------------------------------
3689
+
3690
+ def geom_ribbon(
3691
+ mapping: Optional[Mapping] = None,
3692
+ data: Any = None,
3693
+ stat: str = "identity",
3694
+ position: str = "identity",
3695
+ na_rm: bool = False,
3696
+ show_legend: Any = None,
3697
+ inherit_aes: bool = True,
3698
+ orientation: Any = None,
3699
+ outline_type: str = "both",
3700
+ **kwargs: Any,
3701
+ ) -> Any:
3702
+ """Create a ribbon layer."""
3703
+ layer = _layer_import()
3704
+ return layer(
3705
+ geom=GeomRibbon, stat=stat, data=data, mapping=mapping,
3706
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3707
+ params={"na_rm": na_rm, "orientation": orientation, "outline_type": outline_type, **kwargs},
3708
+ )
3709
+
3710
+
3711
+ def geom_area(
3712
+ mapping: Optional[Mapping] = None,
3713
+ data: Any = None,
3714
+ stat: str = "align",
3715
+ position: str = "stack",
3716
+ na_rm: bool = False,
3717
+ show_legend: Any = None,
3718
+ inherit_aes: bool = True,
3719
+ orientation: Any = None,
3720
+ outline_type: str = "upper",
3721
+ **kwargs: Any,
3722
+ ) -> Any:
3723
+ """Create an area layer."""
3724
+ layer = _layer_import()
3725
+ return layer(
3726
+ geom=GeomArea, stat=stat, data=data, mapping=mapping,
3727
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3728
+ params={"na_rm": na_rm, "orientation": orientation, "outline_type": outline_type, **kwargs},
3729
+ )
3730
+
3731
+
3732
+ def geom_smooth(
3733
+ mapping: Optional[Mapping] = None,
3734
+ data: Any = None,
3735
+ stat: str = "smooth",
3736
+ position: str = "identity",
3737
+ na_rm: bool = False,
3738
+ show_legend: Any = None,
3739
+ inherit_aes: bool = True,
3740
+ method: Any = None,
3741
+ formula: Any = None,
3742
+ se: bool = True,
3743
+ orientation: Any = None,
3744
+ **kwargs: Any,
3745
+ ) -> Any:
3746
+ """Create a smooth layer."""
3747
+ layer = _layer_import()
3748
+ params: Dict[str, Any] = {
3749
+ "na_rm": na_rm, "orientation": orientation, "se": se, **kwargs,
3750
+ }
3751
+ if stat == "smooth":
3752
+ params["method"] = method
3753
+ params["formula"] = formula
3754
+ return layer(
3755
+ geom=GeomSmooth, stat=stat, data=data, mapping=mapping,
3756
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3757
+ params=params,
3758
+ )
3759
+
3760
+
3761
+ # ---------------------------------------------------------------------------
3762
+ # Polygon
3763
+ # ---------------------------------------------------------------------------
3764
+
3765
+ def geom_polygon(
3766
+ mapping: Optional[Mapping] = None,
3767
+ data: Any = None,
3768
+ stat: str = "identity",
3769
+ position: str = "identity",
3770
+ na_rm: bool = False,
3771
+ show_legend: Any = None,
3772
+ inherit_aes: bool = True,
3773
+ rule: str = "evenodd",
3774
+ **kwargs: Any,
3775
+ ) -> Any:
3776
+ """Create a polygon layer."""
3777
+ layer = _layer_import()
3778
+ return layer(
3779
+ geom=GeomPolygon, stat=stat, data=data, mapping=mapping,
3780
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3781
+ params={"na_rm": na_rm, "rule": rule, **kwargs},
3782
+ )
3783
+
3784
+
3785
+ # ---------------------------------------------------------------------------
3786
+ # Errorbar / Crossbar / Linerange / Pointrange
3787
+ # ---------------------------------------------------------------------------
3788
+
3789
+ def geom_errorbar(
3790
+ mapping: Optional[Mapping] = None,
3791
+ data: Any = None,
3792
+ stat: str = "identity",
3793
+ position: str = "identity",
3794
+ na_rm: bool = False,
3795
+ show_legend: Any = None,
3796
+ inherit_aes: bool = True,
3797
+ orientation: Any = None,
3798
+ **kwargs: Any,
3799
+ ) -> Any:
3800
+ """Create an errorbar layer."""
3801
+ layer = _layer_import()
3802
+ return layer(
3803
+ geom=GeomErrorbar, stat=stat, data=data, mapping=mapping,
3804
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3805
+ params={"na_rm": na_rm, "orientation": orientation, **kwargs},
3806
+ )
3807
+
3808
+
3809
+ def geom_errorbarh(
3810
+ mapping: Optional[Mapping] = None,
3811
+ data: Any = None,
3812
+ orientation: str = "y",
3813
+ **kwargs: Any,
3814
+ ) -> Any:
3815
+ """Create a horizontal errorbar (deprecated -- use geom_errorbar)."""
3816
+ warnings.warn(
3817
+ "geom_errorbarh() is deprecated. Use geom_errorbar(orientation='y').",
3818
+ FutureWarning,
3819
+ stacklevel=2,
3820
+ )
3821
+ return geom_errorbar(mapping=mapping, data=data, orientation=orientation, **kwargs)
3822
+
3823
+
3824
+ def geom_crossbar(
3825
+ mapping: Optional[Mapping] = None,
3826
+ data: Any = None,
3827
+ stat: str = "identity",
3828
+ position: str = "identity",
3829
+ na_rm: bool = False,
3830
+ show_legend: Any = None,
3831
+ inherit_aes: bool = True,
3832
+ orientation: Any = None,
3833
+ **kwargs: Any,
3834
+ ) -> Any:
3835
+ """Create a crossbar layer."""
3836
+ layer = _layer_import()
3837
+ return layer(
3838
+ geom=GeomCrossbar, stat=stat, data=data, mapping=mapping,
3839
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3840
+ params={"na_rm": na_rm, "orientation": orientation, **kwargs},
3841
+ )
3842
+
3843
+
3844
+ def geom_linerange(
3845
+ mapping: Optional[Mapping] = None,
3846
+ data: Any = None,
3847
+ stat: str = "identity",
3848
+ position: str = "identity",
3849
+ na_rm: bool = False,
3850
+ show_legend: Any = None,
3851
+ inherit_aes: bool = True,
3852
+ orientation: Any = None,
3853
+ **kwargs: Any,
3854
+ ) -> Any:
3855
+ """Create a linerange layer."""
3856
+ layer = _layer_import()
3857
+ return layer(
3858
+ geom=GeomLinerange, stat=stat, data=data, mapping=mapping,
3859
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3860
+ params={"na_rm": na_rm, "orientation": orientation, **kwargs},
3861
+ )
3862
+
3863
+
3864
+ def geom_pointrange(
3865
+ mapping: Optional[Mapping] = None,
3866
+ data: Any = None,
3867
+ stat: str = "identity",
3868
+ position: str = "identity",
3869
+ na_rm: bool = False,
3870
+ show_legend: Any = None,
3871
+ inherit_aes: bool = True,
3872
+ orientation: Any = None,
3873
+ fatten: float = 4,
3874
+ **kwargs: Any,
3875
+ ) -> Any:
3876
+ """Create a pointrange layer."""
3877
+ layer = _layer_import()
3878
+ return layer(
3879
+ geom=GeomPointrange, stat=stat, data=data, mapping=mapping,
3880
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3881
+ params={"na_rm": na_rm, "orientation": orientation, "fatten": fatten, **kwargs},
3882
+ )
3883
+
3884
+
3885
+ # ---------------------------------------------------------------------------
3886
+ # Segment / Curve / Spoke
3887
+ # ---------------------------------------------------------------------------
3888
+
3889
+ def geom_segment(
3890
+ mapping: Optional[Mapping] = None,
3891
+ data: Any = None,
3892
+ stat: str = "identity",
3893
+ position: str = "identity",
3894
+ na_rm: bool = False,
3895
+ show_legend: Any = None,
3896
+ inherit_aes: bool = True,
3897
+ **kwargs: Any,
3898
+ ) -> Any:
3899
+ """Create a segment layer."""
3900
+ layer = _layer_import()
3901
+ return layer(
3902
+ geom=GeomSegment, stat=stat, data=data, mapping=mapping,
3903
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3904
+ params={"na_rm": na_rm, **kwargs},
3905
+ )
3906
+
3907
+
3908
+ def geom_curve(
3909
+ mapping: Optional[Mapping] = None,
3910
+ data: Any = None,
3911
+ stat: str = "identity",
3912
+ position: str = "identity",
3913
+ na_rm: bool = False,
3914
+ show_legend: Any = None,
3915
+ inherit_aes: bool = True,
3916
+ curvature: float = 0.5,
3917
+ angle: float = 90,
3918
+ ncp: int = 5,
3919
+ **kwargs: Any,
3920
+ ) -> Any:
3921
+ """Create a curve layer."""
3922
+ layer = _layer_import()
3923
+ return layer(
3924
+ geom=GeomCurve, stat=stat, data=data, mapping=mapping,
3925
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3926
+ params={"na_rm": na_rm, "curvature": curvature, "angle": angle, "ncp": ncp, **kwargs},
3927
+ )
3928
+
3929
+
3930
+ def geom_spoke(
3931
+ mapping: Optional[Mapping] = None,
3932
+ data: Any = None,
3933
+ stat: str = "identity",
3934
+ position: str = "identity",
3935
+ na_rm: bool = False,
3936
+ show_legend: Any = None,
3937
+ inherit_aes: bool = True,
3938
+ **kwargs: Any,
3939
+ ) -> Any:
3940
+ """Create a spoke layer."""
3941
+ layer = _layer_import()
3942
+ return layer(
3943
+ geom=GeomSpoke, stat=stat, data=data, mapping=mapping,
3944
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3945
+ params={"na_rm": na_rm, **kwargs},
3946
+ )
3947
+
3948
+
3949
+ # ---------------------------------------------------------------------------
3950
+ # Density
3951
+ # ---------------------------------------------------------------------------
3952
+
3953
+ def geom_density(
3954
+ mapping: Optional[Mapping] = None,
3955
+ data: Any = None,
3956
+ stat: str = "density",
3957
+ position: str = "identity",
3958
+ na_rm: bool = False,
3959
+ show_legend: Any = None,
3960
+ inherit_aes: bool = True,
3961
+ outline_type: str = "upper",
3962
+ **kwargs: Any,
3963
+ ) -> Any:
3964
+ """Create a density layer."""
3965
+ layer = _layer_import()
3966
+ return layer(
3967
+ geom=GeomDensity, stat=stat, data=data, mapping=mapping,
3968
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3969
+ params={"na_rm": na_rm, "outline_type": outline_type, **kwargs},
3970
+ )
3971
+
3972
+
3973
+ def geom_density_2d(
3974
+ mapping: Optional[Mapping] = None,
3975
+ data: Any = None,
3976
+ stat: str = "density_2d",
3977
+ position: str = "identity",
3978
+ na_rm: bool = False,
3979
+ show_legend: Any = None,
3980
+ inherit_aes: bool = True,
3981
+ contour_var: str = "density",
3982
+ **kwargs: Any,
3983
+ ) -> Any:
3984
+ """Create a 2D density contour layer."""
3985
+ layer = _layer_import()
3986
+ return layer(
3987
+ geom=GeomDensity2d, stat=stat, data=data, mapping=mapping,
3988
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
3989
+ params={"na_rm": na_rm, "contour_var": contour_var, **kwargs},
3990
+ )
3991
+
3992
+
3993
+ # Aliases
3994
+ geom_density2d = geom_density_2d
3995
+
3996
+
3997
+ def geom_density_2d_filled(
3998
+ mapping: Optional[Mapping] = None,
3999
+ data: Any = None,
4000
+ stat: str = "density_2d_filled",
4001
+ position: str = "identity",
4002
+ na_rm: bool = False,
4003
+ show_legend: Any = None,
4004
+ inherit_aes: bool = True,
4005
+ contour_var: str = "density",
4006
+ **kwargs: Any,
4007
+ ) -> Any:
4008
+ """Create a filled 2D density contour layer."""
4009
+ layer = _layer_import()
4010
+ return layer(
4011
+ geom=GeomDensity2dFilled, stat=stat, data=data, mapping=mapping,
4012
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
4013
+ params={"na_rm": na_rm, "contour_var": contour_var, **kwargs},
4014
+ )
4015
+
4016
+
4017
+ geom_density2d_filled = geom_density_2d_filled
4018
+
4019
+
4020
+ # ---------------------------------------------------------------------------
4021
+ # Contour
4022
+ # ---------------------------------------------------------------------------
4023
+
4024
+ def geom_contour(
4025
+ mapping: Optional[Mapping] = None,
4026
+ data: Any = None,
4027
+ stat: str = "contour",
4028
+ position: str = "identity",
4029
+ na_rm: bool = False,
4030
+ show_legend: Any = None,
4031
+ inherit_aes: bool = True,
4032
+ bins: Optional[int] = None,
4033
+ binwidth: Optional[float] = None,
4034
+ breaks: Any = None,
4035
+ **kwargs: Any,
4036
+ ) -> Any:
4037
+ """Create a contour line layer."""
4038
+ layer = _layer_import()
4039
+ return layer(
4040
+ geom=GeomContour, stat=stat, data=data, mapping=mapping,
4041
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
4042
+ params={"na_rm": na_rm, "bins": bins, "binwidth": binwidth, "breaks": breaks, **kwargs},
4043
+ )
4044
+
4045
+
4046
+ def geom_contour_filled(
4047
+ mapping: Optional[Mapping] = None,
4048
+ data: Any = None,
4049
+ stat: str = "contour_filled",
4050
+ position: str = "identity",
4051
+ na_rm: bool = False,
4052
+ show_legend: Any = None,
4053
+ inherit_aes: bool = True,
4054
+ bins: Optional[int] = None,
4055
+ binwidth: Optional[float] = None,
4056
+ breaks: Any = None,
4057
+ **kwargs: Any,
4058
+ ) -> Any:
4059
+ """Create a filled contour layer."""
4060
+ layer = _layer_import()
4061
+ return layer(
4062
+ geom=GeomContourFilled, stat=stat, data=data, mapping=mapping,
4063
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
4064
+ params={"na_rm": na_rm, "bins": bins, "binwidth": binwidth, "breaks": breaks, **kwargs},
4065
+ )
4066
+
4067
+
4068
+ # ---------------------------------------------------------------------------
4069
+ # Hex / Bin2d
4070
+ # ---------------------------------------------------------------------------
4071
+
4072
+ def geom_hex(
4073
+ mapping: Optional[Mapping] = None,
4074
+ data: Any = None,
4075
+ stat: str = "binhex",
4076
+ position: str = "identity",
4077
+ na_rm: bool = False,
4078
+ show_legend: Any = None,
4079
+ inherit_aes: bool = True,
4080
+ **kwargs: Any,
4081
+ ) -> Any:
4082
+ """Create a hex bin layer."""
4083
+ layer = _layer_import()
4084
+ return layer(
4085
+ geom=GeomHex, stat=stat, data=data, mapping=mapping,
4086
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
4087
+ params={"na_rm": na_rm, **kwargs},
4088
+ )
4089
+
4090
+
4091
+ def geom_bin_2d(
4092
+ mapping: Optional[Mapping] = None,
4093
+ data: Any = None,
4094
+ stat: str = "bin2d",
4095
+ position: str = "identity",
4096
+ na_rm: bool = False,
4097
+ show_legend: Any = None,
4098
+ inherit_aes: bool = True,
4099
+ **kwargs: Any,
4100
+ ) -> Any:
4101
+ """Create a 2D bin heatmap layer."""
4102
+ layer = _layer_import()
4103
+ return layer(
4104
+ geom=GeomBin2d, stat=stat, data=data, mapping=mapping,
4105
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
4106
+ params={"na_rm": na_rm, **kwargs},
4107
+ )
4108
+
4109
+
4110
+ geom_bin2d = geom_bin_2d
4111
+
4112
+
4113
+ # ---------------------------------------------------------------------------
4114
+ # Abline / Hline / Vline
4115
+ # ---------------------------------------------------------------------------
4116
+
4117
+ def geom_abline(
4118
+ mapping: Optional[Mapping] = None,
4119
+ data: Any = None,
4120
+ stat: str = "identity",
4121
+ slope: Any = None,
4122
+ intercept: Any = None,
4123
+ na_rm: bool = False,
4124
+ show_legend: Any = None,
4125
+ inherit_aes: bool = False,
4126
+ **kwargs: Any,
4127
+ ) -> Any:
4128
+ """Create an abline layer."""
4129
+ layer = _layer_import()
4130
+ if slope is not None or intercept is not None:
4131
+ if slope is None:
4132
+ slope = 1
4133
+ if intercept is None:
4134
+ intercept = 0
4135
+ data = pd.DataFrame({"intercept": [intercept] if not hasattr(intercept, "__len__") else intercept,
4136
+ "slope": [slope] if not hasattr(slope, "__len__") else slope})
4137
+ mapping = Mapping(intercept="intercept", slope="slope")
4138
+ show_legend = False
4139
+ elif mapping is None:
4140
+ slope = 1
4141
+ intercept = 0
4142
+ data = pd.DataFrame({"intercept": [intercept], "slope": [slope]})
4143
+ mapping = Mapping(intercept="intercept", slope="slope")
4144
+
4145
+ return layer(
4146
+ geom=GeomAbline, stat=stat, data=data, mapping=mapping,
4147
+ position="identity", show_legend=show_legend, inherit_aes=inherit_aes,
4148
+ params={"na_rm": na_rm, **kwargs},
4149
+ )
4150
+
4151
+
4152
+ def geom_hline(
4153
+ mapping: Optional[Mapping] = None,
4154
+ data: Any = None,
4155
+ stat: str = "identity",
4156
+ position: str = "identity",
4157
+ yintercept: Any = None,
4158
+ na_rm: bool = False,
4159
+ show_legend: Any = None,
4160
+ inherit_aes: bool = False,
4161
+ **kwargs: Any,
4162
+ ) -> Any:
4163
+ """Create a horizontal line layer."""
4164
+ layer = _layer_import()
4165
+ if yintercept is not None:
4166
+ data = pd.DataFrame({"yintercept": [yintercept] if not hasattr(yintercept, "__len__") else yintercept})
4167
+ mapping = Mapping(yintercept="yintercept")
4168
+ show_legend = False
4169
+
4170
+ return layer(
4171
+ geom=GeomHline, stat=stat, data=data, mapping=mapping,
4172
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
4173
+ params={"na_rm": na_rm, **kwargs},
4174
+ )
4175
+
4176
+
4177
+ def geom_vline(
4178
+ mapping: Optional[Mapping] = None,
4179
+ data: Any = None,
4180
+ stat: str = "identity",
4181
+ position: str = "identity",
4182
+ xintercept: Any = None,
4183
+ na_rm: bool = False,
4184
+ show_legend: Any = None,
4185
+ inherit_aes: bool = False,
4186
+ **kwargs: Any,
4187
+ ) -> Any:
4188
+ """Create a vertical line layer."""
4189
+ layer = _layer_import()
4190
+ if xintercept is not None:
4191
+ data = pd.DataFrame({"xintercept": [xintercept] if not hasattr(xintercept, "__len__") else xintercept})
4192
+ mapping = Mapping(xintercept="xintercept")
4193
+ show_legend = False
4194
+
4195
+ return layer(
4196
+ geom=GeomVline, stat=stat, data=data, mapping=mapping,
4197
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
4198
+ params={"na_rm": na_rm, **kwargs},
4199
+ )
4200
+
4201
+
4202
+ # ---------------------------------------------------------------------------
4203
+ # Rug
4204
+ # ---------------------------------------------------------------------------
4205
+
4206
+ def geom_rug(
4207
+ mapping: Optional[Mapping] = None,
4208
+ data: Any = None,
4209
+ stat: str = "identity",
4210
+ position: str = "identity",
4211
+ na_rm: bool = False,
4212
+ show_legend: Any = None,
4213
+ inherit_aes: bool = True,
4214
+ sides: str = "bl",
4215
+ outside: bool = False,
4216
+ length: Any = None,
4217
+ **kwargs: Any,
4218
+ ) -> Any:
4219
+ """Create a rug layer."""
4220
+ layer = _layer_import()
4221
+ return layer(
4222
+ geom=GeomRug, stat=stat, data=data, mapping=mapping,
4223
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
4224
+ params={"na_rm": na_rm, "sides": sides, "outside": outside, "length": length, **kwargs},
4225
+ )
4226
+
4227
+
4228
+ # ---------------------------------------------------------------------------
4229
+ # Blank
4230
+ # ---------------------------------------------------------------------------
4231
+
4232
+ def geom_blank(
4233
+ mapping: Optional[Mapping] = None,
4234
+ data: Any = None,
4235
+ stat: str = "identity",
4236
+ position: str = "identity",
4237
+ show_legend: Any = None,
4238
+ inherit_aes: bool = True,
4239
+ **kwargs: Any,
4240
+ ) -> Any:
4241
+ """Create a blank layer (draws nothing)."""
4242
+ layer = _layer_import()
4243
+ return layer(
4244
+ geom=GeomBlank, stat=stat, data=data, mapping=mapping,
4245
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
4246
+ params=kwargs,
4247
+ )
4248
+
4249
+
4250
+ # ---------------------------------------------------------------------------
4251
+ # Function
4252
+ # ---------------------------------------------------------------------------
4253
+
4254
+ def geom_function(
4255
+ mapping: Optional[Mapping] = None,
4256
+ data: Any = None,
4257
+ stat: str = "function",
4258
+ position: str = "identity",
4259
+ na_rm: bool = False,
4260
+ show_legend: Any = None,
4261
+ inherit_aes: bool = True,
4262
+ **kwargs: Any,
4263
+ ) -> Any:
4264
+ """Create a function layer."""
4265
+ layer = _layer_import()
4266
+ return layer(
4267
+ geom=GeomFunction, stat=stat, data=data, mapping=mapping,
4268
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
4269
+ params={"na_rm": na_rm, **kwargs},
4270
+ )
4271
+
4272
+
4273
+ # ---------------------------------------------------------------------------
4274
+ # Histogram / Freqpoly
4275
+ # ---------------------------------------------------------------------------
4276
+
4277
+ def geom_histogram(
4278
+ mapping: Optional[Mapping] = None,
4279
+ data: Any = None,
4280
+ stat: str = "bin",
4281
+ position: str = "stack",
4282
+ na_rm: bool = False,
4283
+ show_legend: Any = None,
4284
+ inherit_aes: bool = True,
4285
+ binwidth: Any = None,
4286
+ bins: Optional[int] = None,
4287
+ orientation: Any = None,
4288
+ **kwargs: Any,
4289
+ ) -> Any:
4290
+ """Create a histogram layer."""
4291
+ layer = _layer_import()
4292
+ return layer(
4293
+ geom=GeomBar, stat=stat, data=data, mapping=mapping,
4294
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
4295
+ params={"na_rm": na_rm, "binwidth": binwidth, "bins": bins, "orientation": orientation, **kwargs},
4296
+ )
4297
+
4298
+
4299
+ def geom_freqpoly(
4300
+ mapping: Optional[Mapping] = None,
4301
+ data: Any = None,
4302
+ stat: str = "bin",
4303
+ position: str = "identity",
4304
+ na_rm: bool = False,
4305
+ show_legend: Any = None,
4306
+ inherit_aes: bool = True,
4307
+ **kwargs: Any,
4308
+ ) -> Any:
4309
+ """Create a frequency polygon layer."""
4310
+ layer = _layer_import()
4311
+ params: Dict[str, Any] = {"na_rm": na_rm, **kwargs}
4312
+ if stat == "bin":
4313
+ params["pad"] = True
4314
+ return layer(
4315
+ geom=GeomPath, stat=stat, data=data, mapping=mapping,
4316
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
4317
+ params=params,
4318
+ )
4319
+
4320
+
4321
+ # ---------------------------------------------------------------------------
4322
+ # Count / Jitter
4323
+ # ---------------------------------------------------------------------------
4324
+
4325
+ def geom_count(
4326
+ mapping: Optional[Mapping] = None,
4327
+ data: Any = None,
4328
+ stat: str = "sum",
4329
+ position: str = "identity",
4330
+ na_rm: bool = False,
4331
+ show_legend: Any = None,
4332
+ inherit_aes: bool = True,
4333
+ **kwargs: Any,
4334
+ ) -> Any:
4335
+ """Create a count layer (points sized by n at each location)."""
4336
+ layer = _layer_import()
4337
+ return layer(
4338
+ geom=GeomPoint, stat=stat, data=data, mapping=mapping,
4339
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
4340
+ params={"na_rm": na_rm, **kwargs},
4341
+ )
4342
+
4343
+
4344
+ def geom_jitter(
4345
+ mapping: Optional[Mapping] = None,
4346
+ data: Any = None,
4347
+ stat: str = "identity",
4348
+ position: str = "jitter",
4349
+ na_rm: bool = False,
4350
+ show_legend: Any = None,
4351
+ inherit_aes: bool = True,
4352
+ width: Optional[float] = None,
4353
+ height: Optional[float] = None,
4354
+ **kwargs: Any,
4355
+ ) -> Any:
4356
+ """Create a jittered point layer."""
4357
+ layer = _layer_import()
4358
+ if width is not None or height is not None:
4359
+ position = {"name": "jitter", "width": width, "height": height}
4360
+ return layer(
4361
+ geom=GeomPoint, stat=stat, data=data, mapping=mapping,
4362
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
4363
+ params={"na_rm": na_rm, **kwargs},
4364
+ )
4365
+
4366
+
4367
+ # ---------------------------------------------------------------------------
4368
+ # Map
4369
+ # ---------------------------------------------------------------------------
4370
+
4371
+ def geom_map(
4372
+ mapping: Optional[Mapping] = None,
4373
+ data: Any = None,
4374
+ stat: str = "identity",
4375
+ map: Optional[pd.DataFrame] = None,
4376
+ na_rm: bool = False,
4377
+ show_legend: Any = None,
4378
+ inherit_aes: bool = True,
4379
+ **kwargs: Any,
4380
+ ) -> Any:
4381
+ """Create a map polygon layer."""
4382
+ layer = _layer_import()
4383
+ return layer(
4384
+ geom=GeomMap, stat=stat, data=data, mapping=mapping,
4385
+ position="identity", show_legend=show_legend, inherit_aes=inherit_aes,
4386
+ params={"na_rm": na_rm, "map": map, **kwargs},
4387
+ )
4388
+
4389
+
4390
+ # ---------------------------------------------------------------------------
4391
+ # Quantile
4392
+ # ---------------------------------------------------------------------------
4393
+
4394
+ def geom_quantile(
4395
+ mapping: Optional[Mapping] = None,
4396
+ data: Any = None,
4397
+ stat: str = "quantile",
4398
+ position: str = "identity",
4399
+ na_rm: bool = False,
4400
+ show_legend: Any = None,
4401
+ inherit_aes: bool = True,
4402
+ **kwargs: Any,
4403
+ ) -> Any:
4404
+ """Create a quantile regression line layer."""
4405
+ layer = _layer_import()
4406
+ return layer(
4407
+ geom=GeomQuantile, stat=stat, data=data, mapping=mapping,
4408
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
4409
+ params={"na_rm": na_rm, **kwargs},
4410
+ )
4411
+
4412
+
4413
+ # ---------------------------------------------------------------------------
4414
+ # Sf
4415
+ # ---------------------------------------------------------------------------
4416
+
4417
+ def geom_sf(
4418
+ mapping: Optional[Mapping] = None,
4419
+ data: Any = None,
4420
+ stat: str = "sf",
4421
+ position: str = "identity",
4422
+ na_rm: bool = False,
4423
+ show_legend: Any = None,
4424
+ inherit_aes: bool = True,
4425
+ **kwargs: Any,
4426
+ ) -> Any:
4427
+ """Create a simple-features layer."""
4428
+ layer = _layer_import()
4429
+ return layer(
4430
+ geom=GeomSf, stat=stat, data=data, mapping=mapping or Mapping(),
4431
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
4432
+ params={"na_rm": na_rm, **kwargs},
4433
+ )
4434
+
4435
+
4436
+ def geom_sf_text(
4437
+ mapping: Optional[Mapping] = None,
4438
+ data: Any = None,
4439
+ stat: str = "sf_coordinates",
4440
+ position: str = "nudge",
4441
+ na_rm: bool = False,
4442
+ show_legend: Any = None,
4443
+ inherit_aes: bool = True,
4444
+ parse: bool = False,
4445
+ check_overlap: bool = False,
4446
+ **kwargs: Any,
4447
+ ) -> Any:
4448
+ """Create a text layer for sf geometries."""
4449
+ layer = _layer_import()
4450
+ return layer(
4451
+ geom=GeomText, stat=stat, data=data, mapping=mapping or Mapping(),
4452
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
4453
+ params={"na_rm": na_rm, "parse": parse, "check_overlap": check_overlap, **kwargs},
4454
+ )
4455
+
4456
+
4457
+ def geom_sf_label(
4458
+ mapping: Optional[Mapping] = None,
4459
+ data: Any = None,
4460
+ stat: str = "sf_coordinates",
4461
+ position: str = "nudge",
4462
+ na_rm: bool = False,
4463
+ show_legend: Any = None,
4464
+ inherit_aes: bool = True,
4465
+ parse: bool = False,
4466
+ **kwargs: Any,
4467
+ ) -> Any:
4468
+ """Create a label layer for sf geometries."""
4469
+ layer = _layer_import()
4470
+ return layer(
4471
+ geom=GeomLabel, stat=stat, data=data, mapping=mapping or Mapping(),
4472
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
4473
+ params={"na_rm": na_rm, "parse": parse, **kwargs},
4474
+ )
4475
+
4476
+
4477
+ # ---------------------------------------------------------------------------
4478
+ # QQ (geom only -- delegates to stat_qq / stat_qq_line)
4479
+ # ---------------------------------------------------------------------------
4480
+
4481
+ def geom_qq(
4482
+ mapping: Optional[Mapping] = None,
4483
+ data: Any = None,
4484
+ stat: str = "qq",
4485
+ position: str = "identity",
4486
+ na_rm: bool = False,
4487
+ show_legend: Any = None,
4488
+ inherit_aes: bool = True,
4489
+ **kwargs: Any,
4490
+ ) -> Any:
4491
+ """Create a QQ-plot point layer."""
4492
+ layer = _layer_import()
4493
+ return layer(
4494
+ geom=GeomPoint, stat=stat, data=data, mapping=mapping,
4495
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
4496
+ params={"na_rm": na_rm, **kwargs},
4497
+ )
4498
+
4499
+
4500
+ def geom_qq_line(
4501
+ mapping: Optional[Mapping] = None,
4502
+ data: Any = None,
4503
+ stat: str = "qq_line",
4504
+ position: str = "identity",
4505
+ na_rm: bool = False,
4506
+ show_legend: Any = None,
4507
+ inherit_aes: bool = True,
4508
+ **kwargs: Any,
4509
+ ) -> Any:
4510
+ """Create a QQ-line layer."""
4511
+ layer = _layer_import()
4512
+ return layer(
4513
+ geom=GeomPath, stat=stat, data=data, mapping=mapping,
4514
+ position=position, show_legend=show_legend, inherit_aes=inherit_aes,
4515
+ params={"na_rm": na_rm, **kwargs},
4516
+ )