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/coord.py ADDED
@@ -0,0 +1,2108 @@
1
+ """
2
+ Coordinate systems for ggplot2.
3
+
4
+ Coordinate systems control how position aesthetics are mapped to the 2-D
5
+ plane of the plot. They also provide axes, panel backgrounds, and
6
+ foreground decorations.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+
17
+ from ggplot2_py._compat import Waiver, is_waiver, waiver, cli_abort, cli_warn
18
+
19
+
20
+ def _is_waiver_like(x: Any) -> bool:
21
+ """Check if x is a Waiver or waiver-like sentinel."""
22
+ return is_waiver(x) or x is None
23
+ from ggplot2_py.ggproto import GGProto, ggproto
24
+ from ggplot2_py._utils import snake_class, modify_list, compact
25
+
26
+ __all__ = [
27
+ "Coord",
28
+ "CoordCartesian",
29
+ "CoordFixed",
30
+ "CoordFlip",
31
+ "CoordPolar",
32
+ "CoordRadial",
33
+ "CoordTrans",
34
+ "CoordTransform",
35
+ "coord_cartesian",
36
+ "coord_equal",
37
+ "coord_fixed",
38
+ "coord_flip",
39
+ "coord_polar",
40
+ "coord_radial",
41
+ "coord_trans",
42
+ "coord_transform",
43
+ "coord_munch",
44
+ "is_coord",
45
+ "is_Coord",
46
+ ]
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Break computation helpers for panel_params
51
+ # ---------------------------------------------------------------------------
52
+
53
+
54
+ def _scale_numeric_range(scale: Any, fallback: Optional[list] = None) -> list:
55
+ """Return the **expanded** numeric range for *scale*.
56
+
57
+ R (coord-cartesian-.R:175-189 ``view_scales_from_scale``):
58
+
59
+ expansion <- default_expansion(scale, expand = TRUE)
60
+ continuous_range <- expand_limits_scale(scale, expansion, limits)
61
+
62
+ R's ``Scale$dimension()`` itself defaults to ``expansion(0, 0)``
63
+ (i.e. no expansion). Expansion is applied *at the call site* —
64
+ ``view_scales_from_scale`` passes the per-scale
65
+ ``default_expansion`` explicitly. Python previously baked a 5%
66
+ expansion into ``dimension()`` as the default, which corrupted
67
+ any caller that needed raw limits (e.g. ``hex_binwidth``). With
68
+ that default now matching R (no expansion), we have to apply
69
+ the expansion here.
70
+ """
71
+ if scale is None:
72
+ return list(fallback or [0, 1])
73
+
74
+ if hasattr(scale, "dimension"):
75
+ try:
76
+ # Compute the scale-specific expansion vector (continuous
77
+ # mult=0.05, discrete add=0.6, honouring a user-supplied
78
+ # ``expand`` on the scale) and ask dimension() to apply it.
79
+ from ggplot2_py.scale import default_expansion as _def_exp
80
+ exp_vec = _def_exp(scale, expand=True)
81
+ d = list(scale.dimension(expand=exp_vec))
82
+ if len(d) >= 2:
83
+ float(d[0])
84
+ float(d[1])
85
+ return d
86
+ except (ValueError, TypeError, ImportError):
87
+ pass
88
+
89
+ # Fallback to get_limits for scales without dimension()
90
+ if hasattr(scale, "get_limits"):
91
+ try:
92
+ lim = list(scale.get_limits())
93
+ float(lim[0])
94
+ float(lim[1])
95
+ return lim
96
+ except (ValueError, TypeError, IndexError):
97
+ pass
98
+
99
+ return list(fallback or [0, 1])
100
+
101
+
102
+ def _is_discrete_scale(scale: Any) -> bool:
103
+ """Return True if *scale* is a discrete position scale."""
104
+ cls_name = type(scale).__name__ if scale is not None else ""
105
+ return "Discrete" in cls_name
106
+
107
+
108
+ def _compute_mapped_breaks(
109
+ scale: Any,
110
+ range_: list,
111
+ n: int = 5,
112
+ ) -> np.ndarray:
113
+ """Compute major breaks and rescale to [0, 1] NPC.
114
+
115
+ If the scale provides ``get_breaks()``, use it; otherwise fall back
116
+ to ``numpy.linspace``. For discrete position scales the breaks are
117
+ placed at the integer positions corresponding to each level.
118
+ """
119
+ try:
120
+ r0, r1 = float(range_[0]), float(range_[1])
121
+ except (ValueError, TypeError):
122
+ return np.array([])
123
+
124
+ breaks = None
125
+ if scale is not None and hasattr(scale, "get_breaks"):
126
+ try:
127
+ if _is_discrete_scale(scale):
128
+ # For discrete scales, call get_breaks() without numeric
129
+ # limits so it returns the category labels, then map to
130
+ # integer positions 1..N.
131
+ raw = scale.get_breaks()
132
+ if raw is not None and len(raw) > 0:
133
+ breaks = np.arange(1, len(raw) + 1, dtype=float)
134
+ else:
135
+ raw = scale.get_breaks(range_)
136
+ if raw is not None and len(raw) > 0:
137
+ breaks = np.asarray(raw, dtype=float)
138
+ except Exception:
139
+ pass
140
+ if breaks is None or (hasattr(breaks, "__len__") and len(breaks) == 0):
141
+ breaks = np.linspace(r0, r1, n + 2)[1:-1]
142
+ try:
143
+ breaks = np.asarray(breaks, dtype=float)
144
+ except (ValueError, TypeError):
145
+ return np.array([])
146
+ breaks = breaks[np.isfinite(breaks)]
147
+ # Rescale to [0, 1]
148
+ rng = r1 - r0
149
+ if rng == 0:
150
+ return np.array([0.5] * len(breaks))
151
+ return (breaks - r0) / rng
152
+
153
+
154
+ def _compute_break_labels(scale: Any, range_: list) -> Tuple[np.ndarray, List[str]]:
155
+ """Return (break_positions_in_npc, labels) for axis rendering.
156
+
157
+ This supplements ``_compute_mapped_breaks`` by also returning the
158
+ text labels that should appear at each break.
159
+ """
160
+ try:
161
+ r0, r1 = float(range_[0]), float(range_[1])
162
+ except (ValueError, TypeError):
163
+ return np.array([]), []
164
+
165
+ if scale is None or not hasattr(scale, "get_breaks"):
166
+ return np.array([]), []
167
+
168
+ if _is_discrete_scale(scale):
169
+ raw_breaks = scale.get_breaks() # string labels
170
+ if raw_breaks is None or len(raw_breaks) == 0:
171
+ return np.array([]), []
172
+ labels = [str(b) for b in raw_breaks]
173
+ positions = np.arange(1, len(raw_breaks) + 1, dtype=float)
174
+ else:
175
+ raw_breaks = scale.get_breaks(range_)
176
+ if raw_breaks is None or len(raw_breaks) == 0:
177
+ return np.array([]), []
178
+ try:
179
+ positions = np.asarray(raw_breaks, dtype=float)
180
+ except (ValueError, TypeError):
181
+ return np.array([]), []
182
+ # Get labels
183
+ if hasattr(scale, "get_labels"):
184
+ try:
185
+ labels = scale.get_labels(raw_breaks)
186
+ except Exception:
187
+ labels = [str(b) for b in raw_breaks]
188
+ else:
189
+ labels = [str(b) for b in raw_breaks]
190
+
191
+ # Filter out non-finite
192
+ finite = np.isfinite(positions)
193
+ positions = positions[finite]
194
+ labels = [l for l, f in zip(labels, finite) if f]
195
+
196
+ # Filter to range (keep only breaks within [r0, r1])
197
+ in_range = (positions >= r0) & (positions <= r1)
198
+ positions = positions[in_range]
199
+ labels = [l for l, f in zip(labels, in_range) if f]
200
+
201
+ # Rescale to [0, 1]
202
+ rng = r1 - r0
203
+ if rng == 0:
204
+ npc = np.full(len(positions), 0.5)
205
+ else:
206
+ npc = (positions - r0) / rng
207
+
208
+ return npc, labels
209
+
210
+
211
+ def _compute_mapped_minor_breaks(
212
+ scale: Any,
213
+ range_: list,
214
+ major: np.ndarray,
215
+ n: int = 2,
216
+ ) -> np.ndarray:
217
+ """Compute minor breaks in [0, 1] NPC, excluding positions that
218
+ coincide with major breaks."""
219
+ minor = None
220
+ if scale is not None and hasattr(scale, "get_breaks_minor"):
221
+ try:
222
+ minor = scale.get_breaks_minor(range_, n=n)
223
+ except Exception:
224
+ pass
225
+ if minor is None or (hasattr(minor, "__len__") and len(minor) == 0):
226
+ # Default: one minor break between each pair of major breaks
227
+ if len(major) >= 2:
228
+ mids = (major[:-1] + major[1:]) / 2.0
229
+ minor = mids
230
+ else:
231
+ return np.array([])
232
+ else:
233
+ minor = np.asarray(minor, dtype=float)
234
+ rng = range_[1] - range_[0]
235
+ if rng != 0:
236
+ minor = (minor - range_[0]) / rng
237
+ minor = minor[np.isfinite(minor)]
238
+ # Remove minor breaks that coincide with major breaks
239
+ if len(major) > 0 and len(minor) > 0:
240
+ keep = np.array([not np.any(np.abs(major - m) < 1e-8) for m in minor])
241
+ minor = minor[keep]
242
+ return minor
243
+
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # guide_grid — panel background and grid lines
247
+ # ---------------------------------------------------------------------------
248
+
249
+
250
+ def guide_grid(
251
+ theme: Any,
252
+ panel_params: Dict[str, Any],
253
+ coord: Any,
254
+ ) -> Any:
255
+ """Render the panel background rectangle and grid lines.
256
+
257
+ Mirrors R's ``guide_grid()`` from ``guides-grid.R``. Produces a
258
+ ``GTree`` containing:
259
+
260
+ 1. Panel background (``panel.background`` theme element)
261
+ 2. Minor grid lines (``panel.grid.minor.x/y``)
262
+ 3. Major grid lines (``panel.grid.major.x/y``)
263
+
264
+ Parameters
265
+ ----------
266
+ theme : Theme
267
+ The plot theme.
268
+ panel_params : dict
269
+ Panel parameters (must contain ``x_major``, ``x_minor``,
270
+ ``y_major``, ``y_minor`` arrays in [0, 1] NPC).
271
+ coord : Coord
272
+ The coordinate system.
273
+
274
+ Returns
275
+ -------
276
+ GTree
277
+ A grob tree with background + grid lines.
278
+ """
279
+ from grid_py import (
280
+ rect_grob, polyline_grob, grob_tree,
281
+ null_grob, Gpar, Unit, GTree, GList,
282
+ )
283
+ # R: guide_grid() (guides-grid.R:6-41) — always uses element_render(),
284
+ # no try/except fallback. Theme element → grob via element_grob().
285
+ from ggplot2_py.theme_elements import element_render
286
+
287
+ children = []
288
+
289
+ # 1. Panel background (R: guides-grid.R:32)
290
+ bg = element_render(theme, "panel.background")
291
+ if bg is not None:
292
+ children.append(bg)
293
+
294
+ x_major = panel_params.get("x_major", np.array([]))
295
+ x_minor = panel_params.get("x_minor", np.array([]))
296
+ y_major = panel_params.get("y_major", np.array([]))
297
+ y_minor = panel_params.get("y_minor", np.array([]))
298
+
299
+ # 2. Minor grid lines (R: breaks_as_grid, guides-grid.R:43-60)
300
+ if len(y_minor) > 0:
301
+ grob = element_render(
302
+ theme, "panel.grid.minor.y",
303
+ x=np.tile([0.0, 1.0], len(y_minor)),
304
+ y=np.repeat(y_minor, 2),
305
+ id_lengths=[2] * len(y_minor),
306
+ )
307
+ if grob is not None:
308
+ children.append(grob)
309
+
310
+ if len(x_minor) > 0:
311
+ grob = element_render(
312
+ theme, "panel.grid.minor.x",
313
+ x=np.repeat(x_minor, 2),
314
+ y=np.tile([0.0, 1.0], len(x_minor)),
315
+ id_lengths=[2] * len(x_minor),
316
+ )
317
+ if grob is not None:
318
+ children.append(grob)
319
+
320
+ # 3. Major grid lines
321
+ if len(y_major) > 0:
322
+ grob = element_render(
323
+ theme, "panel.grid.major.y",
324
+ x=np.tile([0.0, 1.0], len(y_major)),
325
+ y=np.repeat(y_major, 2),
326
+ id_lengths=[2] * len(y_major),
327
+ )
328
+ if grob is not None:
329
+ children.append(grob)
330
+
331
+ if len(x_major) > 0:
332
+ grob = element_render(
333
+ theme, "panel.grid.major.x",
334
+ x=np.repeat(x_major, 2),
335
+ y=np.tile([0.0, 1.0], len(x_major)),
336
+ id_lengths=[2] * len(x_major),
337
+ )
338
+ if grob is not None:
339
+ children.append(grob)
340
+
341
+ if not children:
342
+ return null_grob()
343
+
344
+ return grob_tree(*children, name="grill")
345
+
346
+
347
+ # ---------------------------------------------------------------------------
348
+ # Utility helpers
349
+ # ---------------------------------------------------------------------------
350
+
351
+ def _rescale(
352
+ x: np.ndarray,
353
+ to: Tuple[float, float] = (0.0, 1.0),
354
+ from_: Optional[Tuple[float, float]] = None,
355
+ ) -> np.ndarray:
356
+ """Linearly rescale *x* from *from_* range to *to* range."""
357
+ x = np.asarray(x, dtype=float)
358
+ if from_ is None:
359
+ from_ = (float(np.nanmin(x)), float(np.nanmax(x)))
360
+ rng = from_[1] - from_[0]
361
+ if rng == 0:
362
+ return np.full_like(x, (to[0] + to[1]) / 2.0)
363
+ return (x - from_[0]) / rng * (to[1] - to[0]) + to[0]
364
+
365
+
366
+ def _squish_infinite(
367
+ x: np.ndarray,
368
+ range_: Optional[Tuple[float, float]] = None,
369
+ ) -> np.ndarray:
370
+ """Squish infinite values to the range endpoints."""
371
+ x = np.asarray(x, dtype=float)
372
+ if range_ is not None:
373
+ x = np.where(np.isneginf(x), range_[0], x)
374
+ x = np.where(np.isposinf(x), range_[1], x)
375
+ else:
376
+ x = np.where(np.isneginf(x), 0.0, x)
377
+ x = np.where(np.isposinf(x), 1.0, x)
378
+ return x
379
+
380
+
381
+ def _dist_euclidean(
382
+ x: np.ndarray, y: np.ndarray
383
+ ) -> np.ndarray:
384
+ """Euclidean distance between successive points."""
385
+ x = np.asarray(x, dtype=float)
386
+ y = np.asarray(y, dtype=float)
387
+ if x.ndim == 0 or len(x) < 2:
388
+ return np.array([0.0])
389
+ dx = np.diff(x)
390
+ dy = np.diff(y)
391
+ return np.sqrt(dx ** 2 + dy ** 2)
392
+
393
+
394
+ def _dist_polar(r: np.ndarray, theta: np.ndarray) -> np.ndarray:
395
+ """Distance in polar coordinates between successive points."""
396
+ r = np.asarray(r, dtype=float)
397
+ theta = np.asarray(theta, dtype=float)
398
+ if len(r) < 2:
399
+ return np.array([0.0])
400
+ dr = np.diff(r)
401
+ dtheta = np.diff(theta)
402
+ r1 = r[:-1]
403
+ r2 = r[1:]
404
+ return np.sqrt(r1 ** 2 + r2 ** 2 - 2 * r1 * r2 * np.cos(dtheta))
405
+
406
+
407
+ def _theta_rescale(
408
+ x: np.ndarray,
409
+ range_: Tuple[float, float],
410
+ arc: Tuple[float, float] = (0, 2 * math.pi),
411
+ direction: int = 1,
412
+ ) -> np.ndarray:
413
+ """Rescale theta to arc range, squishing and wrapping."""
414
+ x = np.asarray(x, dtype=float)
415
+ x = np.clip(x, range_[0], range_[1])
416
+ out = _rescale(x, to=arc, from_=range_)
417
+ return (out % (2 * math.pi)) * direction
418
+
419
+
420
+ def _theta_rescale_no_clip(
421
+ x: np.ndarray,
422
+ range_: Tuple[float, float],
423
+ arc: Tuple[float, float] = (0, 2 * math.pi),
424
+ direction: int = 1,
425
+ ) -> np.ndarray:
426
+ """Rescale theta without clipping."""
427
+ x = np.asarray(x, dtype=float)
428
+ return _rescale(x, to=arc, from_=range_) * direction
429
+
430
+
431
+ def _r_rescale(
432
+ x: np.ndarray,
433
+ range_: Tuple[float, float],
434
+ donut: Tuple[float, float] = (0.0, 0.4),
435
+ ) -> np.ndarray:
436
+ """Rescale radius to donut range."""
437
+ x = np.asarray(x, dtype=float)
438
+ x = np.clip(x, range_[0], range_[1])
439
+ return _rescale(x, to=donut, from_=range_)
440
+
441
+
442
+ def _parse_coord_expand(expand: Any) -> List[bool]:
443
+ """Expand argument to a length-4 list of booleans (top, right, bottom, left)."""
444
+ if isinstance(expand, bool):
445
+ return [expand] * 4
446
+ if isinstance(expand, (list, tuple)):
447
+ result = list(expand)
448
+ while len(result) < 4:
449
+ result.append(result[-1] if result else True)
450
+ return [bool(v) for v in result[:4]]
451
+ return [True, True, True, True]
452
+
453
+
454
+ def _transform_position(
455
+ data: pd.DataFrame,
456
+ trans_x: Any = None,
457
+ trans_y: Any = None,
458
+ ) -> pd.DataFrame:
459
+ """Apply transformation functions to position aesthetics.
460
+
461
+ Parameters
462
+ ----------
463
+ data : pd.DataFrame
464
+ Data to transform.
465
+ trans_x, trans_y : callable, optional
466
+ Transformation functions for x and y families.
467
+
468
+ Returns
469
+ -------
470
+ pd.DataFrame
471
+ Transformed data.
472
+ """
473
+ data = data.copy()
474
+ x_cols = [c for c in data.columns if c in ("x", "xmin", "xmax", "xend", "xintercept")]
475
+ y_cols = [c for c in data.columns if c in ("y", "ymin", "ymax", "yend", "yintercept")]
476
+ if trans_x is not None:
477
+ for c in x_cols:
478
+ data[c] = trans_x(data[c].values)
479
+ if trans_y is not None:
480
+ for c in y_cols:
481
+ data[c] = trans_y(data[c].values)
482
+ return data
483
+
484
+
485
+ # ---------------------------------------------------------------------------
486
+ # Base Coord
487
+ # ---------------------------------------------------------------------------
488
+
489
+ class Coord(GGProto):
490
+ """Base coordinate system.
491
+
492
+ Attributes
493
+ ----------
494
+ default : bool
495
+ Whether this is the default coordinate system.
496
+ clip : str
497
+ Clipping setting: ``"on"``, ``"off"``, or ``"inherit"``.
498
+ reverse : str
499
+ Which directions to reverse: ``"none"``, ``"x"``, ``"y"``, or ``"xy"``.
500
+ """
501
+
502
+ # --- Auto-registration registry (Python-exclusive) -------------------
503
+ _registry: Dict[str, Any] = {}
504
+
505
+ def __init_subclass__(cls, **kwargs: Any) -> None:
506
+ super().__init_subclass__(**kwargs)
507
+ name = cls.__name__
508
+ if name.startswith("Coord") and len(name) > 5:
509
+ key = name[5:]
510
+ Coord._registry[key] = cls
511
+ Coord._registry[key.lower()] = cls
512
+
513
+ default: bool = False
514
+ clip: str = "on"
515
+ reverse: str = "none"
516
+
517
+ # -- setup ---------------------------------------------------------------
518
+
519
+ def setup_params(self, data: Any) -> Dict[str, Any]:
520
+ """Modify or check parameters based on data.
521
+
522
+ Parameters
523
+ ----------
524
+ data : list of DataFrames
525
+ Global data followed by layer data.
526
+
527
+ Returns
528
+ -------
529
+ dict
530
+ Parameters, including parsed ``expand``.
531
+ """
532
+ expand = getattr(self, "expand", True)
533
+ return {"expand": _parse_coord_expand(expand)}
534
+
535
+ def setup_data(self, data: Any, params: Optional[Dict[str, Any]] = None) -> Any:
536
+ """Hook for modifying data before defaults are added.
537
+
538
+ Parameters
539
+ ----------
540
+ data : list of DataFrames
541
+ params : dict
542
+
543
+ Returns
544
+ -------
545
+ list of DataFrames
546
+ """
547
+ return data
548
+
549
+ def setup_layout(self, layout: pd.DataFrame, params: Optional[Dict[str, Any]] = None) -> pd.DataFrame:
550
+ """Hook for the coord to influence layout.
551
+
552
+ Parameters
553
+ ----------
554
+ layout : pd.DataFrame
555
+ Layout table with ``ROW``, ``COL``, ``PANEL``, ``SCALE_X``, ``SCALE_Y``.
556
+ params : dict
557
+
558
+ Returns
559
+ -------
560
+ pd.DataFrame
561
+ Layout with an added ``COORD`` column.
562
+ """
563
+ layout = layout.copy()
564
+ scales = layout[["SCALE_X", "SCALE_Y"]]
565
+ unique_scales = scales.drop_duplicates().reset_index(drop=True)
566
+ unique_scales = unique_scales.copy()
567
+ unique_scales["COORD"] = range(1, len(unique_scales) + 1)
568
+ layout = layout.drop(columns="COORD", errors="ignore")
569
+ layout = pd.merge(layout, unique_scales, on=["SCALE_X", "SCALE_Y"], how="left")
570
+ return layout
571
+
572
+ # -- panel params --------------------------------------------------------
573
+
574
+ def modify_scales(self, scales_x: list, scales_y: list) -> None:
575
+ """Optionally modify scales in-place.
576
+
577
+ Parameters
578
+ ----------
579
+ scales_x, scales_y : list
580
+ Lists of trained x and y scales.
581
+ """
582
+ pass
583
+
584
+ def setup_panel_params(
585
+ self,
586
+ scale_x: Any,
587
+ scale_y: Any,
588
+ params: Optional[Dict[str, Any]] = None,
589
+ ) -> Dict[str, Any]:
590
+ """Create panel parameters for one panel.
591
+
592
+ Parameters
593
+ ----------
594
+ scale_x, scale_y : Scale
595
+ Trained position scales.
596
+ params : dict
597
+
598
+ Returns
599
+ -------
600
+ dict
601
+ Panel parameters including view scales and ranges.
602
+ """
603
+ return {}
604
+
605
+ def setup_panel_guides(
606
+ self,
607
+ panel_params: Dict[str, Any],
608
+ guides: Any,
609
+ params: Optional[Dict[str, Any]] = None,
610
+ ) -> Dict[str, Any]:
611
+ """Initiate position guides for a panel.
612
+
613
+ Parameters
614
+ ----------
615
+ panel_params : dict
616
+ Output from ``setup_panel_params``.
617
+ guides : Guides
618
+ Guides ggproto.
619
+ params : dict
620
+
621
+ Returns
622
+ -------
623
+ dict
624
+ ``panel_params`` with guides appended.
625
+ """
626
+ panel_params["guides"] = guides
627
+ return panel_params
628
+
629
+ def train_panel_guides(
630
+ self,
631
+ panel_params: Dict[str, Any],
632
+ layers: list,
633
+ params: Optional[Dict[str, Any]] = None,
634
+ ) -> Dict[str, Any]:
635
+ """Train and transform position guides.
636
+
637
+ Parameters
638
+ ----------
639
+ panel_params : dict
640
+ layers : list
641
+ params : dict
642
+
643
+ Returns
644
+ -------
645
+ dict
646
+ """
647
+ return panel_params
648
+
649
+ # -- transform -----------------------------------------------------------
650
+
651
+ def transform(self, data: pd.DataFrame, panel_params: Dict[str, Any]) -> pd.DataFrame:
652
+ """Transform data coordinates to [0, 1] range.
653
+
654
+ Parameters
655
+ ----------
656
+ data : pd.DataFrame
657
+ Data with numeric position columns.
658
+ panel_params : dict
659
+ Panel parameters.
660
+
661
+ Returns
662
+ -------
663
+ pd.DataFrame
664
+ Transformed data.
665
+ """
666
+ cli_abort(f"{snake_class(self)} has not implemented transform().")
667
+
668
+ def distance(
669
+ self,
670
+ x: np.ndarray,
671
+ y: np.ndarray,
672
+ panel_params: Dict[str, Any],
673
+ ) -> np.ndarray:
674
+ """Compute distances between successive points.
675
+
676
+ Parameters
677
+ ----------
678
+ x, y : array-like
679
+ panel_params : dict
680
+
681
+ Returns
682
+ -------
683
+ np.ndarray
684
+ """
685
+ cli_abort(f"{snake_class(self)} has not implemented distance().")
686
+
687
+ def backtransform_range(self, panel_params: Dict[str, Any]) -> Dict[str, Any]:
688
+ """Convert ranges back to data coordinates.
689
+
690
+ Parameters
691
+ ----------
692
+ panel_params : dict
693
+
694
+ Returns
695
+ -------
696
+ dict
697
+ With ``x`` and ``y`` ranges.
698
+ """
699
+ cli_abort(f"{snake_class(self)} has not implemented backtransform_range().")
700
+
701
+ def range(self, panel_params: Dict[str, Any]) -> Dict[str, list]:
702
+ """Extract x/y ranges from panel_params.
703
+
704
+ Parameters
705
+ ----------
706
+ panel_params : dict
707
+
708
+ Returns
709
+ -------
710
+ dict
711
+ ``{"x": [lo, hi], "y": [lo, hi]}``.
712
+ """
713
+ cli_abort(f"{snake_class(self)} has not implemented range().")
714
+
715
+ # -- render --------------------------------------------------------------
716
+
717
+ def draw_panel(self, panel: Any, params: Dict[str, Any], theme: Any) -> Any:
718
+ """Decorate panel with foreground and background.
719
+
720
+ Parameters
721
+ ----------
722
+ panel : grob
723
+ params : dict
724
+ theme : Theme
725
+
726
+ Returns
727
+ -------
728
+ grob
729
+ """
730
+ from grid_py import GTree, GList, Viewport
731
+ fg = self.render_fg(params, theme)
732
+ bg = self.render_bg(params, theme)
733
+ children = [bg] + (list(panel) if isinstance(panel, (list, tuple)) else [panel]) + [fg]
734
+
735
+ # The panel viewport maps NPC [0,1] to the panel sub-region,
736
+ # matching R's Coord$draw_panel which wraps content in a
737
+ # clipping viewport.
738
+ return GTree(
739
+ children=GList(*children),
740
+ vp=Viewport(clip=self.clip),
741
+ )
742
+
743
+ def render_fg(self, panel_params: Dict[str, Any], theme: Any) -> Any:
744
+ """Render panel foreground.
745
+
746
+ Parameters
747
+ ----------
748
+ panel_params : dict
749
+ theme : Theme
750
+
751
+ Returns
752
+ -------
753
+ grob
754
+ """
755
+ from grid_py import null_grob
756
+ return null_grob()
757
+
758
+ def render_bg(self, panel_params: Dict[str, Any], theme: Any) -> Any:
759
+ """Render panel background.
760
+
761
+ Parameters
762
+ ----------
763
+ panel_params : dict
764
+ theme : Theme
765
+
766
+ Returns
767
+ -------
768
+ grob
769
+ """
770
+ cli_abort(f"{snake_class(self)} has not implemented render_bg().")
771
+
772
+ def render_axis_h(self, panel_params: Dict[str, Any], theme: Any) -> Dict[str, Any]:
773
+ """Render horizontal axes.
774
+
775
+ Parameters
776
+ ----------
777
+ panel_params : dict
778
+ theme : Theme
779
+
780
+ Returns
781
+ -------
782
+ dict
783
+ ``{"top": grob, "bottom": grob}``.
784
+ """
785
+ from grid_py import null_grob
786
+ return {"top": null_grob(), "bottom": null_grob()}
787
+
788
+ def render_axis_v(self, panel_params: Dict[str, Any], theme: Any) -> Dict[str, Any]:
789
+ """Render vertical axes.
790
+
791
+ Parameters
792
+ ----------
793
+ panel_params : dict
794
+ theme : Theme
795
+
796
+ Returns
797
+ -------
798
+ dict
799
+ ``{"left": grob, "right": grob}``.
800
+ """
801
+ from grid_py import null_grob
802
+ return {"left": null_grob(), "right": null_grob()}
803
+
804
+ def labels(self, labels: Dict[str, Any], panel_params: Dict[str, Any]) -> Dict[str, Any]:
805
+ """Format axis labels.
806
+
807
+ Parameters
808
+ ----------
809
+ labels : dict
810
+ Label structure with ``x`` and ``y`` sub-dicts.
811
+ panel_params : dict
812
+
813
+ Returns
814
+ -------
815
+ dict
816
+ """
817
+ return labels
818
+
819
+ def aspect(self, ranges: Any) -> Optional[float]:
820
+ """Return the aspect ratio for panels.
821
+
822
+ Parameters
823
+ ----------
824
+ ranges : dict
825
+ Panel parameters.
826
+
827
+ Returns
828
+ -------
829
+ float or None
830
+ """
831
+ return None
832
+
833
+ # -- utilities -----------------------------------------------------------
834
+
835
+ def is_linear(self) -> bool:
836
+ """Whether this coordinate system is linear.
837
+
838
+ Returns
839
+ -------
840
+ bool
841
+ """
842
+ return False
843
+
844
+ def is_free(self) -> bool:
845
+ """Whether this coord supports free-scaling in facets.
846
+
847
+ Returns
848
+ -------
849
+ bool
850
+ """
851
+ return False
852
+
853
+
854
+ # ---------------------------------------------------------------------------
855
+ # CoordCartesian
856
+ # ---------------------------------------------------------------------------
857
+
858
+ class CoordCartesian(Coord):
859
+ """Cartesian coordinate system.
860
+
861
+ Attributes
862
+ ----------
863
+ limits : dict
864
+ ``{"x": (lo, hi) or None, "y": (lo, hi) or None}``.
865
+ ratio : float or None
866
+ Aspect ratio ``y/x``.
867
+ """
868
+
869
+ limits: Dict[str, Any] = {"x": None, "y": None}
870
+ ratio: Optional[float] = None
871
+
872
+ def __init__(self, **kwargs: Any) -> None:
873
+ for k, v in kwargs.items():
874
+ setattr(self, k, v)
875
+
876
+ def is_linear(self) -> bool:
877
+ return True
878
+
879
+ def is_free(self) -> bool:
880
+ return self.ratio is None
881
+
882
+ def aspect(self, ranges: Any) -> Optional[float]:
883
+ """Compute aspect ratio from ranges.
884
+
885
+ Parameters
886
+ ----------
887
+ ranges : dict
888
+ Must have ``x.range`` and ``y.range`` or similar.
889
+
890
+ Returns
891
+ -------
892
+ float or None
893
+ """
894
+ if self.ratio is None:
895
+ return None
896
+ y_range = ranges.get("y.range") or ranges.get("y_range", [0, 1])
897
+ x_range = ranges.get("x.range") or ranges.get("x_range", [0, 1])
898
+ return (y_range[1] - y_range[0]) / max(x_range[1] - x_range[0], 1e-10) * self.ratio
899
+
900
+ def distance(
901
+ self,
902
+ x: np.ndarray,
903
+ y: np.ndarray,
904
+ panel_params: Dict[str, Any],
905
+ ) -> np.ndarray:
906
+ """Euclidean distance normalised by the panel diagonal."""
907
+ x_dim = panel_params.get("x_range") or panel_params.get("x.range", [0, 1])
908
+ y_dim = panel_params.get("y_range") or panel_params.get("y.range", [0, 1])
909
+ max_dist = np.sqrt((x_dim[1] - x_dim[0]) ** 2 + (y_dim[1] - y_dim[0]) ** 2)
910
+ if max_dist == 0:
911
+ max_dist = 1.0
912
+ return _dist_euclidean(np.asarray(x), np.asarray(y)) / max_dist
913
+
914
+ def range(self, panel_params: Dict[str, Any]) -> Dict[str, list]:
915
+ x_range = panel_params.get("x_range") or panel_params.get("x.range", [0, 1])
916
+ y_range = panel_params.get("y_range") or panel_params.get("y.range", [0, 1])
917
+ return {"x": list(x_range), "y": list(y_range)}
918
+
919
+ def backtransform_range(self, panel_params: Dict[str, Any]) -> Dict[str, list]:
920
+ return self.range(panel_params)
921
+
922
+ def transform(self, data: pd.DataFrame, panel_params: Dict[str, Any]) -> pd.DataFrame:
923
+ """Rescale x/y into [0, 1].
924
+
925
+ Parameters
926
+ ----------
927
+ data : pd.DataFrame
928
+ panel_params : dict
929
+
930
+ Returns
931
+ -------
932
+ pd.DataFrame
933
+ """
934
+ reverse = panel_params.get("reverse") or getattr(self, "reverse", "none")
935
+ x_range = panel_params.get("x_range") or panel_params.get("x.range", [0, 1])
936
+ y_range = panel_params.get("y_range") or panel_params.get("y.range", [0, 1])
937
+
938
+ def rescale_x(vals: np.ndarray) -> np.ndarray:
939
+ r = x_range
940
+ if reverse in ("x", "xy"):
941
+ r = list(reversed(r))
942
+ return _rescale(vals, to=(0, 1), from_=tuple(r))
943
+
944
+ def rescale_y(vals: np.ndarray) -> np.ndarray:
945
+ r = y_range
946
+ if reverse in ("y", "xy"):
947
+ r = list(reversed(r))
948
+ return _rescale(vals, to=(0, 1), from_=tuple(r))
949
+
950
+ data = _transform_position(data, rescale_x, rescale_y)
951
+ data = _transform_position(data, _squish_infinite, _squish_infinite)
952
+ return data
953
+
954
+ def setup_panel_params(
955
+ self,
956
+ scale_x: Any,
957
+ scale_y: Any,
958
+ params: Optional[Dict[str, Any]] = None,
959
+ ) -> Dict[str, Any]:
960
+ """Build panel parameters from scales.
961
+
962
+ Extracts limits and computes breaks/minor breaks so that
963
+ ``render_bg`` can draw grid lines.
964
+
965
+ Parameters
966
+ ----------
967
+ scale_x, scale_y : Scale
968
+ params : dict
969
+
970
+ Returns
971
+ -------
972
+ dict
973
+ """
974
+ params = params or {}
975
+ x_limits = self.limits.get("x")
976
+ y_limits = self.limits.get("y")
977
+
978
+ x_range = _scale_numeric_range(scale_x, [0, 1])
979
+ y_range = _scale_numeric_range(scale_y, [0, 1])
980
+
981
+ # Apply coord limits as zoom
982
+ if x_limits is not None:
983
+ x_range = list(x_limits)
984
+ if y_limits is not None:
985
+ y_range = list(y_limits)
986
+
987
+ # Compute breaks and rescale to [0, 1] NPC for grid lines
988
+ x_major = _compute_mapped_breaks(scale_x, x_range)
989
+ x_minor = _compute_mapped_minor_breaks(scale_x, x_range, x_major)
990
+ y_major = _compute_mapped_breaks(scale_y, y_range)
991
+ y_minor = _compute_mapped_minor_breaks(scale_y, y_range, y_major)
992
+
993
+ # Break labels for axis rendering
994
+ x_major_pos, x_labels = _compute_break_labels(scale_x, x_range)
995
+ y_major_pos, y_labels = _compute_break_labels(scale_y, y_range)
996
+
997
+ result = {
998
+ "x_range": x_range,
999
+ "y_range": y_range,
1000
+ "x.range": x_range,
1001
+ "y.range": y_range,
1002
+ "x_major": x_major_pos if len(x_major_pos) > 0 else x_major,
1003
+ "x_minor": x_minor,
1004
+ "y_major": y_major_pos if len(y_major_pos) > 0 else y_major,
1005
+ "y_minor": y_minor,
1006
+ "x_labels": x_labels,
1007
+ "y_labels": y_labels,
1008
+ "reverse": getattr(self, "reverse", "none"),
1009
+ }
1010
+
1011
+ # Secondary axes — compute breaks via the AxisSecondary transform
1012
+ for axis, scale, rng in [("x", scale_x, x_range), ("y", scale_y, y_range)]:
1013
+ sec = getattr(scale, "secondary_axis", None)
1014
+ if sec is None or _is_waiver_like(sec):
1015
+ continue
1016
+ trans_fn = getattr(sec, "trans", None)
1017
+ if trans_fn is None:
1018
+ continue
1019
+ try:
1020
+ primary_breaks = np.array([float(b) for b in result[f"{axis}_major"]])
1021
+ # Transform break NPC positions back to data, apply sec trans,
1022
+ # then rescale back to NPC. For dup_axis (identity), this
1023
+ # produces the same positions with (optionally) different labels.
1024
+ data_breaks = primary_breaks * (rng[1] - rng[0]) + rng[0]
1025
+ sec_data = np.array([float(trans_fn(b)) for b in data_breaks])
1026
+ sec_rng = [float(trans_fn(rng[0])), float(trans_fn(rng[1]))]
1027
+ if sec_rng[1] != sec_rng[0]:
1028
+ sec_npc = (sec_data - sec_rng[0]) / (sec_rng[1] - sec_rng[0])
1029
+ else:
1030
+ sec_npc = primary_breaks
1031
+ sec_labels = getattr(sec, "labels", None)
1032
+ if (sec_labels is None or _is_waiver_like(sec_labels)
1033
+ or not hasattr(sec_labels, "__len__")):
1034
+ # derive() / waiver / None → generate from break values
1035
+ sec_labels = [str(round(v, 2)) for v in sec_data]
1036
+ elif callable(sec_labels):
1037
+ sec_labels = sec_labels(sec_data)
1038
+ result[f"{axis}_sec_major"] = sec_npc
1039
+ result[f"{axis}_sec_labels"] = sec_labels
1040
+ except Exception:
1041
+ pass
1042
+
1043
+ return result
1044
+
1045
+ def render_bg(self, panel_params: Dict[str, Any], theme: Any) -> Any:
1046
+ """Render panel background (grid lines, background fill).
1047
+
1048
+ Mirrors R's ``CoordCartesian$render_bg`` which delegates to
1049
+ ``guide_grid()``.
1050
+ """
1051
+ return guide_grid(theme, panel_params, self)
1052
+
1053
+ def render_axis_h(self, panel_params: Dict[str, Any], theme: Any) -> Dict[str, Any]:
1054
+ """Render horizontal axes using the GuideAxis pipeline.
1055
+
1056
+ Mirrors R's ``CoordCartesian$render_axis_h``.
1057
+ """
1058
+ from grid_py import null_grob
1059
+ from ggplot2_py.guide_axis import draw_axis
1060
+
1061
+ breaks = panel_params.get("x_major", np.array([]))
1062
+ labels = panel_params.get("x_labels", [])
1063
+ minor = panel_params.get("x_minor", None)
1064
+
1065
+ bottom = draw_axis(
1066
+ breaks, labels, "bottom", theme,
1067
+ minor_positions=minor,
1068
+ )
1069
+
1070
+ top = null_grob()
1071
+ if panel_params.get("x_sec_major") is not None:
1072
+ sec_labels = panel_params.get("x_sec_labels", [])
1073
+ top = draw_axis(
1074
+ panel_params["x_sec_major"], sec_labels, "top", theme,
1075
+ )
1076
+ return {"top": top, "bottom": bottom}
1077
+
1078
+ def render_axis_v(self, panel_params: Dict[str, Any], theme: Any) -> Dict[str, Any]:
1079
+ """Render vertical axes using the GuideAxis pipeline.
1080
+
1081
+ Mirrors R's ``CoordCartesian$render_axis_v``.
1082
+ """
1083
+ from grid_py import null_grob
1084
+ from ggplot2_py.guide_axis import draw_axis
1085
+
1086
+ breaks = panel_params.get("y_major", np.array([]))
1087
+ labels = panel_params.get("y_labels", [])
1088
+ minor = panel_params.get("y_minor", None)
1089
+
1090
+ left = draw_axis(
1091
+ breaks, labels, "left", theme,
1092
+ minor_positions=minor,
1093
+ )
1094
+
1095
+ right = null_grob()
1096
+ if panel_params.get("y_sec_major") is not None:
1097
+ sec_labels = panel_params.get("y_sec_labels", [])
1098
+ right = draw_axis(
1099
+ panel_params["y_sec_major"], sec_labels, "right", theme,
1100
+ )
1101
+ return {"left": left, "right": right}
1102
+
1103
+
1104
+ def _resolve_element(element_name: str, theme: Any, fallback: dict) -> dict:
1105
+ """Resolve a theme element via calc_element, returning a flat dict.
1106
+
1107
+ Falls back to *fallback* if the element is blank or missing.
1108
+ """
1109
+ from ggplot2_py.theme_elements import calc_element, ElementBlank
1110
+ try:
1111
+ el = calc_element(element_name, theme)
1112
+ except Exception:
1113
+ return dict(fallback)
1114
+ if el is None or isinstance(el, ElementBlank):
1115
+ return dict(fallback)
1116
+ out = dict(fallback)
1117
+ for key in fallback:
1118
+ val = getattr(el, key, None)
1119
+ if val is not None:
1120
+ # Resolve Rel values to float
1121
+ if hasattr(val, "x"): # Rel wrapper
1122
+ val = float(val.x) * fallback.get(key, 1)
1123
+ out[key] = val
1124
+ return out
1125
+
1126
+
1127
+
1128
+ # NOTE: _render_axis has been removed and replaced by guide_axis.draw_axis.
1129
+ # See guide_axis.py and the render_axis_h/render_axis_v methods above.
1130
+
1131
+
1132
+ # ---------------------------------------------------------------------------
1133
+ # CoordFixed
1134
+ # ---------------------------------------------------------------------------
1135
+
1136
+ class CoordFixed(CoordCartesian):
1137
+ """Fixed-ratio Cartesian coordinate system.
1138
+
1139
+ Attributes
1140
+ ----------
1141
+ ratio : float
1142
+ Aspect ratio (y per x unit). Default is 1.
1143
+ """
1144
+
1145
+ ratio: float = 1.0
1146
+
1147
+ def is_free(self) -> bool:
1148
+ return False
1149
+
1150
+ def aspect(self, ranges: Any) -> float:
1151
+ y_range = ranges.get("y.range") or ranges.get("y_range", [0, 1])
1152
+ x_range = ranges.get("x.range") or ranges.get("x_range", [0, 1])
1153
+ return (y_range[1] - y_range[0]) / max(x_range[1] - x_range[0], 1e-10) * self.ratio
1154
+
1155
+
1156
+ # ---------------------------------------------------------------------------
1157
+ # CoordFlip
1158
+ # ---------------------------------------------------------------------------
1159
+
1160
+ class CoordFlip(CoordCartesian):
1161
+ """Flipped Cartesian coordinates (swap x and y)."""
1162
+
1163
+ def transform(self, data: pd.DataFrame, panel_params: Dict[str, Any]) -> pd.DataFrame:
1164
+ data = _flip_axis_labels(data)
1165
+ return super().transform(data, panel_params)
1166
+
1167
+ def backtransform_range(self, panel_params: Dict[str, Any]) -> Dict[str, list]:
1168
+ r = self.range(panel_params)
1169
+ return r
1170
+
1171
+ def range(self, panel_params: Dict[str, Any]) -> Dict[str, list]:
1172
+ un_flipped = super().range(panel_params)
1173
+ return {"x": un_flipped["y"], "y": un_flipped["x"]}
1174
+
1175
+ def setup_panel_params(
1176
+ self,
1177
+ scale_x: Any,
1178
+ scale_y: Any,
1179
+ params: Optional[Dict[str, Any]] = None,
1180
+ ) -> Dict[str, Any]:
1181
+ params = params or {}
1182
+ expand = params.get("expand", [True, True, True, True])
1183
+ if len(expand) >= 4:
1184
+ params["expand"] = [expand[1], expand[0], expand[3], expand[2]]
1185
+ pp = super().setup_panel_params(scale_x, scale_y, params)
1186
+ return _flip_axis_labels(pp) if isinstance(pp, dict) else pp
1187
+
1188
+ def labels(self, labels: Dict[str, Any], panel_params: Dict[str, Any]) -> Dict[str, Any]:
1189
+ return _flip_axis_labels(labels)
1190
+
1191
+ def setup_layout(self, layout: pd.DataFrame, params: Optional[Dict[str, Any]] = None) -> pd.DataFrame:
1192
+ layout = super().setup_layout(layout, params)
1193
+ layout = layout.copy()
1194
+ layout[["SCALE_X", "SCALE_Y"]] = layout[["SCALE_Y", "SCALE_X"]].values
1195
+ return layout
1196
+
1197
+
1198
+ def _flip_axis_labels(x: Any) -> Any:
1199
+ """Swap x/y prefixed names in a dict or DataFrame."""
1200
+ if isinstance(x, dict):
1201
+ new = {}
1202
+ for k, v in x.items():
1203
+ nk = k.replace("x", "__Z__").replace("y", "x").replace("__Z__", "y") if k.startswith(("x", "y")) else k
1204
+ new[nk] = v
1205
+ return new
1206
+ if isinstance(x, pd.DataFrame):
1207
+ rename_map = {}
1208
+ for c in x.columns:
1209
+ if c.startswith("x"):
1210
+ rename_map[c] = "y" + c[1:]
1211
+ elif c.startswith("y"):
1212
+ rename_map[c] = "x" + c[1:]
1213
+ return x.rename(columns=rename_map)
1214
+ return x
1215
+
1216
+
1217
+ # ---------------------------------------------------------------------------
1218
+ # CoordPolar
1219
+ # ---------------------------------------------------------------------------
1220
+
1221
+ class CoordPolar(Coord):
1222
+ """Polar coordinate system.
1223
+
1224
+ Attributes
1225
+ ----------
1226
+ theta : str
1227
+ Which variable to map to angle (``"x"`` or ``"y"``).
1228
+ r : str
1229
+ Which variable to map to radius.
1230
+ start : float
1231
+ Offset from 12 o'clock in radians.
1232
+ direction : int
1233
+ 1 for clockwise, -1 for anticlockwise.
1234
+ """
1235
+
1236
+ theta: str = "x"
1237
+ r: str = "y"
1238
+ start: float = 0.0
1239
+ direction: int = 1
1240
+ limits: Dict[str, Any] = {"x": None, "y": None}
1241
+
1242
+ def __init__(self, **kwargs: Any) -> None:
1243
+ for k, v in kwargs.items():
1244
+ setattr(self, k, v)
1245
+ if self.theta == "x":
1246
+ self.r = "y"
1247
+ else:
1248
+ self.r = "x"
1249
+
1250
+ def aspect(self, ranges: Any) -> float:
1251
+ return 1.0
1252
+
1253
+ def is_free(self) -> bool:
1254
+ return True
1255
+
1256
+ def distance(
1257
+ self,
1258
+ x: np.ndarray,
1259
+ y: np.ndarray,
1260
+ panel_params: Dict[str, Any],
1261
+ boost: float = 0.75,
1262
+ ) -> np.ndarray:
1263
+ arc = (self.start, self.start + 2 * math.pi)
1264
+ if self.theta == "x":
1265
+ r = _rescale(np.asarray(y), from_=tuple(panel_params.get("r.range", [0, 1])))
1266
+ theta = _theta_rescale_no_clip(
1267
+ np.asarray(x),
1268
+ tuple(panel_params.get("theta.range", [0, 1])),
1269
+ arc,
1270
+ self.direction,
1271
+ )
1272
+ else:
1273
+ r = _rescale(np.asarray(x), from_=tuple(panel_params.get("r.range", [0, 1])))
1274
+ theta = _theta_rescale_no_clip(
1275
+ np.asarray(y),
1276
+ tuple(panel_params.get("theta.range", [0, 1])),
1277
+ arc,
1278
+ self.direction,
1279
+ )
1280
+ return _dist_polar(r ** boost, theta)
1281
+
1282
+ def backtransform_range(self, panel_params: Dict[str, Any]) -> Dict[str, list]:
1283
+ return self.range(panel_params)
1284
+
1285
+ def range(self, panel_params: Dict[str, Any]) -> Dict[str, list]:
1286
+ return {
1287
+ self.theta: list(panel_params.get("theta.range", [0, 1])),
1288
+ self.r: list(panel_params.get("r.range", [0, 1])),
1289
+ }
1290
+
1291
+ def setup_panel_params(
1292
+ self,
1293
+ scale_x: Any,
1294
+ scale_y: Any,
1295
+ params: Optional[Dict[str, Any]] = None,
1296
+ ) -> Dict[str, Any]:
1297
+ params = params or {}
1298
+ result: Dict[str, Any] = {}
1299
+ for name, scale in [("x", scale_x), ("y", scale_y)]:
1300
+ limits = self.limits.get(name)
1301
+ rng = _scale_numeric_range(scale, [0, 1])
1302
+ if limits is not None:
1303
+ rng = list(limits)
1304
+
1305
+ is_theta = (self.theta == name)
1306
+ prefix = "theta" if is_theta else "r"
1307
+
1308
+ result[f"{prefix}.range"] = rng
1309
+ if hasattr(scale, "break_info"):
1310
+ info = scale.break_info(rng)
1311
+ result[f"{prefix}.major"] = info.get("major_source")
1312
+ result[f"{prefix}.minor"] = info.get("minor_source")
1313
+ result[f"{prefix}.labels"] = info.get("labels")
1314
+ else:
1315
+ result[f"{prefix}.major"] = None
1316
+ result[f"{prefix}.minor"] = None
1317
+ result[f"{prefix}.labels"] = None
1318
+
1319
+ return result
1320
+
1321
+ def setup_panel_guides(
1322
+ self,
1323
+ panel_params: Dict[str, Any],
1324
+ guides: Any,
1325
+ params: Optional[Dict[str, Any]] = None,
1326
+ ) -> Dict[str, Any]:
1327
+ # CoordPolar cannot render standard guides
1328
+ return panel_params
1329
+
1330
+ def train_panel_guides(
1331
+ self,
1332
+ panel_params: Dict[str, Any],
1333
+ layers: list,
1334
+ params: Optional[Dict[str, Any]] = None,
1335
+ ) -> Dict[str, Any]:
1336
+ return panel_params
1337
+
1338
+ def transform(self, data: pd.DataFrame, panel_params: Dict[str, Any]) -> pd.DataFrame:
1339
+ arc = (self.start, self.start + 2 * math.pi)
1340
+ direction = self.direction
1341
+ data = data.copy()
1342
+
1343
+ # Rename x/y to theta/r based on self.theta
1344
+ if self.theta == "x":
1345
+ theta_col, r_col = "x", "y"
1346
+ else:
1347
+ theta_col, r_col = "y", "x"
1348
+
1349
+ r_range = panel_params.get("r.range", [0, 1])
1350
+ theta_range = panel_params.get("theta.range", [0, 1])
1351
+
1352
+ if r_col in data.columns:
1353
+ data["__r__"] = _r_rescale(data[r_col].values, tuple(r_range))
1354
+ else:
1355
+ data["__r__"] = 0.0
1356
+
1357
+ if theta_col in data.columns:
1358
+ data["__theta__"] = _theta_rescale(
1359
+ data[theta_col].values, tuple(theta_range), arc, direction
1360
+ )
1361
+ else:
1362
+ data["__theta__"] = 0.0
1363
+
1364
+ data["x"] = data["__r__"] * np.sin(data["__theta__"]) + 0.5
1365
+ data["y"] = data["__r__"] * np.cos(data["__theta__"]) + 0.5
1366
+ data.drop(columns=["__r__", "__theta__"], inplace=True, errors="ignore")
1367
+ return data
1368
+
1369
+ def render_bg(self, panel_params: Dict[str, Any], theme: Any) -> Any:
1370
+ return guide_grid(theme, panel_params, self)
1371
+
1372
+ def render_axis_h(self, panel_params: Dict[str, Any], theme: Any) -> Dict[str, Any]:
1373
+ from grid_py import null_grob
1374
+ return {"top": null_grob(), "bottom": null_grob()}
1375
+
1376
+ def render_axis_v(self, panel_params: Dict[str, Any], theme: Any) -> Dict[str, Any]:
1377
+ from grid_py import null_grob
1378
+ return {"left": null_grob(), "right": null_grob()}
1379
+
1380
+ def labels(self, labels: Dict[str, Any], panel_params: Dict[str, Any]) -> Dict[str, Any]:
1381
+ if self.theta == "y":
1382
+ return {"x": labels.get("y", {}), "y": labels.get("x", {})}
1383
+ return labels
1384
+
1385
+
1386
+ # ---------------------------------------------------------------------------
1387
+ # CoordRadial
1388
+ # ---------------------------------------------------------------------------
1389
+
1390
+ class CoordRadial(Coord):
1391
+ """Modern radial (polar) coordinate system.
1392
+
1393
+ Attributes
1394
+ ----------
1395
+ theta : str
1396
+ Angle variable (``"x"`` or ``"y"``).
1397
+ r : str
1398
+ Radius variable.
1399
+ arc : tuple of float
1400
+ Start and end of the arc in radians.
1401
+ r_axis_inside : bool or float or None
1402
+ Whether the r-axis is drawn inside the panel.
1403
+ rotate_angle : bool
1404
+ Whether to transform the ``angle`` aesthetic.
1405
+ inner_radius : tuple of float
1406
+ Inner and outer radius proportions.
1407
+ """
1408
+
1409
+ theta: str = "x"
1410
+ r: str = "y"
1411
+ arc: Tuple[float, float] = (0.0, 2 * math.pi)
1412
+ r_axis_inside: Any = None
1413
+ rotate_angle: bool = False
1414
+ inner_radius: Tuple[float, float] = (0.0, 0.4)
1415
+ limits: Dict[str, Any] = {"theta": None, "r": None}
1416
+
1417
+ def __init__(self, **kwargs: Any) -> None:
1418
+ for k, v in kwargs.items():
1419
+ setattr(self, k, v)
1420
+ if self.theta == "x":
1421
+ self.r = "y"
1422
+ else:
1423
+ self.r = "x"
1424
+
1425
+ def aspect(self, details: Any) -> float:
1426
+ bbox = details.get("bbox", {"x": [0, 1], "y": [0, 1]})
1427
+ dx = bbox["x"][1] - bbox["x"][0]
1428
+ dy = bbox["y"][1] - bbox["y"][0]
1429
+ return dy / max(dx, 1e-10)
1430
+
1431
+ def is_free(self) -> bool:
1432
+ return True
1433
+
1434
+ def distance(
1435
+ self,
1436
+ x: np.ndarray,
1437
+ y: np.ndarray,
1438
+ details: Dict[str, Any],
1439
+ boost: float = 0.75,
1440
+ ) -> np.ndarray:
1441
+ arc = details.get("arc") or self.arc
1442
+ inner = self.inner_radius
1443
+ if self.theta == "x":
1444
+ r = _rescale(np.asarray(y), from_=tuple(details.get("r.range", [0, 1])),
1445
+ to=(inner[0] / 0.4, inner[1] / 0.4))
1446
+ theta = _theta_rescale_no_clip(np.asarray(x), tuple(details.get("theta.range", [0, 1])), arc)
1447
+ else:
1448
+ r = _rescale(np.asarray(x), from_=tuple(details.get("r.range", [0, 1])),
1449
+ to=(inner[0] / 0.4, inner[1] / 0.4))
1450
+ theta = _theta_rescale_no_clip(np.asarray(y), tuple(details.get("theta.range", [0, 1])), arc)
1451
+ return _dist_polar(r ** boost, theta)
1452
+
1453
+ def backtransform_range(self, panel_params: Dict[str, Any]) -> Dict[str, list]:
1454
+ return self.range(panel_params)
1455
+
1456
+ def range(self, panel_params: Dict[str, Any]) -> Dict[str, list]:
1457
+ return {
1458
+ self.theta: list(panel_params.get("theta.range", [0, 1])),
1459
+ self.r: list(panel_params.get("r.range", [0, 1])),
1460
+ }
1461
+
1462
+ def setup_panel_params(
1463
+ self,
1464
+ scale_x: Any,
1465
+ scale_y: Any,
1466
+ params: Optional[Dict[str, Any]] = None,
1467
+ ) -> Dict[str, Any]:
1468
+ params = params or {}
1469
+ result: Dict[str, Any] = {}
1470
+
1471
+ if self.theta == "x":
1472
+ theta_limits = self.limits.get("theta")
1473
+ r_limits = self.limits.get("r")
1474
+ theta_scale, r_scale = scale_x, scale_y
1475
+ else:
1476
+ theta_limits = self.limits.get("theta")
1477
+ r_limits = self.limits.get("r")
1478
+ theta_scale, r_scale = scale_y, scale_x
1479
+
1480
+ # Theta
1481
+ theta_range = _scale_numeric_range(theta_scale, [0, 1])
1482
+ if theta_limits is not None:
1483
+ theta_range = list(theta_limits)
1484
+
1485
+ # R
1486
+ r_range = _scale_numeric_range(r_scale, [0, 1])
1487
+ if r_limits is not None:
1488
+ r_range = list(r_limits)
1489
+
1490
+ result["theta.range"] = theta_range
1491
+ result["r.range"] = r_range
1492
+ result["bbox"] = _polar_bbox(self.arc, inner_radius=self.inner_radius)
1493
+ result["arc"] = self.arc
1494
+ result["inner_radius"] = self.inner_radius
1495
+
1496
+ return result
1497
+
1498
+ def transform(self, data: pd.DataFrame, panel_params: Dict[str, Any]) -> pd.DataFrame:
1499
+ data = data.copy()
1500
+ bbox = panel_params.get("bbox", {"x": [0, 1], "y": [0, 1]})
1501
+ arc = panel_params.get("arc", self.arc)
1502
+ inner_radius = panel_params.get("inner_radius", self.inner_radius)
1503
+
1504
+ if self.theta == "x":
1505
+ theta_col, r_col = "x", "y"
1506
+ else:
1507
+ theta_col, r_col = "y", "x"
1508
+
1509
+ r_range = panel_params.get("r.range", [0, 1])
1510
+ theta_range = panel_params.get("theta.range", [0, 1])
1511
+
1512
+ if r_col in data.columns:
1513
+ data["__r__"] = _r_rescale(data[r_col].values, tuple(r_range), donut=inner_radius)
1514
+ else:
1515
+ data["__r__"] = 0.0
1516
+
1517
+ if theta_col in data.columns:
1518
+ data["__theta__"] = _theta_rescale(
1519
+ data[theta_col].values, tuple(theta_range), arc
1520
+ )
1521
+ else:
1522
+ data["__theta__"] = 0.0
1523
+
1524
+ raw_x = data["__r__"] * np.sin(data["__theta__"]) + 0.5
1525
+ raw_y = data["__r__"] * np.cos(data["__theta__"]) + 0.5
1526
+ data["x"] = _rescale(raw_x, from_=tuple(bbox["x"]))
1527
+ data["y"] = _rescale(raw_y, from_=tuple(bbox["y"]))
1528
+ data.drop(columns=["__r__", "__theta__"], inplace=True, errors="ignore")
1529
+ return data
1530
+
1531
+ def render_bg(self, panel_params: Dict[str, Any], theme: Any) -> Any:
1532
+ return guide_grid(theme, panel_params, self)
1533
+
1534
+ def render_axis_h(self, panel_params: Dict[str, Any], theme: Any) -> Dict[str, Any]:
1535
+ from grid_py import null_grob
1536
+ return {"top": null_grob(), "bottom": null_grob()}
1537
+
1538
+ def render_axis_v(self, panel_params: Dict[str, Any], theme: Any) -> Dict[str, Any]:
1539
+ from grid_py import null_grob
1540
+ return {"left": null_grob(), "right": null_grob()}
1541
+
1542
+ def labels(self, labels: Dict[str, Any], panel_params: Dict[str, Any]) -> Dict[str, Any]:
1543
+ if self.theta == "y":
1544
+ return {"x": labels.get("y", {}), "y": labels.get("x", {})}
1545
+ return labels
1546
+
1547
+
1548
+ def _polar_bbox(
1549
+ arc: Tuple[float, float],
1550
+ margin: Tuple[float, float, float, float] = (0.05, 0.05, 0.05, 0.05),
1551
+ inner_radius: Tuple[float, float] = (0.0, 0.4),
1552
+ ) -> Dict[str, list]:
1553
+ """Compute bounding box for a partial polar chart.
1554
+
1555
+ Parameters
1556
+ ----------
1557
+ arc : tuple of float
1558
+ Start and end angles in radians.
1559
+ margin : tuple
1560
+ Margins (top, right, bottom, left).
1561
+ inner_radius : tuple
1562
+ Inner and outer radii.
1563
+
1564
+ Returns
1565
+ -------
1566
+ dict
1567
+ ``{"x": [xmin, xmax], "y": [ymin, ymax]}``.
1568
+ """
1569
+ if abs(arc[1] - arc[0]) >= 2 * math.pi:
1570
+ return {"x": [0.0, 1.0], "y": [0.0, 1.0]}
1571
+
1572
+ sorted_arc = (min(arc), max(arc))
1573
+ angles = np.array([sorted_arc[0], sorted_arc[1]])
1574
+ x_outer = 0.5 * np.sin(angles) + 0.5
1575
+ y_outer = 0.5 * np.cos(angles) + 0.5
1576
+
1577
+ # Check cardinal directions
1578
+ cardinal = np.array([0, 0.5 * math.pi, math.pi, 1.5 * math.pi])
1579
+ in_sector = _in_arc(cardinal, sorted_arc)
1580
+
1581
+ # top, right, bottom, left extremes
1582
+ bounds = [
1583
+ 1.0 if in_sector[0] else max(float(np.max(y_outer)), 0.5 + margin[0]),
1584
+ 1.0 if in_sector[1] else max(float(np.max(x_outer)), 0.5 + margin[1]),
1585
+ 0.0 if in_sector[2] else min(float(np.min(y_outer)), 0.5 - margin[2]),
1586
+ 0.0 if in_sector[3] else min(float(np.min(x_outer)), 0.5 - margin[3]),
1587
+ ]
1588
+ return {"x": [bounds[3], bounds[1]], "y": [bounds[2], bounds[0]]}
1589
+
1590
+
1591
+ def _in_arc(theta: np.ndarray, arc: Tuple[float, float]) -> np.ndarray:
1592
+ """Test whether angles are inside an arc."""
1593
+ theta = np.asarray(theta)
1594
+ if abs(arc[1] - arc[0]) >= 2 * math.pi - 1e-8:
1595
+ return np.ones(len(theta), dtype=bool)
1596
+ a0 = arc[0] % (2 * math.pi)
1597
+ a1 = arc[1] % (2 * math.pi)
1598
+ if a0 < a1:
1599
+ return (theta >= a0) & (theta <= a1)
1600
+ else:
1601
+ return ~((theta < a0) & (theta > a1))
1602
+
1603
+
1604
+ # ---------------------------------------------------------------------------
1605
+ # CoordTransform
1606
+ # ---------------------------------------------------------------------------
1607
+
1608
+ class CoordTransform(Coord):
1609
+ """Transformed Cartesian coordinate system.
1610
+
1611
+ Applies arbitrary transformations to x and y.
1612
+
1613
+ Attributes
1614
+ ----------
1615
+ trans : dict
1616
+ ``{"x": transform, "y": transform}`` where each transform has
1617
+ ``transform()`` and ``inverse()`` methods.
1618
+ limits : dict
1619
+ Coordinate limits.
1620
+ """
1621
+
1622
+ trans: Dict[str, Any] = {"x": None, "y": None}
1623
+ limits: Dict[str, Any] = {"x": None, "y": None}
1624
+
1625
+ def __init__(self, **kwargs: Any) -> None:
1626
+ for k, v in kwargs.items():
1627
+ setattr(self, k, v)
1628
+
1629
+ def is_free(self) -> bool:
1630
+ return True
1631
+
1632
+ def distance(
1633
+ self,
1634
+ x: np.ndarray,
1635
+ y: np.ndarray,
1636
+ panel_params: Dict[str, Any],
1637
+ ) -> np.ndarray:
1638
+ x_range = panel_params.get("x.range", [0, 1])
1639
+ y_range = panel_params.get("y.range", [0, 1])
1640
+ max_dist = np.sqrt((x_range[1] - x_range[0]) ** 2 + (y_range[1] - y_range[0]) ** 2)
1641
+ if max_dist == 0:
1642
+ max_dist = 1.0
1643
+ tx = self.trans["x"].transform(np.asarray(x)) if self.trans.get("x") else np.asarray(x)
1644
+ ty = self.trans["y"].transform(np.asarray(y)) if self.trans.get("y") else np.asarray(y)
1645
+ return _dist_euclidean(tx, ty) / max_dist
1646
+
1647
+ def backtransform_range(self, panel_params: Dict[str, Any]) -> Dict[str, list]:
1648
+ x_range = panel_params.get("x.range", [0, 1])
1649
+ y_range = panel_params.get("y.range", [0, 1])
1650
+ inv_x = self.trans["x"].inverse(np.array(x_range)) if self.trans.get("x") else x_range
1651
+ inv_y = self.trans["y"].inverse(np.array(y_range)) if self.trans.get("y") else y_range
1652
+ return {"x": list(inv_x), "y": list(inv_y)}
1653
+
1654
+ def range(self, panel_params: Dict[str, Any]) -> Dict[str, list]:
1655
+ return {
1656
+ "x": list(panel_params.get("x.range", [0, 1])),
1657
+ "y": list(panel_params.get("y.range", [0, 1])),
1658
+ }
1659
+
1660
+ def transform(self, data: pd.DataFrame, panel_params: Dict[str, Any]) -> pd.DataFrame:
1661
+ reverse = panel_params.get("reverse") or getattr(self, "reverse", "none")
1662
+ x_range = list(panel_params.get("x.range", [0, 1]))
1663
+ y_range = list(panel_params.get("y.range", [0, 1]))
1664
+
1665
+ if reverse in ("x", "xy"):
1666
+ x_range = list(reversed(x_range))
1667
+ if reverse in ("y", "xy"):
1668
+ y_range = list(reversed(y_range))
1669
+
1670
+ trans_x = self.trans.get("x")
1671
+ trans_y = self.trans.get("y")
1672
+
1673
+ def apply_trans_x(vals: np.ndarray) -> np.ndarray:
1674
+ vals = np.asarray(vals, dtype=float)
1675
+ finite = np.isfinite(vals)
1676
+ if trans_x is not None and np.any(finite):
1677
+ vals[finite] = _rescale(
1678
+ trans_x.transform(vals[finite]),
1679
+ to=(0, 1),
1680
+ from_=tuple(x_range),
1681
+ )
1682
+ else:
1683
+ vals = _rescale(vals, to=(0, 1), from_=tuple(x_range))
1684
+ return vals
1685
+
1686
+ def apply_trans_y(vals: np.ndarray) -> np.ndarray:
1687
+ vals = np.asarray(vals, dtype=float)
1688
+ finite = np.isfinite(vals)
1689
+ if trans_y is not None and np.any(finite):
1690
+ vals[finite] = _rescale(
1691
+ trans_y.transform(vals[finite]),
1692
+ to=(0, 1),
1693
+ from_=tuple(y_range),
1694
+ )
1695
+ else:
1696
+ vals = _rescale(vals, to=(0, 1), from_=tuple(y_range))
1697
+ return vals
1698
+
1699
+ data = _transform_position(data, apply_trans_x, apply_trans_y)
1700
+ data = _transform_position(data, _squish_infinite, _squish_infinite)
1701
+ return data
1702
+
1703
+ def setup_panel_params(
1704
+ self,
1705
+ scale_x: Any,
1706
+ scale_y: Any,
1707
+ params: Optional[Dict[str, Any]] = None,
1708
+ ) -> Dict[str, Any]:
1709
+ params = params or {}
1710
+ x_limits = self.limits.get("x")
1711
+ y_limits = self.limits.get("y")
1712
+
1713
+ x_range = _scale_numeric_range(scale_x, [0, 1])
1714
+ if x_limits is not None:
1715
+ x_range = list(x_limits)
1716
+
1717
+ y_range = _scale_numeric_range(scale_y, [0, 1])
1718
+ if y_limits is not None:
1719
+ y_range = list(y_limits)
1720
+
1721
+ return {
1722
+ "x_range": x_range,
1723
+ "y_range": y_range,
1724
+ "x.range": x_range,
1725
+ "y.range": y_range,
1726
+ "reverse": getattr(self, "reverse", "none"),
1727
+ }
1728
+
1729
+ def render_bg(self, panel_params: Dict[str, Any], theme: Any) -> Any:
1730
+ return guide_grid(theme, panel_params, self)
1731
+
1732
+
1733
+ # Alias for backward compatibility
1734
+ CoordTrans = CoordTransform
1735
+
1736
+
1737
+ # ---------------------------------------------------------------------------
1738
+ # coord_munch
1739
+ # ---------------------------------------------------------------------------
1740
+
1741
+ def coord_munch(
1742
+ coord: Coord,
1743
+ data: pd.DataFrame,
1744
+ range_: Dict[str, Any],
1745
+ n: int = 50,
1746
+ is_closed: bool = False,
1747
+ ) -> pd.DataFrame:
1748
+ """Interpolate path data for non-linear coordinate systems.
1749
+
1750
+ For linear coordinates, the data is returned unchanged (after
1751
+ transformation). For non-linear coordinates, points are
1752
+ interpolated so that straight lines in data space become curves
1753
+ in plot space.
1754
+
1755
+ Parameters
1756
+ ----------
1757
+ coord : Coord
1758
+ Coordinate system.
1759
+ data : pd.DataFrame
1760
+ Data with ``x`` and ``y`` columns (at minimum).
1761
+ range_ : dict
1762
+ Panel parameters / ranges.
1763
+ n : int
1764
+ Maximum number of interpolation points per segment.
1765
+ is_closed : bool
1766
+ Whether the path is closed (polygon).
1767
+
1768
+ Returns
1769
+ -------
1770
+ pd.DataFrame
1771
+ Transformed (and possibly interpolated) data.
1772
+ """
1773
+ if coord.is_linear():
1774
+ return coord.transform(data, range_)
1775
+
1776
+ # For non-linear coords, interpolate
1777
+ if len(data) < 2:
1778
+ return coord.transform(data, range_)
1779
+
1780
+ # Compute distances to determine segment counts
1781
+ x = data["x"].values
1782
+ y = data["y"].values
1783
+ dist = coord.distance(x, y, range_)
1784
+
1785
+ # Interpolate segments that are long
1786
+ if len(dist) == 0:
1787
+ return coord.transform(data, range_)
1788
+
1789
+ # Determine how many points each segment needs
1790
+ max_dist = float(np.nanmax(dist)) if len(dist) > 0 else 0.0
1791
+ if max_dist == 0:
1792
+ return coord.transform(data, range_)
1793
+
1794
+ # Simple approach: subdivide each segment proportionally
1795
+ segments = np.ceil(dist / max_dist * n).astype(int)
1796
+ segments = np.clip(segments, 1, n)
1797
+
1798
+ rows = []
1799
+ for i in range(len(data) - 1):
1800
+ nseg = int(segments[i]) if i < len(segments) else 1
1801
+ row_start = data.iloc[i]
1802
+ row_end = data.iloc[i + 1]
1803
+ for j in range(nseg):
1804
+ t = j / nseg
1805
+ new_row = {}
1806
+ for col in data.columns:
1807
+ v0 = row_start[col]
1808
+ v1 = row_end[col]
1809
+ if isinstance(v0, (int, float, np.integer, np.floating)):
1810
+ new_row[col] = v0 + (v1 - v0) * t
1811
+ else:
1812
+ new_row[col] = v0
1813
+ rows.append(new_row)
1814
+ # Last point
1815
+ rows.append(dict(data.iloc[-1]))
1816
+
1817
+ munched = pd.DataFrame(rows)
1818
+ return coord.transform(munched, range_)
1819
+
1820
+
1821
+ # ---------------------------------------------------------------------------
1822
+ # Constructor functions
1823
+ # ---------------------------------------------------------------------------
1824
+
1825
+ def coord_cartesian(
1826
+ xlim: Optional[Sequence[float]] = None,
1827
+ ylim: Optional[Sequence[float]] = None,
1828
+ expand: Union[bool, List[bool]] = True,
1829
+ default: bool = False,
1830
+ clip: str = "on",
1831
+ reverse: str = "none",
1832
+ ratio: Optional[float] = None,
1833
+ ) -> CoordCartesian:
1834
+ """Create a Cartesian coordinate system.
1835
+
1836
+ Parameters
1837
+ ----------
1838
+ xlim, ylim : sequence of float or None
1839
+ Limits for zooming (does not filter data).
1840
+ expand : bool or list of bool
1841
+ Whether to expand limits to avoid data/axis overlap.
1842
+ default : bool
1843
+ Whether this is the default coord.
1844
+ clip : str
1845
+ Clipping: ``"on"`` or ``"off"``.
1846
+ reverse : str
1847
+ ``"none"``, ``"x"``, ``"y"``, or ``"xy"``.
1848
+ ratio : float or None
1849
+ Fixed aspect ratio.
1850
+
1851
+ Returns
1852
+ -------
1853
+ CoordCartesian
1854
+ """
1855
+ return CoordCartesian(
1856
+ limits={"x": list(xlim) if xlim is not None else None,
1857
+ "y": list(ylim) if ylim is not None else None},
1858
+ expand=expand,
1859
+ default=default,
1860
+ clip=clip,
1861
+ reverse=reverse,
1862
+ ratio=ratio,
1863
+ )
1864
+
1865
+
1866
+ def coord_fixed(ratio: float = 1.0, **kwargs: Any) -> CoordFixed:
1867
+ """Create a fixed-ratio coordinate system.
1868
+
1869
+ Parameters
1870
+ ----------
1871
+ ratio : float
1872
+ Aspect ratio (y/x).
1873
+ **kwargs
1874
+ Passed to :class:`CoordFixed`.
1875
+
1876
+ Returns
1877
+ -------
1878
+ CoordFixed
1879
+ """
1880
+ obj = CoordFixed(ratio=ratio, **kwargs)
1881
+ if "limits" not in kwargs:
1882
+ obj.limits = {
1883
+ "x": kwargs.get("xlim"),
1884
+ "y": kwargs.get("ylim"),
1885
+ }
1886
+ return obj
1887
+
1888
+
1889
+ coord_equal = coord_fixed
1890
+
1891
+
1892
+ def coord_flip(
1893
+ xlim: Optional[Sequence[float]] = None,
1894
+ ylim: Optional[Sequence[float]] = None,
1895
+ expand: Union[bool, List[bool]] = True,
1896
+ clip: str = "on",
1897
+ ) -> CoordFlip:
1898
+ """Create a flipped Cartesian coordinate system.
1899
+
1900
+ Parameters
1901
+ ----------
1902
+ xlim, ylim : sequence of float or None
1903
+ expand : bool or list
1904
+ clip : str
1905
+
1906
+ Returns
1907
+ -------
1908
+ CoordFlip
1909
+ """
1910
+ return CoordFlip(
1911
+ limits={"x": list(xlim) if xlim is not None else None,
1912
+ "y": list(ylim) if ylim is not None else None},
1913
+ expand=expand,
1914
+ clip=clip,
1915
+ )
1916
+
1917
+
1918
+ def coord_polar(
1919
+ theta: str = "x",
1920
+ start: float = 0.0,
1921
+ direction: int = 1,
1922
+ clip: str = "on",
1923
+ ) -> CoordPolar:
1924
+ """Create a polar coordinate system.
1925
+
1926
+ Parameters
1927
+ ----------
1928
+ theta : str
1929
+ ``"x"`` or ``"y"``.
1930
+ start : float
1931
+ Offset from 12 o'clock in radians.
1932
+ direction : int
1933
+ 1 for clockwise, -1 for anticlockwise.
1934
+ clip : str
1935
+
1936
+ Returns
1937
+ -------
1938
+ CoordPolar
1939
+ """
1940
+ if theta not in ("x", "y"):
1941
+ cli_abort("theta must be 'x' or 'y'.")
1942
+ return CoordPolar(
1943
+ theta=theta,
1944
+ start=start,
1945
+ direction=int(np.sign(direction)),
1946
+ clip=clip,
1947
+ )
1948
+
1949
+
1950
+ def coord_radial(
1951
+ theta: str = "x",
1952
+ start: float = 0.0,
1953
+ end: Optional[float] = None,
1954
+ thetalim: Optional[Sequence[float]] = None,
1955
+ rlim: Optional[Sequence[float]] = None,
1956
+ expand: Union[bool, List[bool]] = True,
1957
+ clip: str = "off",
1958
+ r_axis_inside: Any = None,
1959
+ rotate_angle: bool = False,
1960
+ inner_radius: float = 0.0,
1961
+ reverse: str = "none",
1962
+ ) -> CoordRadial:
1963
+ """Create a radial coordinate system.
1964
+
1965
+ Parameters
1966
+ ----------
1967
+ theta : str
1968
+ ``"x"`` or ``"y"``.
1969
+ start : float
1970
+ Start angle in radians.
1971
+ end : float or None
1972
+ End angle. Defaults to ``start + 2*pi``.
1973
+ thetalim, rlim : sequence or None
1974
+ Limits for theta and r.
1975
+ expand : bool or list
1976
+ clip : str
1977
+ r_axis_inside : bool, float, or None
1978
+ rotate_angle : bool
1979
+ inner_radius : float
1980
+ Between 0 and 1.
1981
+ reverse : str
1982
+ ``"none"``, ``"theta"``, ``"r"``, or ``"thetar"``.
1983
+
1984
+ Returns
1985
+ -------
1986
+ CoordRadial
1987
+ """
1988
+ if theta not in ("x", "y"):
1989
+ cli_abort("theta must be 'x' or 'y'.")
1990
+ if reverse not in ("none", "theta", "r", "thetar"):
1991
+ cli_abort("reverse must be 'none', 'theta', 'r', or 'thetar'.")
1992
+
1993
+ arc_end = end if end is not None else (start + 2 * math.pi)
1994
+ arc = (start, arc_end)
1995
+
1996
+ if arc[0] > arc[1]:
1997
+ n_rot = int((arc[0] - arc[1]) // (2 * math.pi)) + 1
1998
+ arc = (arc[0] - n_rot * 2 * math.pi, arc[1])
1999
+
2000
+ if reverse in ("theta", "thetar"):
2001
+ arc = (arc[1], arc[0])
2002
+
2003
+ inner = (inner_radius, 1.0)
2004
+ inner = (inner[0] * 0.4, inner[1] * 0.4)
2005
+ if reverse in ("r", "thetar"):
2006
+ inner = (inner[1], inner[0])
2007
+
2008
+ return CoordRadial(
2009
+ theta=theta,
2010
+ arc=arc,
2011
+ limits={"theta": list(thetalim) if thetalim is not None else None,
2012
+ "r": list(rlim) if rlim is not None else None},
2013
+ expand=expand,
2014
+ clip=clip,
2015
+ r_axis_inside=r_axis_inside,
2016
+ rotate_angle=rotate_angle,
2017
+ inner_radius=inner,
2018
+ reverse=reverse,
2019
+ )
2020
+
2021
+
2022
+ def coord_transform(
2023
+ x: Any = "identity",
2024
+ y: Any = "identity",
2025
+ xlim: Optional[Sequence[float]] = None,
2026
+ ylim: Optional[Sequence[float]] = None,
2027
+ clip: str = "on",
2028
+ expand: Union[bool, List[bool]] = True,
2029
+ reverse: str = "none",
2030
+ ) -> CoordTransform:
2031
+ """Create a transformed coordinate system.
2032
+
2033
+ Parameters
2034
+ ----------
2035
+ x, y : str or transform
2036
+ Transformations for x and y.
2037
+ xlim, ylim : sequence or None
2038
+ clip : str
2039
+ expand : bool or list
2040
+ reverse : str
2041
+
2042
+ Returns
2043
+ -------
2044
+ CoordTransform
2045
+ """
2046
+ from scales import as_transform
2047
+
2048
+ if isinstance(x, str):
2049
+ x = as_transform(x)
2050
+ if isinstance(y, str):
2051
+ y = as_transform(y)
2052
+
2053
+ return CoordTransform(
2054
+ trans={"x": x, "y": y},
2055
+ limits={"x": list(xlim) if xlim is not None else None,
2056
+ "y": list(ylim) if ylim is not None else None},
2057
+ expand=expand,
2058
+ reverse=reverse,
2059
+ clip=clip,
2060
+ )
2061
+
2062
+
2063
+ def coord_trans(**kwargs: Any) -> CoordTransform:
2064
+ """Deprecated alias for :func:`coord_transform`.
2065
+
2066
+ Parameters
2067
+ ----------
2068
+ **kwargs
2069
+ Passed to :func:`coord_transform`.
2070
+
2071
+ Returns
2072
+ -------
2073
+ CoordTransform
2074
+ """
2075
+ cli_warn("coord_trans() is deprecated; use coord_transform().")
2076
+ return coord_transform(**kwargs)
2077
+
2078
+
2079
+ # ---------------------------------------------------------------------------
2080
+ # Predicates
2081
+ # ---------------------------------------------------------------------------
2082
+
2083
+ def is_coord(x: Any) -> bool:
2084
+ """Test whether *x* is a Coord.
2085
+
2086
+ Parameters
2087
+ ----------
2088
+ x : object
2089
+
2090
+ Returns
2091
+ -------
2092
+ bool
2093
+ """
2094
+ return isinstance(x, Coord)
2095
+
2096
+
2097
+ def is_Coord(x: Any) -> bool:
2098
+ """Deprecated alias for :func:`is_coord`.
2099
+
2100
+ Parameters
2101
+ ----------
2102
+ x : object
2103
+
2104
+ Returns
2105
+ -------
2106
+ bool
2107
+ """
2108
+ return is_coord(x)