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.
- ggh4x/__init__.py +140 -0
- ggh4x/_aimed_text_grob.py +432 -0
- ggh4x/_borrowed_ggplot2.py +273 -0
- ggh4x/_cli.py +84 -0
- ggh4x/_datasets.py +106 -0
- ggh4x/_download.py +111 -0
- ggh4x/_facet_helpers.py +313 -0
- ggh4x/_facet_utils.py +649 -0
- ggh4x/_gap_grobs.py +606 -0
- ggh4x/_registry.py +10 -0
- ggh4x/_rlang.py +93 -0
- ggh4x/_utils.py +150 -0
- ggh4x/_vctrs.py +233 -0
- ggh4x/conveniences.py +601 -0
- ggh4x/coord_axes_inside.py +380 -0
- ggh4x/element_part_rect.py +545 -0
- ggh4x/facet_grid2.py +1018 -0
- ggh4x/facet_manual.py +901 -0
- ggh4x/facet_nested.py +776 -0
- ggh4x/facet_nested_wrap.py +193 -0
- ggh4x/facet_wrap2.py +896 -0
- ggh4x/geom_box.py +536 -0
- ggh4x/geom_outline_point.py +444 -0
- ggh4x/geom_pointpath.py +259 -0
- ggh4x/geom_polygonraster.py +252 -0
- ggh4x/geom_rectrug.py +489 -0
- ggh4x/geom_text_aimed.py +279 -0
- ggh4x/guide_stringlegend.py +354 -0
- ggh4x/help_secondary.py +549 -0
- ggh4x/multiscale/__init__.py +51 -0
- ggh4x/multiscale/_multiscale_add.py +207 -0
- ggh4x/multiscale/scale_listed.py +167 -0
- ggh4x/multiscale/scale_manual.py +478 -0
- ggh4x/multiscale/scale_multi.py +393 -0
- ggh4x/panel_scales/__init__.py +58 -0
- ggh4x/panel_scales/at_panel.py +115 -0
- ggh4x/panel_scales/facetted_pos_scales.py +647 -0
- ggh4x/panel_scales/force_panelsize.py +411 -0
- ggh4x/panel_scales/scale_facet.py +222 -0
- ggh4x/position_disjoint_ranges.py +229 -0
- ggh4x/position_lineartrans.py +242 -0
- ggh4x/py.typed +0 -0
- ggh4x/resources/faithful.csv +273 -0
- ggh4x/resources/iris.csv +151 -0
- ggh4x/resources/mtcars.csv +33 -0
- ggh4x/resources/pressure.csv +20 -0
- ggh4x/resources/volcano.csv +87 -0
- ggh4x/save.py +255 -0
- ggh4x/stat_difference.py +388 -0
- ggh4x/stat_funxy.py +436 -0
- ggh4x/stat_rle.py +290 -0
- ggh4x/stat_rollingkernel.py +369 -0
- ggh4x/stat_theodensity.py +681 -0
- ggh4x/strip_nested.py +448 -0
- ggh4x/strip_split.py +687 -0
- ggh4x/strip_tag.py +636 -0
- ggh4x/strip_themed.py +232 -0
- ggh4x/strip_vanilla.py +1464 -0
- ggh4x/themes.py +31 -0
- ggh4x/themes_ggh4x.py +67 -0
- ggh4x_python-0.3.1.9000.dist-info/METADATA +40 -0
- ggh4x_python-0.3.1.9000.dist-info/RECORD +64 -0
- ggh4x_python-0.3.1.9000.dist-info/WHEEL +4 -0
- 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)
|