ggh4x-python 0.3.1.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 (64) hide show
  1. ggh4x/__init__.py +140 -0
  2. ggh4x/_aimed_text_grob.py +432 -0
  3. ggh4x/_borrowed_ggplot2.py +273 -0
  4. ggh4x/_cli.py +84 -0
  5. ggh4x/_datasets.py +106 -0
  6. ggh4x/_download.py +111 -0
  7. ggh4x/_facet_helpers.py +313 -0
  8. ggh4x/_facet_utils.py +649 -0
  9. ggh4x/_gap_grobs.py +606 -0
  10. ggh4x/_registry.py +10 -0
  11. ggh4x/_rlang.py +93 -0
  12. ggh4x/_utils.py +150 -0
  13. ggh4x/_vctrs.py +233 -0
  14. ggh4x/conveniences.py +601 -0
  15. ggh4x/coord_axes_inside.py +380 -0
  16. ggh4x/element_part_rect.py +545 -0
  17. ggh4x/facet_grid2.py +1018 -0
  18. ggh4x/facet_manual.py +901 -0
  19. ggh4x/facet_nested.py +776 -0
  20. ggh4x/facet_nested_wrap.py +193 -0
  21. ggh4x/facet_wrap2.py +896 -0
  22. ggh4x/geom_box.py +536 -0
  23. ggh4x/geom_outline_point.py +444 -0
  24. ggh4x/geom_pointpath.py +259 -0
  25. ggh4x/geom_polygonraster.py +252 -0
  26. ggh4x/geom_rectrug.py +489 -0
  27. ggh4x/geom_text_aimed.py +279 -0
  28. ggh4x/guide_stringlegend.py +354 -0
  29. ggh4x/help_secondary.py +549 -0
  30. ggh4x/multiscale/__init__.py +51 -0
  31. ggh4x/multiscale/_multiscale_add.py +207 -0
  32. ggh4x/multiscale/scale_listed.py +167 -0
  33. ggh4x/multiscale/scale_manual.py +478 -0
  34. ggh4x/multiscale/scale_multi.py +393 -0
  35. ggh4x/panel_scales/__init__.py +58 -0
  36. ggh4x/panel_scales/at_panel.py +115 -0
  37. ggh4x/panel_scales/facetted_pos_scales.py +647 -0
  38. ggh4x/panel_scales/force_panelsize.py +411 -0
  39. ggh4x/panel_scales/scale_facet.py +222 -0
  40. ggh4x/position_disjoint_ranges.py +229 -0
  41. ggh4x/position_lineartrans.py +242 -0
  42. ggh4x/py.typed +0 -0
  43. ggh4x/resources/faithful.csv +273 -0
  44. ggh4x/resources/iris.csv +151 -0
  45. ggh4x/resources/mtcars.csv +33 -0
  46. ggh4x/resources/pressure.csv +20 -0
  47. ggh4x/resources/volcano.csv +87 -0
  48. ggh4x/save.py +255 -0
  49. ggh4x/stat_difference.py +388 -0
  50. ggh4x/stat_funxy.py +436 -0
  51. ggh4x/stat_rle.py +290 -0
  52. ggh4x/stat_rollingkernel.py +369 -0
  53. ggh4x/stat_theodensity.py +681 -0
  54. ggh4x/strip_nested.py +448 -0
  55. ggh4x/strip_split.py +687 -0
  56. ggh4x/strip_tag.py +636 -0
  57. ggh4x/strip_themed.py +232 -0
  58. ggh4x/strip_vanilla.py +1464 -0
  59. ggh4x/themes.py +31 -0
  60. ggh4x/themes_ggh4x.py +67 -0
  61. ggh4x_python-0.3.1.9000.dist-info/METADATA +40 -0
  62. ggh4x_python-0.3.1.9000.dist-info/RECORD +64 -0
  63. ggh4x_python-0.3.1.9000.dist-info/WHEEL +4 -0
  64. ggh4x_python-0.3.1.9000.dist-info/licenses/LICENSE +3 -0
