ggplot2-python 4.0.2.9000__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ggplot2_py/__init__.py +852 -0
- ggplot2_py/_compat.py +475 -0
- ggplot2_py/_plugins.py +129 -0
- ggplot2_py/_utils.py +544 -0
- ggplot2_py/aes.py +586 -0
- ggplot2_py/annotation.py +540 -0
- ggplot2_py/coord.py +2108 -0
- ggplot2_py/coords/__init__.py +49 -0
- ggplot2_py/datasets.py +265 -0
- ggplot2_py/draw_key.py +454 -0
- ggplot2_py/facet.py +1456 -0
- ggplot2_py/fortify.py +95 -0
- ggplot2_py/geom.py +4516 -0
- ggplot2_py/geoms/__init__.py +12 -0
- ggplot2_py/ggproto.py +279 -0
- ggplot2_py/guide.py +2925 -0
- ggplot2_py/guide_axis.py +615 -0
- ggplot2_py/guide_colourbar.py +657 -0
- ggplot2_py/guide_legend.py +1061 -0
- ggplot2_py/guides/__init__.py +8 -0
- ggplot2_py/labeller.py +296 -0
- ggplot2_py/labels.py +309 -0
- ggplot2_py/layer.py +954 -0
- ggplot2_py/layout.py +754 -0
- ggplot2_py/limits.py +314 -0
- ggplot2_py/plot.py +1401 -0
- ggplot2_py/plot_render.py +866 -0
- ggplot2_py/position.py +1269 -0
- ggplot2_py/protocols.py +171 -0
- ggplot2_py/py.typed +0 -0
- ggplot2_py/qplot.py +233 -0
- ggplot2_py/resources/diamonds.csv +53941 -0
- ggplot2_py/resources/economics.csv +575 -0
- ggplot2_py/resources/economics_long.csv +2871 -0
- ggplot2_py/resources/faithfuld.csv +5626 -0
- ggplot2_py/resources/luv_colours.csv +658 -0
- ggplot2_py/resources/midwest.csv +438 -0
- ggplot2_py/resources/mpg.csv +235 -0
- ggplot2_py/resources/msleep.csv +84 -0
- ggplot2_py/resources/presidential.csv +13 -0
- ggplot2_py/resources/seals.csv +1156 -0
- ggplot2_py/resources/txhousing.csv +8603 -0
- ggplot2_py/save.py +316 -0
- ggplot2_py/scale.py +2727 -0
- ggplot2_py/scales/__init__.py +4252 -0
- ggplot2_py/stat.py +6071 -0
- ggplot2_py/stats/__init__.py +9 -0
- ggplot2_py/theme.py +490 -0
- ggplot2_py/theme_defaults.py +1350 -0
- ggplot2_py/theme_elements.py +2052 -0
- ggplot2_python-4.0.2.9000.dist-info/METADATA +179 -0
- ggplot2_python-4.0.2.9000.dist-info/RECORD +54 -0
- ggplot2_python-4.0.2.9000.dist-info/WHEEL +4 -0
- ggplot2_python-4.0.2.9000.dist-info/licenses/LICENSE +3 -0
ggplot2_py/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)
|