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
ggh4x/_gap_grobs.py ADDED
@@ -0,0 +1,606 @@
1
+ """Custom grobs and geometry helpers for :mod:`ggh4x.geom_pointpath`.
2
+
3
+ This module ports the grid-level machinery of R ggh4x ``geom_pointpath.R``:
4
+ the two custom grob classes that interrupt a path around its points, plus the
5
+ three pure-geometry helpers they rely on.
6
+
7
+ R source: ``ggh4x/R/geom_pointpath.R`` (``makeContext.gapsegments``,
8
+ ``makeContext.gapsegmentschain``, ``intersect_line_circle``,
9
+ ``crop_segment_ends``, ``filter_gp``).
10
+
11
+ Notes
12
+ -----
13
+ * In R the ``makeContext`` methods *reclass* the grob in place
14
+ (``class(x)[1] <- "segments"``) and return it. :mod:`grid_py` dispatches
15
+ rendering on a fixed ``_grid_class`` registry and treats an unknown class as
16
+ a silent no-op, so the Python ports instead **construct and return a freshly
17
+ built renderable grob** (:func:`grid_py.segments_grob` /
18
+ :func:`grid_py.polyline_grob`) from :meth:`Grob.make_context`.
19
+ * All trimming happens in absolute millimetres: the stored ``x0``/``y0``/
20
+ ``x1``/``y1`` are npc :class:`grid_py.Unit` objects that are converted to mm
21
+ against the live panel viewport inside ``make_context`` (so the gaps are
22
+ resize-invariant, matching R).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Any, Dict, List, Optional
28
+
29
+ import numpy as np
30
+
31
+ from grid_py import (
32
+ Gpar,
33
+ Grob,
34
+ Unit,
35
+ convert_x,
36
+ convert_y,
37
+ null_grob,
38
+ polyline_grob,
39
+ segments_grob,
40
+ )
41
+
42
+ __all__ = [
43
+ "intersect_line_circle",
44
+ "crop_segment_ends",
45
+ "filter_gp",
46
+ "GapSegmentsGrob",
47
+ "GapSegmentsChainGrob",
48
+ "_chain_compute",
49
+ ]
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Geometry helpers
54
+ # ---------------------------------------------------------------------------
55
+ def intersect_line_circle(
56
+ x1: np.ndarray,
57
+ y1: np.ndarray,
58
+ x2: np.ndarray,
59
+ y2: np.ndarray,
60
+ cx: np.ndarray,
61
+ cy: np.ndarray,
62
+ r: np.ndarray,
63
+ prio: int = 1,
64
+ ) -> Dict[str, np.ndarray]:
65
+ """Intersect a circle with a line, returning one intersection per element.
66
+
67
+ Port of R ``intersect_line_circle`` (``geom_pointpath.R:322-363``). The
68
+ circle is parameterised by centre ``(cx, cy)`` and radius ``r``; the line
69
+ passes through ``(x1, y1)`` and ``(x2, y2)``. The implementation follows
70
+ the Wolfram MathWorld *Circle-Line Intersection* formula.
71
+
72
+ Parameters
73
+ ----------
74
+ x1, y1 : numpy.ndarray
75
+ Coordinates of the first point on each line.
76
+ x2, y2 : numpy.ndarray
77
+ Coordinates of the second point on each line.
78
+ cx, cy : numpy.ndarray
79
+ Coordinates of each circle centre.
80
+ r : numpy.ndarray
81
+ Radius of each circle.
82
+ prio : int, default ``1``
83
+ Which intersection to return: ``1`` selects the intersection closer to
84
+ ``(x1, y1)``, ``2`` selects the one closer to ``(x2, y2)``.
85
+
86
+ Returns
87
+ -------
88
+ dict of numpy.ndarray
89
+ ``{"x": ndarray, "y": ndarray}`` of the chosen intersection points
90
+ (``NaN`` where the line misses the circle).
91
+ """
92
+ x1 = np.asarray(x1, dtype="float64")
93
+ y1 = np.asarray(y1, dtype="float64")
94
+ x2 = np.asarray(x2, dtype="float64")
95
+ y2 = np.asarray(y2, dtype="float64")
96
+ cx = np.asarray(cx, dtype="float64")
97
+ cy = np.asarray(cy, dtype="float64")
98
+ r = np.asarray(r, dtype="float64")
99
+
100
+ # Centre the circle at (0, 0).
101
+ x1 = x1 - cx
102
+ x2 = x2 - cx
103
+ y1 = y1 - cy
104
+ y2 = y2 - cy
105
+
106
+ dx = x2 - x1
107
+ dy = y2 - y1
108
+ dr2 = dx ** 2 + dy ** 2
109
+ det = x1 * y2 - x2 * y1
110
+
111
+ # Discriminant: <0 no intersection, 0 tangent, >0 two intersections.
112
+ with np.errstate(invalid="ignore"):
113
+ dis = r ** 2 * dr2 - det ** 2
114
+ dis = np.where(dis < 0, np.nan, dis)
115
+ dis = np.sqrt(dis)
116
+
117
+ # R uses sign(dy); note sign(0) == 0 (matches numpy).
118
+ sign_dy = np.sign(dy)
119
+ abs_dy = np.abs(dy)
120
+ with np.errstate(invalid="ignore", divide="ignore"):
121
+ x_1 = (det * dy + sign_dy * dx * dis) / dr2
122
+ x_2 = (det * dy - sign_dy * dx * dis) / dr2
123
+ y_1 = (-det * dx + abs_dy * dis) / dr2
124
+ y_2 = (-det * dx - abs_dy * dis) / dr2
125
+
126
+ if prio == 1:
127
+ dist1 = np.sqrt((x1 - x_1) ** 2 + (y1 - y_1) ** 2)
128
+ dist2 = np.sqrt((x1 - x_2) ** 2 + (y1 - y_2) ** 2)
129
+ else:
130
+ dist1 = np.sqrt((x2 - x_1) ** 2 + (y2 - y_1) ** 2)
131
+ dist2 = np.sqrt((x2 - x_2) ** 2 + (y2 - y_2) ** 2)
132
+
133
+ # R: ifelse(test, x_2, x_1); a NaN test propagates NaN (ifelse keeps NA).
134
+ test = dist2 < dist1
135
+ new_x = np.where(test, x_2, x_1) + cx
136
+ new_y = np.where(test, y_2, y_1) + cy
137
+ # Preserve R's NA propagation when the comparison itself is NA.
138
+ nan_test = np.isnan(dist1) | np.isnan(dist2)
139
+ new_x = np.where(nan_test, np.nan, new_x)
140
+ new_y = np.where(nan_test, np.nan, new_y)
141
+ return {"x": new_x, "y": new_y}
142
+
143
+
144
+ def crop_segment_ends(
145
+ x0: np.ndarray,
146
+ x1: np.ndarray,
147
+ y0: np.ndarray,
148
+ y1: np.ndarray,
149
+ r: np.ndarray,
150
+ ) -> Dict[str, np.ndarray]:
151
+ """Shorten both ends of each segment by ``r``.
152
+
153
+ Port of R ``crop_segment_ends`` (``geom_pointpath.R:365-386``). Each
154
+ segment ``(x0, y0) -> (x1, y1)`` is nudged inward at both ends by radius
155
+ ``r`` (the gap around each point). Non-finite nudges (zero-length
156
+ segments) are replaced by zero, mirroring ggh4x issue #73.
157
+
158
+ Parameters
159
+ ----------
160
+ x0, x1, y0, y1 : numpy.ndarray
161
+ Segment endpoint coordinates.
162
+ r : numpy.ndarray
163
+ Per-segment crop radius.
164
+
165
+ Returns
166
+ -------
167
+ dict of numpy.ndarray
168
+ ``{"x0", "x1", "y0", "y1", "keep"}`` with the cropped coordinates and
169
+ a boolean ``keep`` flagging segments that did not over-shoot (i.e. the
170
+ crop did not reverse their orientation).
171
+ """
172
+ x0 = np.asarray(x0, dtype="float64").copy()
173
+ x1 = np.asarray(x1, dtype="float64").copy()
174
+ y0 = np.asarray(y0, dtype="float64").copy()
175
+ y1 = np.asarray(y1, dtype="float64").copy()
176
+ r = np.asarray(r, dtype="float64")
177
+
178
+ dx = x1 - x0
179
+ dy = y1 - y0
180
+ hyp = np.sqrt(dx ** 2 + dy ** 2)
181
+ with np.errstate(invalid="ignore", divide="ignore"):
182
+ nudge_y = (dy / hyp) * r
183
+ nudge_x = (dx / hyp) * r
184
+
185
+ nudge_y = np.where(np.isfinite(nudge_y), nudge_y, 0.0)
186
+ nudge_x = np.where(np.isfinite(nudge_x), nudge_x, 0.0)
187
+
188
+ new_x0 = x0 + nudge_x
189
+ new_x1 = x1 - nudge_x
190
+ new_y0 = y0 + nudge_y
191
+ new_y1 = y1 - nudge_y
192
+
193
+ keep = (np.sign(dx) == np.sign(new_x1 - new_x0)) & (
194
+ np.sign(dy) == np.sign(new_y1 - new_y0)
195
+ )
196
+ return {
197
+ "x0": new_x0,
198
+ "x1": new_x1,
199
+ "y0": new_y0,
200
+ "y1": new_y1,
201
+ "keep": keep,
202
+ }
203
+
204
+
205
+ def filter_gp(gp: Optional[Gpar], keep: np.ndarray) -> Optional[Gpar]:
206
+ """Subset every length>1 entry of a :class:`grid_py.Gpar` by ``keep``.
207
+
208
+ Port of R ``filter_gp`` (``geom_pointpath.R:388-392``). Scalar (length-1)
209
+ graphical parameters are passed through unchanged; per-point vectors are
210
+ subset to the kept positions so the gp stays aligned with the surviving
211
+ segments.
212
+
213
+ Parameters
214
+ ----------
215
+ gp : grid_py.Gpar or None
216
+ The graphical parameters to filter.
217
+ keep : numpy.ndarray
218
+ Boolean mask (or integer index) selecting which positions to keep.
219
+
220
+ Returns
221
+ -------
222
+ grid_py.Gpar or None
223
+ A new :class:`grid_py.Gpar` with vector entries filtered, or ``None``
224
+ if *gp* is ``None``.
225
+ """
226
+ if gp is None:
227
+ return None
228
+ keep = np.asarray(keep)
229
+ params = _gpar_to_dict(gp)
230
+ out: Dict[str, Any] = {}
231
+ for key, value in params.items():
232
+ if value is None:
233
+ out[key] = value
234
+ continue
235
+ arr = np.asarray(value)
236
+ # R: consider <- lengths(gp) > 1L
237
+ if arr.ndim >= 1 and arr.shape[0] > 1:
238
+ out[key] = arr[keep]
239
+ else:
240
+ out[key] = value
241
+ return Gpar(**out)
242
+
243
+
244
+ def _gpar_to_dict(gp: Gpar) -> Dict[str, Any]:
245
+ """Return the populated fields of a :class:`grid_py.Gpar` as a dict.
246
+
247
+ Parameters
248
+ ----------
249
+ gp : grid_py.Gpar
250
+ Graphical parameters.
251
+
252
+ Returns
253
+ -------
254
+ dict
255
+ Mapping of populated gp field names to their values.
256
+ """
257
+ # Gpar stores its values either in a dict-like ``_params`` mapping or as
258
+ # plain attributes; handle both defensively.
259
+ for attr in ("_params", "params"):
260
+ store = getattr(gp, attr, None)
261
+ if isinstance(store, dict):
262
+ return {k: v for k, v in store.items() if v is not None}
263
+ if hasattr(gp, "to_dict"):
264
+ try:
265
+ return {k: v for k, v in gp.to_dict().items() if v is not None}
266
+ except (AttributeError, TypeError):
267
+ pass
268
+ # Fallback: scrape known gpar slots.
269
+ known = (
270
+ "col",
271
+ "fill",
272
+ "alpha",
273
+ "lty",
274
+ "lwd",
275
+ "lex",
276
+ "lineend",
277
+ "linejoin",
278
+ "linemitre",
279
+ "fontsize",
280
+ "cex",
281
+ "fontfamily",
282
+ "fontface",
283
+ "lineheight",
284
+ "font",
285
+ )
286
+ return {k: getattr(gp, k) for k in known if getattr(gp, k, None) is not None}
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # Custom grobs
291
+ # ---------------------------------------------------------------------------
292
+ class GapSegmentsGrob(Grob):
293
+ """Path-interrupting segments grob for *linear* coordinates.
294
+
295
+ Stores the inter-point segments of a :class:`~ggh4x.geom_pointpath.GeomPointPath`
296
+ as npc-unit coordinates plus the per-point crop radius ``mult``. At draw
297
+ time, :meth:`make_context` converts to millimetres, crops the segment ends
298
+ and emits a plain :func:`grid_py.segments_grob`.
299
+
300
+ Port of the grob carrying ``cl = "gapsegments"`` and its S3 method
301
+ ``makeContext.gapsegments`` (``geom_pointpath.R:272-298``).
302
+ """
303
+
304
+ def __init__(
305
+ self,
306
+ x0: Unit,
307
+ x1: Unit,
308
+ y0: Unit,
309
+ y1: Unit,
310
+ mult: np.ndarray,
311
+ id: np.ndarray,
312
+ arrow: Any = None,
313
+ name: Optional[str] = None,
314
+ gp: Optional[Gpar] = None,
315
+ vp: Optional[Any] = None,
316
+ ) -> None:
317
+ super().__init__(
318
+ name=name,
319
+ gp=gp,
320
+ vp=vp,
321
+ _grid_class="gapsegments",
322
+ x0=x0,
323
+ x1=x1,
324
+ y0=y0,
325
+ y1=y1,
326
+ mult=np.asarray(mult, dtype="float64"),
327
+ id=np.asarray(id),
328
+ arrow=arrow,
329
+ )
330
+
331
+ def make_context(self) -> Grob:
332
+ """Crop the segment ends in mm and return a renderable segments grob.
333
+
334
+ Returns
335
+ -------
336
+ grid_py.Grob
337
+ A :func:`grid_py.segments_grob` with cropped millimetre coordinates
338
+ and the filtered ``gp``/``arrow``, or :func:`grid_py.null_grob`
339
+ when every segment over-shoots.
340
+ """
341
+ x0 = np.asarray(convert_x(self.x0, "mm", valueOnly=True), dtype="float64")
342
+ y0 = np.asarray(convert_y(self.y0, "mm", valueOnly=True), dtype="float64")
343
+ x1 = np.asarray(convert_x(self.x1, "mm", valueOnly=True), dtype="float64")
344
+ y1 = np.asarray(convert_y(self.y1, "mm", valueOnly=True), dtype="float64")
345
+
346
+ cut = crop_segment_ends(x0, x1, y0, y1, self.mult)
347
+ keep = cut["keep"]
348
+ if not np.any(keep):
349
+ return null_grob()
350
+
351
+ gp = filter_gp(self.gp, keep)
352
+ return segments_grob(
353
+ x0=Unit(cut["x0"][keep], "mm"),
354
+ x1=Unit(cut["x1"][keep], "mm"),
355
+ y0=Unit(cut["y0"][keep], "mm"),
356
+ y1=Unit(cut["y1"][keep], "mm"),
357
+ arrow=self.arrow,
358
+ gp=gp,
359
+ name=self.name,
360
+ )
361
+
362
+
363
+ class GapSegmentsChainGrob(Grob):
364
+ """Path-interrupting polyline grob for *non-linear* coordinates.
365
+
366
+ A much more involved version of :class:`GapSegmentsGrob`: it deletes
367
+ segments whose start *and* end fall within the gap radius of a point, trims
368
+ the partial-overlap edge cases with a circle-line intersection, re-stitches
369
+ the surviving segments into a polyline and returns it.
370
+
371
+ Port of the grob carrying ``cl = "gapsegmentschain"`` and its S3 method
372
+ ``makeContext.gapsegmentschain`` (``geom_pointpath.R:156-264``).
373
+ """
374
+
375
+ def __init__(
376
+ self,
377
+ x0: Unit,
378
+ x1: Unit,
379
+ y0: Unit,
380
+ y1: Unit,
381
+ mult: np.ndarray,
382
+ id: np.ndarray,
383
+ arrow: Any = None,
384
+ name: Optional[str] = None,
385
+ gp: Optional[Gpar] = None,
386
+ vp: Optional[Any] = None,
387
+ ) -> None:
388
+ super().__init__(
389
+ name=name,
390
+ gp=gp,
391
+ vp=vp,
392
+ _grid_class="gapsegmentschain",
393
+ x0=x0,
394
+ x1=x1,
395
+ y0=y0,
396
+ y1=y1,
397
+ mult=np.asarray(mult, dtype="float64"),
398
+ id=np.asarray(id),
399
+ arrow=arrow,
400
+ )
401
+
402
+ def make_context(self) -> Grob:
403
+ """Trim, re-stitch and return a renderable polyline grob.
404
+
405
+ Returns
406
+ -------
407
+ grid_py.Grob
408
+ A :func:`grid_py.polyline_grob` with millimetre coordinates, the
409
+ re-grouped ``id`` and one ``gp`` entry per group, or
410
+ :func:`grid_py.null_grob` when nothing survives.
411
+ """
412
+ x0 = np.asarray(convert_x(self.x0, "mm", valueOnly=True), dtype="float64")
413
+ y0 = np.asarray(convert_y(self.y0, "mm", valueOnly=True), dtype="float64")
414
+ x1 = np.asarray(convert_x(self.x1, "mm", valueOnly=True), dtype="float64")
415
+ y1 = np.asarray(convert_y(self.y1, "mm", valueOnly=True), dtype="float64")
416
+
417
+ result = _chain_compute(x0, x1, y0, y1, self.mult, self.id)
418
+ if result is None:
419
+ return null_grob()
420
+ xy_x, xy_y, xy_id, keep, grp_start = result
421
+
422
+ gp = filter_gp(self.gp, keep)
423
+ gp = filter_gp(gp, grp_start)
424
+
425
+ return polyline_grob(
426
+ x=Unit(xy_x, "mm"),
427
+ y=Unit(xy_y, "mm"),
428
+ id=xy_id,
429
+ gp=gp,
430
+ arrow=self.arrow,
431
+ name=self.name,
432
+ )
433
+
434
+
435
+ def _chain_compute(
436
+ x0: np.ndarray,
437
+ x1: np.ndarray,
438
+ y0: np.ndarray,
439
+ y1: np.ndarray,
440
+ mult: np.ndarray,
441
+ id_vec: np.ndarray,
442
+ ) -> Optional[tuple]:
443
+ """Run the gap-segments-chain trimming and re-stitching algorithm.
444
+
445
+ Pure-geometry core of :meth:`GapSegmentsChainGrob.make_context`, split out
446
+ so it can be verified against R without a live viewport. Coordinates are
447
+ in millimetres. Port of ``makeContext.gapsegmentschain``
448
+ (``geom_pointpath.R:156-256``).
449
+
450
+ Parameters
451
+ ----------
452
+ x0, x1, y0, y1 : numpy.ndarray
453
+ Segment endpoint coordinates (mm).
454
+ mult : numpy.ndarray
455
+ Per-segment gap radius (mm).
456
+ id_vec : numpy.ndarray
457
+ Per-segment group identifier.
458
+
459
+ Returns
460
+ -------
461
+ tuple or None
462
+ ``(x, y, id, keep, grp_start)`` where ``x``/``y``/``id`` are the
463
+ polyline vertex arrays, ``keep`` is the per-segment survival mask and
464
+ ``grp_start`` flags the first kept segment of each group. Returns
465
+ ``None`` when nothing survives.
466
+ """
467
+ x0 = np.asarray(x0, dtype="float64").copy()
468
+ x1 = np.asarray(x1, dtype="float64").copy()
469
+ y0 = np.asarray(y0, dtype="float64").copy()
470
+ y1 = np.asarray(y1, dtype="float64").copy()
471
+ mult = np.asarray(mult, dtype="float64")
472
+ id_vec = np.asarray(id_vec)
473
+ n = len(x0)
474
+
475
+ # rle(id) -> per-element group start/end (0-based indices).
476
+ start, end = _rle_start_end(id_vec)
477
+
478
+ keep = np.ones(n, dtype=bool)
479
+
480
+ # Distances to the group's start point.
481
+ dist0_start = np.sqrt((x0 - x0[start]) ** 2 + (y0 - y0[start]) ** 2)
482
+ dist1_start = np.sqrt((x1 - x0[start]) ** 2 + (y1 - y0[start]) ** 2)
483
+ keep = keep & ((dist0_start > mult) | (dist1_start > mult))
484
+ left = np.flatnonzero((dist1_start > mult) & ~(dist0_start > mult))
485
+
486
+ # Distances to the group's end point.
487
+ dist0 = np.sqrt((x0 - x1[end]) ** 2 + (y0 - y1[end]) ** 2)
488
+ dist1 = np.sqrt((x1 - x1[end]) ** 2 + (y1 - y1[end]) ** 2)
489
+ keep = keep & ((dist0 > mult) | (dist1 > mult))
490
+ right = np.flatnonzero((dist0 > mult) != (dist1 > mult))
491
+
492
+ # Edge cases that are both left and right need special handling.
493
+ isect = np.intersect1d(left, right)
494
+ if isect.size > 0:
495
+ cut = crop_segment_ends(
496
+ x0[isect], x1[isect], y0[isect], y1[isect], mult[isect]
497
+ )
498
+ x0[isect] = cut["x0"]
499
+ x1[isect] = cut["x1"]
500
+ y0[isect] = cut["y0"]
501
+ y1[isect] = cut["y1"]
502
+ keep[isect] = cut["keep"]
503
+ left = np.setdiff1d(left, isect)
504
+ right = np.setdiff1d(right, isect)
505
+
506
+ if keep.sum() == 0:
507
+ return None
508
+
509
+ # Handle left edge cases.
510
+ if left.size > 0:
511
+ xy = intersect_line_circle(
512
+ x1=x0[left],
513
+ y1=y0[left],
514
+ x2=x1[left],
515
+ y2=y1[left],
516
+ cx=x0[start[left]],
517
+ cy=y0[start[left]],
518
+ r=mult[left],
519
+ prio=2,
520
+ )
521
+ x0[left] = xy["x"]
522
+ y0[left] = xy["y"]
523
+
524
+ # Handle right edge cases.
525
+ if right.size > 0:
526
+ xy = intersect_line_circle(
527
+ x1=x1[right],
528
+ y1=y1[right],
529
+ x2=x0[right],
530
+ y2=y0[right],
531
+ cx=x1[end[right]],
532
+ cy=y1[end[right]],
533
+ r=mult[right],
534
+ prio=2,
535
+ )
536
+ x1[right] = xy["x"]
537
+ y1[right] = xy["y"]
538
+
539
+ # Index that interleaves (start, end) of each kept segment, so the
540
+ # segment list becomes a polyline vertex list.
541
+ kept = np.flatnonzero(keep)
542
+ idx = np.empty(2 * kept.size, dtype=int)
543
+ idx[0::2] = kept
544
+ idx[1::2] = kept + n
545
+
546
+ cat_x = np.concatenate([x0, x1])
547
+ cat_y = np.concatenate([y0, y1])
548
+ cat_id = np.concatenate([id_vec, id_vec])
549
+ xy_x = cat_x[idx]
550
+ xy_y = cat_y[idx]
551
+ xy_id = cat_id[idx]
552
+
553
+ # Deduplicate consecutive identical (x, y, id) rows.
554
+ m = len(xy_x)
555
+ if m >= 2:
556
+ same = (
557
+ (xy_x[1:] == xy_x[:-1])
558
+ & (xy_y[1:] == xy_y[:-1])
559
+ & (xy_id[1:] == xy_id[:-1])
560
+ )
561
+ dup = np.concatenate([[False], same])
562
+ keep_rows = ~dup
563
+ xy_x = xy_x[keep_rows]
564
+ xy_y = xy_y[keep_rows]
565
+ xy_id = xy_id[keep_rows]
566
+
567
+ # First kept segment of each group (one gp entry per group).
568
+ id_kept = id_vec[keep]
569
+ if id_kept.size > 0:
570
+ grp_start = np.concatenate([[True], id_kept[1:] != id_kept[:-1]])
571
+ else:
572
+ grp_start = np.array([], dtype=bool)
573
+
574
+ return xy_x, xy_y, xy_id, keep, grp_start
575
+
576
+
577
+ def _rle_start_end(id_vec: np.ndarray) -> tuple:
578
+ """Compute per-element run start/end indices (0-based), mirroring R's ``rle``.
579
+
580
+ For each element, ``start`` is the index of the first element of its
581
+ run and ``end`` is the index of the last element of its run. This is the
582
+ 0-based translation of the ``rep.int(start, lengths)`` /
583
+ ``rep.int(end, lengths)`` idiom in ``makeContext.gapsegmentschain``.
584
+
585
+ Parameters
586
+ ----------
587
+ id_vec : numpy.ndarray
588
+ Run-length-encodable identifier vector.
589
+
590
+ Returns
591
+ -------
592
+ tuple of numpy.ndarray
593
+ ``(start, end)`` index arrays the same length as *id_vec*.
594
+ """
595
+ n = len(id_vec)
596
+ if n == 0:
597
+ return np.array([], dtype=int), np.array([], dtype=int)
598
+ change = np.empty(n, dtype=bool)
599
+ change[0] = True
600
+ change[1:] = id_vec[1:] != id_vec[:-1]
601
+ run_starts = np.flatnonzero(change) # index of first element of each run
602
+ lengths = np.diff(np.append(run_starts, n))
603
+ run_ends = run_starts + lengths - 1
604
+ start = np.repeat(run_starts, lengths)
605
+ end = np.repeat(run_ends, lengths)
606
+ return start, end
ggh4x/_registry.py ADDED
@@ -0,0 +1,10 @@
1
+ """Remote data asset registry (placeholder).
2
+
3
+ Populated by ``scripts/data_registry.py generate`` after data staging.
4
+ Do not edit manually once generated.
5
+ """
6
+
7
+ DATA_DIR_NAME = "ggh4x_data"
8
+ CACHE_DIR_NAME = "ggh4x-python"
9
+
10
+ REGISTRY: dict[str, dict[str, str]] = {}
ggh4x/_rlang.py ADDED
@@ -0,0 +1,93 @@
1
+ """rlang shims (R source: rlang usage in ggh4x).
2
+
3
+ ggh4x imports a handful of rlang helpers. The non-NSE ones (``arg_match0``, ``%||%``,
4
+ ``inject``/``exec`` splicing) map cleanly to Python; the NSE ones (``enquo``/``eval_tidy``)
5
+ are rewritten to standard evaluation at the call sites (documented deviations), so they are
6
+ deliberately absent here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Callable, Sequence, TypeVar
12
+
13
+ from ._cli import cli_abort
14
+
15
+ __all__ = ["arg_match0", "value_or", "exec_call"]
16
+
17
+ T = TypeVar("T")
18
+
19
+
20
+ def value_or(x: T | None, default: T) -> T:
21
+ """Null-coalesce, mirroring rlang's ``%||%``.
22
+
23
+ Parameters
24
+ ----------
25
+ x : T | None
26
+ Candidate value.
27
+ default : T
28
+ Fallback used when *x* is ``None``.
29
+
30
+ Returns
31
+ -------
32
+ T
33
+ *x* if it is not ``None``, else *default*.
34
+
35
+ Notes
36
+ -----
37
+ This is a whole-object coalesce (not elementwise), matching R's ``%||%``.
38
+ """
39
+ return x if x is not None else default
40
+
41
+
42
+ def arg_match0(
43
+ arg: str,
44
+ values: Sequence[str],
45
+ arg_name: str = "arg",
46
+ ) -> str:
47
+ """Validate a string argument against allowed choices, mirroring ``rlang::arg_match0``.
48
+
49
+ Parameters
50
+ ----------
51
+ arg : str
52
+ The supplied value.
53
+ values : sequence of str
54
+ The allowed values.
55
+ arg_name : str
56
+ Name of the argument (for the error message).
57
+
58
+ Returns
59
+ -------
60
+ str
61
+ *arg* unchanged when it is one of *values*.
62
+
63
+ Raises
64
+ ------
65
+ ValueError
66
+ If *arg* is not among *values* (message lists the valid choices, like R).
67
+ """
68
+ if arg in values:
69
+ return arg
70
+ choices = ", ".join(repr(v) for v in values)
71
+ cli_abort(
72
+ f"`{arg_name}` must be one of {choices}, not {arg!r}.",
73
+ )
74
+
75
+
76
+ def exec_call(fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
77
+ """Call *fn* with spliced args, mirroring ``rlang::exec`` / ``inject``.
78
+
79
+ Parameters
80
+ ----------
81
+ fn : callable
82
+ Function to invoke.
83
+ *args : Any
84
+ Positional arguments (splice a list with ``*list``).
85
+ **kwargs : Any
86
+ Keyword arguments (splice a dict with ``**dict``).
87
+
88
+ Returns
89
+ -------
90
+ T
91
+ The result of ``fn(*args, **kwargs)``.
92
+ """
93
+ return fn(*args, **kwargs)