rgrid-python 4.5.3__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.
grid_py/_gpar.py ADDED
@@ -0,0 +1,572 @@
1
+ """Graphical parameters for grid_py (port of R's grid ``gpar`` system).
2
+
3
+ This module provides the :class:`Gpar` class, which encapsulates a set of
4
+ graphical parameters analogous to R's ``gpar()`` objects. Individual
5
+ parameters may be scalars **or** vectors (lists); when vectorised the
6
+ recycling / subscripting semantics of R are preserved.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import copy
12
+ import math
13
+ from typing import Any, Dict, List, Optional, Sequence, Union
14
+
15
+ import numpy as np
16
+
17
+ __all__ = ["Gpar", "get_gpar"]
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Constants
21
+ # ---------------------------------------------------------------------------
22
+
23
+ _VALID_LTY: set[str] = {
24
+ "solid",
25
+ "dashed",
26
+ "dotted",
27
+ "dotdash",
28
+ "longdash",
29
+ "twodash",
30
+ }
31
+
32
+ _VALID_LINEEND: set[str] = {"round", "butt", "square"}
33
+
34
+ _VALID_LINEJOIN: set[str] = {"round", "mitre", "bevel"}
35
+
36
+ _FONTFACE_MAP: dict[str, int] = {
37
+ "plain": 1,
38
+ "bold": 2,
39
+ "italic": 3,
40
+ "oblique": 3,
41
+ "bold.italic": 4,
42
+ "symbol": 5,
43
+ "cyrillic": 5,
44
+ "cyrillic.oblique": 6,
45
+ "EUC": 7,
46
+ }
47
+
48
+ # The set of parameter names that the Gpar constructor accepts.
49
+ _GPAR_NAMES: set[str] = {
50
+ "col",
51
+ "fill",
52
+ "alpha",
53
+ "lty",
54
+ "lwd",
55
+ "lex",
56
+ "lineend",
57
+ "linejoin",
58
+ "linemitre",
59
+ "fontsize",
60
+ "cex",
61
+ "fontfamily",
62
+ "fontface",
63
+ "lineheight",
64
+ "font",
65
+ }
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Helpers
70
+ # ---------------------------------------------------------------------------
71
+
72
+
73
+ def _as_list(value: Any) -> list:
74
+ """Wrap scalars in a list; pass through sequences unchanged."""
75
+ if isinstance(value, (list, tuple, np.ndarray)):
76
+ return list(value)
77
+ return [value]
78
+
79
+
80
+ def _is_hex_lty(s: str) -> bool:
81
+ """Return True when *s* looks like a valid hex-string line-type spec."""
82
+ return all(c in "0123456789abcdefABCDEF" for c in s) and len(s) > 0
83
+
84
+
85
+ def _resolve_fontface(value: Any) -> int:
86
+ """Convert a fontface specification to an integer code.
87
+
88
+ Parameters
89
+ ----------
90
+ value : int, str, or float
91
+ A fontface specification. Strings are mapped through
92
+ ``_FONTFACE_MAP``; numeric values are cast to ``int``.
93
+
94
+ Returns
95
+ -------
96
+ int
97
+ The integer font-face code.
98
+
99
+ Raises
100
+ ------
101
+ ValueError
102
+ If the string is not a recognised face name.
103
+ """
104
+ if isinstance(value, (int, float, np.integer, np.floating)):
105
+ return int(value)
106
+ if isinstance(value, str):
107
+ if value in _FONTFACE_MAP:
108
+ return _FONTFACE_MAP[value]
109
+ raise ValueError(f"invalid fontface '{value}'")
110
+ raise TypeError(f"fontface must be int or str, got {type(value).__name__}")
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Gpar class
115
+ # ---------------------------------------------------------------------------
116
+
117
+
118
+ class Gpar:
119
+ """A set of graphical parameters (port of R's ``gpar``).
120
+
121
+ Parameters
122
+ ----------
123
+ col : str or list of str, optional
124
+ Line / border colour(s).
125
+ fill : str or list of str, optional
126
+ Fill colour(s) or pattern(s).
127
+ alpha : float or list of float, optional
128
+ Transparency value(s) in the range ``[0, 1]``.
129
+ lty : str, int, or list, optional
130
+ Line type. One of ``"solid"``, ``"dashed"``, ``"dotted"``,
131
+ ``"dotdash"``, ``"longdash"``, ``"twodash"``, a hex string, or an
132
+ integer code.
133
+ lwd : float or list of float, optional
134
+ Line width(s).
135
+ lex : float or list of float, optional
136
+ Line-width expansion multiplier(s).
137
+ lineend : str or list of str, optional
138
+ Line end style: ``"round"``, ``"butt"``, or ``"square"``.
139
+ linejoin : str or list of str, optional
140
+ Line join style: ``"round"``, ``"mitre"``, or ``"bevel"``.
141
+ linemitre : float or list of float, optional
142
+ Mitre limit (must be >= 1).
143
+ fontsize : float or list of float, optional
144
+ Font size in points.
145
+ cex : float or list of float, optional
146
+ Character expansion factor.
147
+ fontfamily : str or list of str, optional
148
+ Font family name(s).
149
+ fontface : int, str, or list, optional
150
+ Font face specification. Mapped to an integer code internally.
151
+ Cannot be specified together with *font*.
152
+ lineheight : float or list of float, optional
153
+ Line-height multiplier.
154
+ font : int or list of int, optional
155
+ Integer font-face code (alias for *fontface*). Cannot be specified
156
+ together with *fontface*.
157
+
158
+ Raises
159
+ ------
160
+ TypeError
161
+ If a parameter has an inappropriate type.
162
+ ValueError
163
+ If a parameter value is out of range or not among valid choices.
164
+
165
+ Examples
166
+ --------
167
+ >>> gp = Gpar(col="red", lwd=2, alpha=0.5)
168
+ >>> gp
169
+ Gpar(col='red', lwd=2, alpha=0.5)
170
+ """
171
+
172
+ # Slots keep instances lightweight.
173
+ __slots__ = ("_params",)
174
+
175
+ def __init__(self, **kwargs: Any) -> None:
176
+ # Reject unknown parameter names early.
177
+ unknown = set(kwargs) - _GPAR_NAMES
178
+ if unknown:
179
+ raise TypeError(
180
+ f"unknown graphical parameter(s): {', '.join(sorted(unknown))}"
181
+ )
182
+
183
+ params: Dict[str, Any] = {}
184
+
185
+ # -- fontface / font mutual exclusion (mirrors R) ------------------
186
+ if "fontface" in kwargs and "font" in kwargs:
187
+ raise ValueError("must specify only one of 'font' and 'fontface'")
188
+
189
+ if "fontface" in kwargs:
190
+ ff = kwargs.pop("fontface")
191
+ if ff is not None:
192
+ ff_list = _as_list(ff)
193
+ if len(ff_list) == 0:
194
+ raise ValueError("'gpar' element 'fontface' must not be length 0")
195
+ resolved = [_resolve_fontface(v) for v in ff_list]
196
+ params["font"] = resolved[0] if len(resolved) == 1 else resolved
197
+ # fontface is consumed; do not store it directly.
198
+
199
+ # -- process remaining parameters ----------------------------------
200
+ for name, value in kwargs.items():
201
+ if value is None:
202
+ continue
203
+
204
+ vals = _as_list(value)
205
+
206
+ if len(vals) == 0:
207
+ raise ValueError(
208
+ f"'gpar' element '{name}' must not be length 0"
209
+ )
210
+
211
+ # --- per-parameter validation ---------------------------------
212
+ if name in ("fontsize", "lineheight", "cex", "lwd", "lex"):
213
+ try:
214
+ vals = [float(v) for v in vals]
215
+ except (TypeError, ValueError) as exc:
216
+ raise TypeError(
217
+ f"'{name}' must be numeric, got {type(value).__name__}"
218
+ ) from exc
219
+
220
+ elif name == "alpha":
221
+ try:
222
+ vals = [float(v) for v in vals]
223
+ except (TypeError, ValueError) as exc:
224
+ raise TypeError(
225
+ f"'alpha' must be numeric, got {type(value).__name__}"
226
+ ) from exc
227
+ if any(v < 0 or v > 1 for v in vals):
228
+ raise ValueError("invalid 'alpha' value (must be 0-1)")
229
+
230
+ elif name == "linemitre":
231
+ try:
232
+ vals = [float(v) for v in vals]
233
+ except (TypeError, ValueError) as exc:
234
+ raise TypeError(
235
+ f"'linemitre' must be numeric, got {type(value).__name__}"
236
+ ) from exc
237
+ if any(v < 1 for v in vals):
238
+ raise ValueError("invalid 'linemitre' value (must be >= 1)")
239
+
240
+ elif name == "font":
241
+ try:
242
+ vals = [int(v) for v in vals]
243
+ except (TypeError, ValueError) as exc:
244
+ raise TypeError(
245
+ f"'font' must be integer, got {type(value).__name__}"
246
+ ) from exc
247
+
248
+ elif name == "lty":
249
+ for v in vals:
250
+ if isinstance(v, str):
251
+ if v not in _VALID_LTY and not _is_hex_lty(v):
252
+ raise ValueError(
253
+ f"invalid line type '{v}'; must be one of "
254
+ f"{sorted(_VALID_LTY)} or a hex string"
255
+ )
256
+ elif not isinstance(v, (int, float, np.integer, np.floating)):
257
+ raise TypeError(
258
+ f"'lty' must be str or numeric, got {type(v).__name__}"
259
+ )
260
+
261
+ elif name == "lineend":
262
+ for v in vals:
263
+ if v not in _VALID_LINEEND:
264
+ raise ValueError(
265
+ f"invalid 'lineend' value '{v}'; "
266
+ f"must be one of {sorted(_VALID_LINEEND)}"
267
+ )
268
+
269
+ elif name == "linejoin":
270
+ for v in vals:
271
+ if v not in _VALID_LINEJOIN:
272
+ raise ValueError(
273
+ f"invalid 'linejoin' value '{v}'; "
274
+ f"must be one of {sorted(_VALID_LINEJOIN)}"
275
+ )
276
+
277
+ elif name == "fontfamily":
278
+ vals = [str(v) for v in vals]
279
+
280
+ elif name in ("col", "fill"):
281
+ # Accept strings or lists of strings; no further validation
282
+ # here (colour resolution is deferred to the rendering
283
+ # backend, matching R's behaviour).
284
+ pass
285
+
286
+ # Store single-element lists as scalars for cleaner repr.
287
+ params[name] = vals[0] if len(vals) == 1 else vals
288
+
289
+ self._params = params
290
+
291
+ # -- dict-like access --------------------------------------------------
292
+
293
+ @property
294
+ def params(self) -> Dict[str, Any]:
295
+ """Return a **copy** of the underlying parameter dictionary.
296
+
297
+ Returns
298
+ -------
299
+ dict
300
+ Mapping of parameter names to their values.
301
+ """
302
+ return dict(self._params)
303
+
304
+ def get(self, name: str, default: Any = None) -> Any:
305
+ """Retrieve a single parameter value.
306
+
307
+ Parameters
308
+ ----------
309
+ name : str
310
+ Parameter name.
311
+ default : object, optional
312
+ Value returned when *name* is not set.
313
+
314
+ Returns
315
+ -------
316
+ object
317
+ The parameter value, or *default*.
318
+ """
319
+ return self._params.get(name, default)
320
+
321
+ def set(self, name: str, value: Any) -> None:
322
+ """Set a single parameter value.
323
+
324
+ Parameters
325
+ ----------
326
+ name : str
327
+ Parameter name.
328
+ value : object
329
+ The value to set.
330
+ """
331
+ self._params[name] = value
332
+
333
+ def __contains__(self, name: str) -> bool:
334
+ return name in self._params
335
+
336
+ def names(self) -> List[str]:
337
+ """Return the names of parameters currently set.
338
+
339
+ Returns
340
+ -------
341
+ list of str
342
+ """
343
+ return list(self._params.keys())
344
+
345
+ # -- length & subscripting ---------------------------------------------
346
+
347
+ def __len__(self) -> int:
348
+ """Return the maximum length across all vectorised parameters.
349
+
350
+ Returns
351
+ -------
352
+ int
353
+ 0 if no parameters are set; otherwise ``max(len(v))`` over all
354
+ parameters (scalars count as length 1).
355
+ """
356
+ if not self._params:
357
+ return 0
358
+ return max(
359
+ len(v) if isinstance(v, list) else 1
360
+ for v in self._params.values()
361
+ )
362
+
363
+ def __getitem__(self, index: int) -> "Gpar":
364
+ """Subscript the Gpar, returning a new Gpar with the *index*-th element.
365
+
366
+ Vector parameters are recycled to the maximum length (matching R's
367
+ ``[.gpar`` method) before the element is selected.
368
+
369
+ Parameters
370
+ ----------
371
+ index : int
372
+ Zero-based index into the (recycled) parameter vectors.
373
+
374
+ Returns
375
+ -------
376
+ Gpar
377
+ A new ``Gpar`` containing scalar values for each parameter.
378
+
379
+ Raises
380
+ ------
381
+ IndexError
382
+ If *index* is out of range after recycling.
383
+ """
384
+ n = len(self)
385
+ if n == 0:
386
+ return Gpar()
387
+
388
+ if index < 0:
389
+ index += n
390
+ if index < 0 or index >= n:
391
+ raise IndexError(f"Gpar index {index} out of range [0, {n})")
392
+
393
+ new_params: Dict[str, Any] = {}
394
+ for name, value in self._params.items():
395
+ if isinstance(value, list):
396
+ # Recycle to length n, then pick element.
397
+ recycled = (value * math.ceil(n / len(value)))[:n]
398
+ new_params[name] = recycled[index]
399
+ else:
400
+ new_params[name] = value
401
+ # Build via internal path to skip re-validation.
402
+ gp = object.__new__(Gpar)
403
+ gp._params = new_params
404
+ return gp
405
+
406
+ # -- merge -------------------------------------------------------------
407
+
408
+ def _merge(self, parent: "Gpar") -> "Gpar":
409
+ """Merge with a *parent* ``Gpar`` (child overrides parent).
410
+
411
+ Parameters that are present in *self* take precedence. Parameters
412
+ only present in *parent* are inherited. ``cex``, ``alpha``, and
413
+ ``lex`` are **cumulative** — the child value is multiplied by the
414
+ parent value, matching R's ``set.gpar`` semantics.
415
+
416
+ Parameters
417
+ ----------
418
+ parent : Gpar
419
+ The parent graphical parameters to merge with.
420
+
421
+ Returns
422
+ -------
423
+ Gpar
424
+ A new ``Gpar`` containing the merged parameters.
425
+ """
426
+ merged = copy.deepcopy(parent._params)
427
+ merged.update(copy.deepcopy(self._params))
428
+
429
+ # Cumulative parameters
430
+ for cum_name in ("cex", "alpha", "lex"):
431
+ if cum_name in self._params and cum_name in parent._params:
432
+ child_val = self._params[cum_name]
433
+ parent_val = parent._params[cum_name]
434
+ if isinstance(child_val, list) or isinstance(parent_val, list):
435
+ c_list = _as_list(child_val)
436
+ p_list = _as_list(parent_val)
437
+ maxn = max(len(c_list), len(p_list))
438
+ c_cyc = (c_list * math.ceil(maxn / len(c_list)))[:maxn]
439
+ p_cyc = (p_list * math.ceil(maxn / len(p_list)))[:maxn]
440
+ result = [c * p for c, p in zip(c_cyc, p_cyc)]
441
+ merged[cum_name] = result[0] if len(result) == 1 else result
442
+ else:
443
+ merged[cum_name] = child_val * parent_val
444
+
445
+ gp = object.__new__(Gpar)
446
+ gp._params = merged
447
+ return gp
448
+
449
+ # -- display -----------------------------------------------------------
450
+
451
+ def __repr__(self) -> str:
452
+ if not self._params:
453
+ return "Gpar()"
454
+ items = ", ".join(f"{k}={v!r}" for k, v in self._params.items())
455
+ return f"Gpar({items})"
456
+
457
+ def __str__(self) -> str:
458
+ return self.__repr__()
459
+
460
+ # -- equality (useful for testing) -------------------------------------
461
+
462
+ def __eq__(self, other: object) -> bool:
463
+ if not isinstance(other, Gpar):
464
+ return NotImplemented
465
+ return self._params == other._params
466
+
467
+
468
+ # ---------------------------------------------------------------------------
469
+ # Module-level helpers
470
+ # ---------------------------------------------------------------------------
471
+
472
+
473
+ def _default_gpar() -> Gpar:
474
+ """Return a ``Gpar`` populated with R's default graphical parameters.
475
+
476
+ Returns
477
+ -------
478
+ Gpar
479
+ Default graphical parameters matching R's internal defaults:
480
+ ``fontsize=12``, ``cex=1``, ``fontfamily=""``, ``fontface=1``
481
+ (plain), ``lineheight=1.2``, ``col="black"``, ``fill="transparent"``,
482
+ ``alpha=1``, ``lwd=1``, ``lex=1``, ``lty="solid"``,
483
+ ``lineend="round"``, ``linejoin="round"``, ``linemitre=10``.
484
+ """
485
+ return Gpar(
486
+ fontsize=12,
487
+ cex=1,
488
+ fontfamily="",
489
+ fontface=1,
490
+ lineheight=1.2,
491
+ col="black",
492
+ fill="transparent",
493
+ alpha=1.0,
494
+ lwd=1.0,
495
+ lex=1.0,
496
+ lty="solid",
497
+ lineend="round",
498
+ linejoin="round",
499
+ linemitre=10.0,
500
+ )
501
+
502
+
503
+ def get_gpar(names: Optional[Sequence[str]] = None) -> Gpar:
504
+ """Return the current graphical parameters.
505
+
506
+ Port of R's ``get.gpar()`` (``gpar.R:275-293``). Reads the live
507
+ gpar state from the GridState singleton (equivalent to R's
508
+ ``grid.Call(C_getGPar)`` which reads from ``GSS_GPAR``).
509
+ Falls back to defaults if no state is initialised.
510
+
511
+ Parameters
512
+ ----------
513
+ names : sequence of str, optional
514
+ If provided, only the listed parameter names are returned. All
515
+ names must be valid ``Gpar`` parameter names.
516
+
517
+ Returns
518
+ -------
519
+ Gpar
520
+ A ``Gpar`` instance with the requested (or all current) parameters.
521
+
522
+ Raises
523
+ ------
524
+ ValueError
525
+ If any element of *names* is not a valid gpar name.
526
+
527
+ Examples
528
+ --------
529
+ >>> gp = get_gpar()
530
+ >>> gp.get("fontsize")
531
+ 12.0
532
+ >>> get_gpar(names=["col", "lwd"])
533
+ Gpar(col='black', lwd=1.0)
534
+ """
535
+ # R: result <- grid.Call(C_getGPar) — read from current device state
536
+ # R's C_getGPar returns the fully-resolved gpar from GSS_GPAR which
537
+ # already contains all default values. We emulate this by merging
538
+ # the defaults with whatever the state stack currently holds.
539
+ defaults = _default_gpar()
540
+ try:
541
+ from ._state import get_state
542
+ state = get_state()
543
+ state_gp = state.get_gpar()
544
+ except Exception:
545
+ state_gp = None
546
+
547
+ # Build merged result: defaults overridden by state
548
+ merged = Gpar(**defaults._params)
549
+ if state_gp is not None:
550
+ for k, v in state_gp._params.items():
551
+ if v is not None:
552
+ merged._params[k] = v
553
+
554
+ if names is None:
555
+ return merged
556
+
557
+ # R: if (!is.character(names) || !all(names %in% .grid.gpar.names))
558
+ # stop("must specify only valid 'gpar' names")
559
+ invalid = set(names) - _GPAR_NAMES
560
+ if invalid:
561
+ raise ValueError(
562
+ f"invalid gpar name(s): {', '.join(sorted(invalid))}"
563
+ )
564
+
565
+ subset: Dict[str, Any] = {}
566
+ for n in names:
567
+ val = merged.get(n, None)
568
+ if val is not None:
569
+ subset[n] = val
570
+ gp = object.__new__(Gpar)
571
+ gp._params = subset
572
+ return gp