@@ -0,0 +1,549 @@
1
+ """Secondary axis helper.
2
+
3
+ Python port of the ggh4x R source file ``help_secondary.R``.
4
+
5
+ The public entry point :func:`help_secondary` constructs a
6
+ :class:`ggplot2_py.scale.AxisSecondary` whose reverse transformation
7
+ (``trans``) is fitted from data, and which carries an extra ``proj``
8
+ attribute (a forward-projection callable) used to map secondary data onto
9
+ the primary axis when building a plot.
10
+
11
+ R uses non-standard evaluation (``rlang::enquo``/``eval_tidy``/``as_label``)
12
+ so that users can write ``help_secondary(df, y1, y2)`` with bare column
13
+ symbols. Python has no NSE, so this port accepts either array-likes
14
+ (``numpy``/``pandas``/sequences) **or** column-name strings that are
15
+ resolved against the ``data`` argument (a :class:`pandas.DataFrame`).
16
+
17
+ Five fitting ``method`` choices are implemented, each mirroring the
18
+ corresponding ``help_sec_*`` helper in the R source:
19
+
20
+ ``"range"``
21
+ Overlap the full ranges of primary and secondary data
22
+ (``scales::rescale``).
23
+ ``"max"``
24
+ Make the maxima coincide (``scales::rescale_max``).
25
+ ``"fit"``
26
+ Use the coefficients of ``lm(primary ~ secondary)``
27
+ (ported via :func:`numpy.polyfit`).
28
+ ``"ccf"``
29
+ Align series by the lag of maximum cross-correlation
30
+ (a faithful re-implementation of ``stats::ccf``) then apply ``"fit"``.
31
+ ``"sortfit"``
32
+ Independently sort both inputs then apply ``"fit"``.
33
+
34
+ R source reference: ``help_secondary.R`` (functions ``help_secondary``,
35
+ ``new_sec_axis``, ``help_sec_range``, ``help_sec_max``, ``help_sec_fit``,
36
+ ``help_sec_ccf``, ``help_sec_sortfit``).
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ from typing import Any, Callable, Dict, Optional, Sequence, Tuple, Union
42
+
43
+ import numpy as np
44
+
45
+ try: # pragma: no cover - pandas is part of the runtime env
46
+ import pandas as pd
47
+ except ImportError: # pragma: no cover
48
+ pd = None # type: ignore
49
+
50
+ from ggplot2_py.scale import sec_axis, AxisSecondary
51
+ from ggplot2_py._compat import is_waiver
52
+
53
+ import scales
54
+
55
+ from ._cli import cli_abort
56
+ from ._rlang import arg_match0
57
+
58
+ __all__ = [
59
+ "help_secondary",
60
+ "_new_sec_axis",
61
+ "_help_sec_range",
62
+ "_help_sec_max",
63
+ "_help_sec_fit",
64
+ "_help_sec_ccf",
65
+ "_help_sec_sortfit",
66
+ "_help_sec_ccf_acf",
67
+ ]
68
+
69
+ # Allowed ``method`` choices, in R's declaration order.
70
+ _METHODS: Tuple[str, ...] = ("range", "max", "fit", "ccf", "sortfit")
71
+
72
+ ArrayLike = Union[Sequence[float], np.ndarray, "pd.Series"]
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Input resolution (replaces R's NSE eval_tidy / as_label)
77
+ # ---------------------------------------------------------------------------
78
+
79
+ def _resolve(value: Any, data: Any) -> Tuple[np.ndarray, Optional[str]]:
80
+ """Resolve a ``primary``/``secondary`` argument to a numeric array.
81
+
82
+ Mirrors R's ``eval_tidy(enquo(x), data)`` plus ``as_label(x)`` for the
83
+ default axis title. Because Python has no bare-symbol capture, a string
84
+ is treated as a column name looked up in ``data``; anything else is
85
+ coerced to a numeric :class:`numpy.ndarray`.
86
+
87
+ Parameters
88
+ ----------
89
+ value : Any
90
+ A column-name string, a :class:`pandas.Series`, or any array-like /
91
+ scalar sequence of numbers.
92
+ data : Any
93
+ A :class:`pandas.DataFrame` (or mapping) used to resolve string
94
+ column names. May be ``None``.
95
+
96
+ Returns
97
+ -------
98
+ arr : numpy.ndarray
99
+ The resolved values as a 1-D float array.
100
+ name : str or None
101
+ A label for the values (column name or ``Series.name``), used as the
102
+ default secondary-axis title. ``None`` when no label is available.
103
+ """
104
+ name: Optional[str] = None
105
+
106
+ if isinstance(value, str):
107
+ # Column-name string: resolve against ``data`` (R eval_tidy).
108
+ name = value
109
+ if data is None:
110
+ cli_abort(
111
+ "Cannot resolve column {.val %s}: `data` is `None`." % value
112
+ )
113
+ try:
114
+ col = data[value]
115
+ except Exception:
116
+ cli_abort("Column {.val %s} not found in `data`." % value)
117
+ arr = np.asarray(getattr(col, "to_numpy", lambda: col)(), dtype=float)
118
+ return np.atleast_1d(arr), name
119
+
120
+ if pd is not None and isinstance(value, pd.Series):
121
+ nm = value.name
122
+ name = None if nm is None else str(nm)
123
+ return np.atleast_1d(np.asarray(value.to_numpy(), dtype=float)), name
124
+
125
+ # Plain array-like / sequence / scalar.
126
+ arr = np.atleast_1d(np.asarray(value, dtype=float))
127
+ return arr, name
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Public constructor
132
+ # ---------------------------------------------------------------------------
133
+
134
+ def help_secondary(
135
+ data: Any = None,
136
+ primary: ArrayLike = (0, 1),
137
+ secondary: ArrayLike = (0, 1),
138
+ method: str = "range",
139
+ na_rm: bool = True,
140
+ **kwargs: Any,
141
+ ) -> AxisSecondary:
142
+ """Construct a secondary axis with a fitted projection.
143
+
144
+ Python port of R ``help_secondary`` (``help_secondary.R`` L70-106).
145
+
146
+ The intent is to call this **before** building a plot. The returned
147
+ :class:`~ggplot2_py.scale.AxisSecondary` has its ``trans`` populated by a
148
+ fitted reverse transformation and carries an extra ``proj`` attribute --
149
+ a forward-projection callable that maps secondary data onto the primary
150
+ axis (used as an aesthetic value, e.g. ``aes(y = sec.proj(psavert))``).
151
+
152
+ Parameters
153
+ ----------
154
+ data : pandas.DataFrame or mapping or None, optional
155
+ Data used to resolve ``primary``/``secondary`` when they are given as
156
+ column-name strings. Mirrors the ``data`` argument of the R
157
+ function. Default ``None``.
158
+ primary, secondary : str or array_like, optional
159
+ The primary and secondary values. Either array-likes (numpy /
160
+ pandas / sequences) or column-name strings resolved against ``data``.
161
+ Replaces R's bare-symbol NSE expressions. Default ``(0, 1)``.
162
+ method : {'range', 'max', 'fit', 'ccf', 'sortfit'}, optional
163
+ Fitting strategy (see module docstring). Default ``'range'``.
164
+ na_rm : bool, optional
165
+ Whether to remove missing values (``True``) or propagate them
166
+ (``False``). Applies to ``method='range'`` and ``method='max'``.
167
+ Default ``True``.
168
+ **kwargs
169
+ Forwarded to :func:`ggplot2_py.scale.sec_axis` (``name``, ``breaks``,
170
+ ``labels``, ``guide``). Mirrors ``@inheritDotParams
171
+ ggplot2::sec_axis -trans``.
172
+
173
+ Returns
174
+ -------
175
+ ggplot2_py.scale.AxisSecondary
176
+ The secondary axis, with an added ``proj`` callable attribute and,
177
+ when no ``name`` was supplied, ``name`` defaulted to the secondary
178
+ label.
179
+
180
+ See Also
181
+ --------
182
+ ggplot2_py.scale.sec_axis
183
+ """
184
+ method = arg_match0(method, _METHODS, arg_name="method")
185
+
186
+ primary_vals, _ = _resolve(primary, data)
187
+ secondary_vals, sec_name = _resolve(secondary, data)
188
+
189
+ # ``name = as_label(secondary)`` (R L82). The label is derived during
190
+ # resolution; fall back to the original string repr if unavailable.
191
+ name = sec_name
192
+ if name is None and isinstance(secondary, str):
193
+ name = secondary
194
+
195
+ if method == "range":
196
+ help_ = _help_sec_range(primary_vals, secondary_vals, na_rm=na_rm)
197
+ elif method == "max":
198
+ help_ = _help_sec_max(primary_vals, secondary_vals, na_rm=na_rm)
199
+ elif method == "fit":
200
+ help_ = _help_sec_fit(primary_vals, secondary_vals)
201
+ elif method == "ccf":
202
+ help_ = _help_sec_ccf(primary_vals, secondary_vals)
203
+ else: # "sortfit"
204
+ help_ = _help_sec_sortfit(primary_vals, secondary_vals)
205
+
206
+ # R: ggproto(NULL, new_sec_axis(trans = help$reverse, ...), proj = help$forward)
207
+ out = _new_sec_axis(trans=help_["reverse"], **kwargs)
208
+ # Attach the forward projection. AxisSecondary is a plain class, so this
209
+ # extra attribute is safe (R stores it as the ggproto `proj` member).
210
+ out.proj = help_["forward"]
211
+
212
+ # R: if (inherits(out$name, "waiver")) out$name <- name
213
+ if is_waiver(out.name) and name is not None:
214
+ out.name = name
215
+
216
+ return out
217
+
218
+
219
+ def _new_sec_axis(trans: Optional[Callable] = None, **kwargs: Any) -> AxisSecondary:
220
+ """Bridge ``trans``/``transform`` to :func:`ggplot2_py.scale.sec_axis`.
221
+
222
+ Python port of R ``new_sec_axis`` (``help_secondary.R`` L109-115), which
223
+ renames ``trans`` to ``transform`` for ggplot2 >= 3.5.0. ``sec_axis`` in
224
+ ggplot2_py already accepts both ``transform`` and ``trans``; passing
225
+ ``transform`` avoids the deprecation warning emitted for ``trans``.
226
+
227
+ Parameters
228
+ ----------
229
+ trans : callable, optional
230
+ The reverse transformation function.
231
+ **kwargs
232
+ Additional ``sec_axis`` arguments (``name``, ``breaks``, ``labels``,
233
+ ``guide``).
234
+
235
+ Returns
236
+ -------
237
+ ggplot2_py.scale.AxisSecondary
238
+ """
239
+ return sec_axis(transform=trans, **kwargs)
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # Range helpers
244
+ # ---------------------------------------------------------------------------
245
+
246
+ def _range(x: np.ndarray, na_rm: bool) -> Tuple[float, float]:
247
+ """Compute ``base::range(x, na.rm=na_rm)``.
248
+
249
+ Parameters
250
+ ----------
251
+ x : numpy.ndarray
252
+ Numeric values.
253
+ na_rm : bool
254
+ If ``True``, ignore NaN (``nanmin``/``nanmax``); if ``False``,
255
+ propagate NaN so the result is ``(nan, nan)`` when any value is NaN
256
+ (matching R's ``range(x, na.rm = FALSE)``).
257
+
258
+ Returns
259
+ -------
260
+ tuple of float
261
+ ``(min, max)``.
262
+ """
263
+ x = np.asarray(x, dtype=float)
264
+ if na_rm:
265
+ return float(np.nanmin(x)), float(np.nanmax(x))
266
+ return float(np.min(x)), float(np.max(x))
267
+
268
+
269
+ def _help_sec_range(
270
+ from_: np.ndarray, to: np.ndarray, na_rm: bool = True
271
+ ) -> Dict[str, Callable]:
272
+ """Range-overlap projection.
273
+
274
+ Python port of R ``help_sec_range`` (``help_secondary.R`` L119-130).
275
+
276
+ Parameters
277
+ ----------
278
+ from_ : numpy.ndarray
279
+ Primary values.
280
+ to : numpy.ndarray
281
+ Secondary values.
282
+ na_rm : bool, optional
283
+ Passed to :func:`_range`. Default ``True``.
284
+
285
+ Returns
286
+ -------
287
+ dict
288
+ ``{'forward': callable, 'reverse': callable}`` where ``forward`` maps
289
+ secondary -> primary and ``reverse`` maps primary -> secondary, both
290
+ via :func:`scales.rescale`.
291
+ """
292
+ from_rng = _range(from_, na_rm=na_rm)
293
+ to_rng = _range(to, na_rm=na_rm)
294
+
295
+ def forward(x: ArrayLike) -> np.ndarray:
296
+ return scales.rescale(_num(x), to=from_rng, from_range=to_rng)
297
+
298
+ def reverse(x: ArrayLike) -> np.ndarray:
299
+ return scales.rescale(_num(x), to=to_rng, from_range=from_rng)
300
+
301
+ return {"forward": forward, "reverse": reverse}
302
+
303
+
304
+ def _help_sec_max(
305
+ from_: np.ndarray, to: np.ndarray, na_rm: bool = True
306
+ ) -> Dict[str, Callable]:
307
+ """Maxima-coincidence projection.
308
+
309
+ Python port of R ``help_sec_max`` (``help_secondary.R`` L132-143).
310
+
311
+ Parameters
312
+ ----------
313
+ from_ : numpy.ndarray
314
+ Primary values.
315
+ to : numpy.ndarray
316
+ Secondary values.
317
+ na_rm : bool, optional
318
+ Passed to :func:`_range`. Default ``True``.
319
+
320
+ Returns
321
+ -------
322
+ dict
323
+ ``{'forward': callable, 'reverse': callable}`` via
324
+ :func:`scales.rescale_max`.
325
+ """
326
+ from_rng = _range(from_, na_rm=na_rm)
327
+ to_rng = _range(to, na_rm=na_rm)
328
+
329
+ def forward(x: ArrayLike) -> np.ndarray:
330
+ return scales.rescale_max(_num(x), to=from_rng, from_range=to_rng)
331
+
332
+ def reverse(x: ArrayLike) -> np.ndarray:
333
+ return scales.rescale_max(_num(x), to=to_rng, from_range=from_rng)
334
+
335
+ return {"forward": forward, "reverse": reverse}
336
+
337
+
338
+ # ---------------------------------------------------------------------------
339
+ # Linear-fit helpers
340
+ # ---------------------------------------------------------------------------
341
+
342
+ def _help_sec_fit(from_: np.ndarray, to: np.ndarray) -> Dict[str, Callable]:
343
+ """Linear-model projection.
344
+
345
+ Python port of R ``help_sec_fit`` (``help_secondary.R`` L145-159).
346
+ Uses ``coef(lm(from ~ to))``; the port computes the same coefficients
347
+ with :func:`numpy.polyfit` on ``(to, from_)``. Like R's ``lm`` (whose
348
+ default ``na.action`` is ``na.omit``), pairs containing NaN in either
349
+ series are dropped before fitting.
350
+
351
+ Parameters
352
+ ----------
353
+ from_ : numpy.ndarray
354
+ Primary values (the response in ``lm``).
355
+ to : numpy.ndarray
356
+ Secondary values (the predictor in ``lm``).
357
+
358
+ Returns
359
+ -------
360
+ dict
361
+ ``{'forward': callable, 'reverse': callable}``. ``forward(x) =
362
+ intercept + x * slope`` (secondary -> primary); ``reverse(x) =
363
+ (x - intercept) / slope`` (primary -> secondary).
364
+
365
+ Raises
366
+ ------
367
+ ValueError
368
+ If ``from_`` and ``to`` have unequal length (via :func:`cli_abort`).
369
+ """
370
+ from_ = np.asarray(from_, dtype=float)
371
+ to = np.asarray(to, dtype=float)
372
+ if from_.shape[0] != to.shape[0]:
373
+ cli_abort("The primary and secondary values must have the same length.")
374
+
375
+ # R lm() drops rows where either variable is NA (na.action = na.omit).
376
+ mask = ~(np.isnan(from_) | np.isnan(to))
377
+ f_fit = from_[mask]
378
+ t_fit = to[mask]
379
+
380
+ # coef(lm(from ~ to)) = c(intercept, slope).
381
+ # numpy.polyfit(to, from, 1) returns [slope, intercept]; reverse the order.
382
+ poly = np.polyfit(t_fit, f_fit, 1)
383
+ slope = float(poly[0])
384
+ intercept = float(poly[1])
385
+
386
+ def forward(x: ArrayLike) -> np.ndarray:
387
+ return intercept + _num(x) * slope
388
+
389
+ def reverse(x: ArrayLike) -> np.ndarray:
390
+ return (_num(x) - intercept) / slope
391
+
392
+ return {"forward": forward, "reverse": reverse}
393
+
394
+
395
+ def _help_sec_ccf_acf(from_: np.ndarray, to: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
396
+ """Cross-correlation function, faithful to ``stats::ccf``.
397
+
398
+ Reproduces R's ``ccf(from, to, lag.max = n - 1, plot = FALSE)`` exactly.
399
+ R de-means both series, then for lag ``k`` correlates ``from[t + k]`` with
400
+ ``to[t]``, dividing the cross-covariance by ``n`` and by the product of
401
+ the two series' sample standard deviations (each computed with a ``1/n``
402
+ divisor). Lags run from ``-(n - 1)`` to ``(n - 1)``.
403
+
404
+ Parameters
405
+ ----------
406
+ from_ : numpy.ndarray
407
+ First series (``x``).
408
+ to : numpy.ndarray
409
+ Second series (``y``). Must have the same length as ``from_``.
410
+
411
+ Returns
412
+ -------
413
+ lags : numpy.ndarray
414
+ Integer lags from ``-(n - 1)`` to ``(n - 1)``.
415
+ acf : numpy.ndarray
416
+ The cross-correlation at each lag, matching ``ccf$acf``.
417
+ """
418
+ from_ = np.asarray(from_, dtype=float)
419
+ to = np.asarray(to, dtype=float)
420
+ n = from_.shape[0]
421
+
422
+ xc = from_ - np.mean(from_)
423
+ yc = to - np.mean(to)
424
+
425
+ # Denominator: sqrt( (sum(xc^2)/n) * (sum(yc^2)/n) ), i.e. product of the
426
+ # 1/n-normalised sample standard deviations of each (de-meaned) series.
427
+ denom = np.sqrt((np.sum(xc ** 2) / n) * (np.sum(yc ** 2) / n))
428
+
429
+ # np.correlate(xc, yc, 'full')[i] == sum_t xc[t + (i - (n-1))] * yc[t],
430
+ # which is exactly R's lag convention. Length is 2n - 1.
431
+ cross = np.correlate(xc, yc, mode="full") / n
432
+ acf = cross / denom
433
+ lags = np.arange(-(n - 1), n)
434
+ return lags, acf
435
+
436
+
437
+ def _help_sec_ccf(from_: np.ndarray, to: np.ndarray) -> Dict[str, Callable]:
438
+ """Cross-correlation-aligned linear projection.
439
+
440
+ Python port of R ``help_sec_ccf`` (``help_secondary.R`` L161-178).
441
+ Finds the lag of maximum cross-correlation (:func:`_help_sec_ccf_acf`),
442
+ truncates the two series to align them at that lag, then delegates to
443
+ :func:`_help_sec_fit`.
444
+
445
+ Parameters
446
+ ----------
447
+ from_ : numpy.ndarray
448
+ Primary values.
449
+ to : numpy.ndarray
450
+ Secondary values. Must have the same length as ``from_``.
451
+
452
+ Returns
453
+ -------
454
+ dict
455
+ ``{'forward': callable, 'reverse': callable}`` from the fit on the
456
+ aligned data.
457
+
458
+ Raises
459
+ ------
460
+ ValueError
461
+ If ``from_`` and ``to`` have unequal length (via :func:`cli_abort`).
462
+ """
463
+ from_ = np.asarray(from_, dtype=float)
464
+ to = np.asarray(to, dtype=float)
465
+ n = from_.shape[0]
466
+ if n != to.shape[0]:
467
+ cli_abort("The primary and secondary values must have the same length.")
468
+
469
+ lags, acf = _help_sec_ccf_acf(from_, to)
470
+ # which.max returns the FIRST maximum; np.argmax matches that tie rule.
471
+ lag = int(lags[int(np.argmax(acf))])
472
+
473
+ # No block for 0-lag because the data is already optimally aligned.
474
+ if np.sign(lag) == 1:
475
+ # R: from <- tail(from, -lag); to <- head(to, -lag)
476
+ from_ = from_[lag:]
477
+ to = to[:-lag]
478
+ elif np.sign(lag) == -1:
479
+ # R: from <- head(from, lag); to <- tail(to, lag), with lag < 0.
480
+ # head(x, lag<0) drops the LAST |lag| (keeps first n-|lag|); tail(x,
481
+ # lag<0) drops the FIRST |lag| (keeps last n-|lag|) — so both keep
482
+ # n-|lag| elements and stay aligned. (Previously `to[-(-lag):]` kept
483
+ # the last |lag|, a length mismatch that raised in _help_sec_fit.)
484
+ from_ = from_[:lag]
485
+ to = to[-lag:]
486
+
487
+ return _help_sec_fit(from_=from_, to=to)
488
+
489
+
490
+ def _help_sec_sortfit(from_: np.ndarray, to: np.ndarray) -> Dict[str, Callable]:
491
+ """Sorted linear projection.
492
+
493
+ Python port of R ``help_sec_sortfit`` (``help_secondary.R`` L180-182).
494
+ Independently sorts both series (dropping NaN, as R's ``sort`` does by
495
+ default) then delegates to :func:`_help_sec_fit`.
496
+
497
+ Parameters
498
+ ----------
499
+ from_ : numpy.ndarray
500
+ Primary values.
501
+ to : numpy.ndarray
502
+ Secondary values.
503
+
504
+ Returns
505
+ -------
506
+ dict
507
+ ``{'forward': callable, 'reverse': callable}``.
508
+ """
509
+ return _help_sec_fit(from_=_sort(from_), to=_sort(to))
510
+
511
+
512
+ # ---------------------------------------------------------------------------
513
+ # Small numeric utilities
514
+ # ---------------------------------------------------------------------------
515
+
516
+ def _num(x: ArrayLike) -> np.ndarray:
517
+ """Coerce an aesthetic/array value to a float :class:`numpy.ndarray`.
518
+
519
+ Parameters
520
+ ----------
521
+ x : array_like
522
+ Value to coerce (handles pandas Series via ``to_numpy``).
523
+
524
+ Returns
525
+ -------
526
+ numpy.ndarray
527
+ Float array (scalars become 0-D arrays, preserved by NumPy ops).
528
+ """
529
+ if pd is not None and isinstance(x, pd.Series):
530
+ x = x.to_numpy()
531
+ return np.asarray(x, dtype=float)
532
+
533
+
534
+ def _sort(x: np.ndarray) -> np.ndarray:
535
+ """Sort ascending, dropping NaN (mirrors R ``sort`` defaults).
536
+
537
+ Parameters
538
+ ----------
539
+ x : numpy.ndarray
540
+ Values to sort.
541
+
542
+ Returns
543
+ -------
544
+ numpy.ndarray
545
+ Sorted finite values (NaN removed).
546
+ """
547
+ x = np.asarray(x, dtype=float)
548
+ x = x[~np.isnan(x)]
549
+ return np.sort(x)
@@ -0,0 +1,51 @@
1
+ """Multiple / listed / manual scales for ggh4x.
2
+
3
+ This package ports three R ggh4x files that implement two independent
4
+ capabilities sharing the :class:`MultiScale` deferred-mutation container plus a
5
+ standalone manual position scale:
6
+
7
+ * :mod:`scale_multi` (``scale_multi.R``) -- :func:`scale_colour_multi` /
8
+ :func:`scale_fill_multi`: map several non-standard colour/fill aesthetics each
9
+ to its own gradient :func:`ggplot2_py.continuous_scale`.
10
+ * :mod:`scale_listed` (``scale_listed.R``) -- :func:`scale_listed`: distribute a
11
+ user-supplied list of discrete scales bound to non-standard aesthetics, grouped
12
+ by the standard aesthetic each replaces. Also home of the shared
13
+ :class:`MultiScale` container and its ``ggplot_add`` handler.
14
+ * :mod:`scale_manual` (``scale_manual.R``) -- :func:`scale_x_manual` /
15
+ :func:`scale_y_manual`: a hybrid discrete/continuous position scale
16
+ (:class:`ScaleManualPosition`) that places discrete levels at arbitrary
17
+ continuous coordinates.
18
+
19
+ Importing this package registers the ``MultiScale`` handler on
20
+ :func:`ggplot2_py.plot.update_ggplot` (via the :mod:`_multiscale_add` import),
21
+ exactly as ``ggnewscale.__init__`` imports ``_ggplot_add`` for its side effect.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ # Importing _multiscale_add registers @update_ggplot.register(MultiScale).
27
+ from ._multiscale_add import MultiScale
28
+ from .scale_listed import scale_listed
29
+ from .scale_manual import (
30
+ ScaleManualPosition,
31
+ scale_x_manual,
32
+ scale_y_manual,
33
+ sep_discrete,
34
+ )
35
+ from .scale_multi import (
36
+ scale_color_multi,
37
+ scale_colour_multi,
38
+ scale_fill_multi,
39
+ )
40
+
41
+ __all__ = [
42
+ "scale_fill_multi",
43
+ "scale_colour_multi",
44
+ "scale_color_multi",
45
+ "scale_listed",
46
+ "scale_x_manual",
47
+ "scale_y_manual",
48
+ "sep_discrete",
49
+ "ScaleManualPosition",
50
+ "MultiScale",
51
+ ]