scales-python 1.4.0.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.
- scales/__init__.py +295 -0
- scales/_colors.py +272 -0
- scales/_palettes_data.py +595 -0
- scales/_utils.py +579 -0
- scales/bounds.py +512 -0
- scales/breaks.py +627 -0
- scales/breaks_log.py +268 -0
- scales/colour_manip.py +681 -0
- scales/colour_mapping.py +593 -0
- scales/colour_ramp.py +126 -0
- scales/labels.py +2144 -0
- scales/minor_breaks.py +197 -0
- scales/palettes.py +1328 -0
- scales/py.typed +0 -0
- scales/range.py +223 -0
- scales/scale_continuous.py +146 -0
- scales/scale_discrete.py +196 -0
- scales/transforms.py +1338 -0
- scales_python-1.4.0.9000.dist-info/METADATA +73 -0
- scales_python-1.4.0.9000.dist-info/RECORD +22 -0
- scales_python-1.4.0.9000.dist-info/WHEEL +4 -0
- scales_python-1.4.0.9000.dist-info/licenses/LICENSE +3 -0
scales/breaks_log.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Break generators for log-scaled axes.
|
|
3
|
+
|
|
4
|
+
Faithful Python port of ``R/breaks-log.R``:
|
|
5
|
+
|
|
6
|
+
* ``breaks_log`` replicates R's algorithm: look for integer powers of
|
|
7
|
+
``base``; when too few, densify via ``log_sub_breaks`` (Wilkinson-style
|
|
8
|
+
greedy candidate selection), falling back to ``extended_breaks``.
|
|
9
|
+
* ``minor_breaks_log`` honours R's ``detail in {1, 5, 10}`` contract,
|
|
10
|
+
handles negatives by mirroring ticks across zero, and exposes the
|
|
11
|
+
selected detail on the result as a ``.detail`` attribute.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
from typing import Callable, Optional
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
from numpy.typing import ArrayLike
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"breaks_log",
|
|
24
|
+
"log_breaks", # R alias: log_breaks <- breaks_log
|
|
25
|
+
"minor_breaks_log",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# breaks_log
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
def _log_sub_breaks(
|
|
34
|
+
rng: tuple[float, float], n: int = 5, base: float = 10
|
|
35
|
+
) -> np.ndarray:
|
|
36
|
+
"""Port of R's internal ``log_sub_breaks``.
|
|
37
|
+
|
|
38
|
+
Greedily picks candidate integers in ``(1, base)`` that, added to
|
|
39
|
+
the current ``steps`` set, maximise the minimum log-spacing gap.
|
|
40
|
+
Repeats until there are at least ``n - 2`` relevant breaks or the
|
|
41
|
+
candidate pool is exhausted. Falls back to
|
|
42
|
+
:func:`scales.breaks.breaks_extended` when no admissible set is
|
|
43
|
+
found.
|
|
44
|
+
"""
|
|
45
|
+
lo = math.floor(rng[0])
|
|
46
|
+
hi = math.ceil(rng[1])
|
|
47
|
+
if base <= 2:
|
|
48
|
+
return np.array(
|
|
49
|
+
[base ** p for p in range(int(lo), int(hi) + 1)], dtype=float
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
steps: list[float] = [1.0]
|
|
53
|
+
|
|
54
|
+
def _delta(x: float) -> float:
|
|
55
|
+
candidate = sorted({x, *steps, base})
|
|
56
|
+
log_vals = [math.log(v, base) for v in candidate]
|
|
57
|
+
diffs = [log_vals[i + 1] - log_vals[i] for i in range(len(log_vals) - 1)]
|
|
58
|
+
return min(diffs)
|
|
59
|
+
|
|
60
|
+
candidate_pool = [c for c in range(2, int(base))]
|
|
61
|
+
|
|
62
|
+
breaks = np.array([], dtype=float)
|
|
63
|
+
relevant_count = 0
|
|
64
|
+
powers = np.arange(int(lo), int(hi) + 1)
|
|
65
|
+
|
|
66
|
+
while candidate_pool:
|
|
67
|
+
# Pick the candidate that maximises the minimum log spacing.
|
|
68
|
+
deltas = [_delta(c) for c in candidate_pool]
|
|
69
|
+
best_idx = int(np.argmax(deltas))
|
|
70
|
+
steps.append(float(candidate_pool.pop(best_idx)))
|
|
71
|
+
|
|
72
|
+
# Regenerate break set: outer product of base^powers and steps.
|
|
73
|
+
breaks = np.sort(
|
|
74
|
+
np.asarray([b * s for b in base ** powers for s in steps], dtype=float)
|
|
75
|
+
)
|
|
76
|
+
mask = (base ** rng[0] <= breaks) & (breaks <= base ** rng[1])
|
|
77
|
+
relevant_count = int(np.sum(mask))
|
|
78
|
+
if relevant_count >= (n - 2):
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
if relevant_count >= (n - 2):
|
|
82
|
+
# Include one extra break on each side when available, mirroring
|
|
83
|
+
# R's `lower_end` / `upper_end` logic.
|
|
84
|
+
inside = np.where((base ** rng[0] <= breaks) & (breaks <= base ** rng[1]))[0]
|
|
85
|
+
if inside.size == 0:
|
|
86
|
+
return np.array([], dtype=float)
|
|
87
|
+
lower_end = max(int(inside.min()) - 1, 0)
|
|
88
|
+
upper_end = min(int(inside.max()) + 1, len(breaks) - 1)
|
|
89
|
+
return breaks[lower_end:upper_end + 1]
|
|
90
|
+
|
|
91
|
+
# Fallback: Wilkinson extended on the original (exp'd) range.
|
|
92
|
+
from .breaks import breaks_extended
|
|
93
|
+
return breaks_extended(n=n)(np.asarray([base ** rng[0], base ** rng[1]]))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def breaks_log(
|
|
97
|
+
n: int = 5,
|
|
98
|
+
base: float = 10,
|
|
99
|
+
) -> Callable[[ArrayLike], np.ndarray]:
|
|
100
|
+
"""Create a break generator for log-scaled axes.
|
|
101
|
+
|
|
102
|
+
Faithful port of R's ``breaks_log``:
|
|
103
|
+
|
|
104
|
+
1. Take ``range(x)`` ignoring non-finite. If not finite → empty.
|
|
105
|
+
2. Compute integer-power candidates ``base^seq(floor(lo), ceil(hi))``.
|
|
106
|
+
3. If fewer than ``n - 2`` fall inside the data range, shrink the
|
|
107
|
+
step ``by`` from ``floor((max - min) / n) + 1`` down to 1; when
|
|
108
|
+
that's still not enough, delegate to ``log_sub_breaks``.
|
|
109
|
+
|
|
110
|
+
Parameters
|
|
111
|
+
----------
|
|
112
|
+
n : int, optional
|
|
113
|
+
Target number of breaks (default 5).
|
|
114
|
+
base : float, optional
|
|
115
|
+
Logarithm base (default 10).
|
|
116
|
+
|
|
117
|
+
Returns
|
|
118
|
+
-------
|
|
119
|
+
callable
|
|
120
|
+
A function ``(x) -> numpy.ndarray`` of break positions.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def _breaks(x: ArrayLike) -> np.ndarray:
|
|
124
|
+
x_arr = np.asarray(x, dtype=float)
|
|
125
|
+
finite = x_arr[np.isfinite(x_arr) & (x_arr > 0)]
|
|
126
|
+
if finite.size == 0:
|
|
127
|
+
return np.array([], dtype=float)
|
|
128
|
+
|
|
129
|
+
raw_rng = (float(np.min(finite)), float(np.max(finite)))
|
|
130
|
+
rng = (math.log(raw_rng[0], base), math.log(raw_rng[1], base))
|
|
131
|
+
lo = math.floor(rng[0])
|
|
132
|
+
hi = math.ceil(rng[1])
|
|
133
|
+
|
|
134
|
+
if hi == lo:
|
|
135
|
+
return np.array([base ** lo], dtype=float)
|
|
136
|
+
|
|
137
|
+
by = math.floor((hi - lo) / n) + 1
|
|
138
|
+
while by >= 1:
|
|
139
|
+
powers = np.arange(lo, hi + by, by)
|
|
140
|
+
breaks = base ** powers
|
|
141
|
+
mask = (base ** rng[0] <= breaks) & (breaks <= base ** rng[1])
|
|
142
|
+
if int(np.sum(mask)) >= (n - 2):
|
|
143
|
+
return breaks
|
|
144
|
+
by -= 1
|
|
145
|
+
|
|
146
|
+
return _log_sub_breaks(rng, n=n, base=base)
|
|
147
|
+
|
|
148
|
+
return _breaks
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
log_breaks = breaks_log
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# minor_breaks_log
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def minor_breaks_log(
|
|
159
|
+
detail: Optional[int] = None,
|
|
160
|
+
smallest: Optional[float] = None,
|
|
161
|
+
) -> Callable[[np.ndarray, np.ndarray, int], np.ndarray]:
|
|
162
|
+
"""Create a minor-break generator for log-10 axes.
|
|
163
|
+
|
|
164
|
+
Faithful port of R's ``minor_breaks_log``:
|
|
165
|
+
|
|
166
|
+
* ``detail`` must be one of ``{1, 5, 10}`` if given.
|
|
167
|
+
- ``10`` → tens ladder only (``10^k``).
|
|
168
|
+
- ``5`` → tens plus mid-decade ``5 * 10^k``.
|
|
169
|
+
- ``1`` → tens plus fives plus ones (``1..9 * 10^k``).
|
|
170
|
+
* When ``detail`` is ``None``, the function auto-selects based on
|
|
171
|
+
the number of decades covered: ``> 15`` → 10, ``> 8`` → 5, else 1.
|
|
172
|
+
* When any data value is ``<= 0`` (so a signed-log scale such as
|
|
173
|
+
``asinh`` is in use), the ladder is reflected about zero and a
|
|
174
|
+
``0`` tick is added.
|
|
175
|
+
|
|
176
|
+
Parameters
|
|
177
|
+
----------
|
|
178
|
+
detail : int, optional
|
|
179
|
+
One of 1, 5, or 10.
|
|
180
|
+
smallest : float, optional
|
|
181
|
+
Smallest absolute value to include when negatives are present.
|
|
182
|
+
Defaults to ``min(1, max(|x|)) * 0.1``.
|
|
183
|
+
|
|
184
|
+
Returns
|
|
185
|
+
-------
|
|
186
|
+
callable
|
|
187
|
+
Function ``(x, ...) -> numpy.ndarray``. The returned array
|
|
188
|
+
carries a ``.detail`` attribute (per-tick 10/5/1) mirroring R.
|
|
189
|
+
"""
|
|
190
|
+
if detail is not None and detail not in (1, 5, 10):
|
|
191
|
+
raise ValueError("detail must be one of 1, 5, or 10")
|
|
192
|
+
if smallest is not None:
|
|
193
|
+
if not np.isfinite(smallest) or smallest <= 0 or smallest < 1e-100:
|
|
194
|
+
raise ValueError(
|
|
195
|
+
"smallest must be a finite positive number >= 1e-100"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def _minor_breaks(x: np.ndarray, *args, **kwargs) -> np.ndarray:
|
|
199
|
+
x_arr = np.asarray(x, dtype=float)
|
|
200
|
+
has_negatives = bool(np.any(x_arr <= 0))
|
|
201
|
+
|
|
202
|
+
if has_negatives:
|
|
203
|
+
large = float(np.nanmax(np.abs(x_arr)))
|
|
204
|
+
small = smallest if smallest is not None else min(1.0, large) * 0.1
|
|
205
|
+
# Work with (small*10, large) as the effective positive range.
|
|
206
|
+
x_used = np.sort(np.array([small * 10.0, large]))
|
|
207
|
+
else:
|
|
208
|
+
x_used = x_arr[x_arr > 0]
|
|
209
|
+
small = None
|
|
210
|
+
|
|
211
|
+
start = int(math.floor(math.log10(np.min(x_used)))) - 1
|
|
212
|
+
end = int(math.ceil(math.log10(np.max(x_used)))) + 1
|
|
213
|
+
|
|
214
|
+
if detail is None:
|
|
215
|
+
# R: findInterval(abs(end - start), c(8, 15), left.open=TRUE) + 1
|
|
216
|
+
span = abs(end - start)
|
|
217
|
+
if span > 15:
|
|
218
|
+
chosen = 10
|
|
219
|
+
elif span > 8:
|
|
220
|
+
chosen = 5
|
|
221
|
+
else:
|
|
222
|
+
chosen = 1
|
|
223
|
+
else:
|
|
224
|
+
chosen = detail
|
|
225
|
+
|
|
226
|
+
ladder = np.array(
|
|
227
|
+
[10.0 ** p for p in range(start, end + 1)], dtype=float
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
tens = np.array([], dtype=float)
|
|
231
|
+
fives = np.array([], dtype=float)
|
|
232
|
+
ones = np.array([], dtype=float)
|
|
233
|
+
if chosen in (10, 5, 1):
|
|
234
|
+
tens = ladder
|
|
235
|
+
if chosen in (5, 1):
|
|
236
|
+
fives = 5.0 * ladder
|
|
237
|
+
if chosen == 1:
|
|
238
|
+
ones = np.array(
|
|
239
|
+
[m * p for p in ladder for m in range(1, 10)], dtype=float
|
|
240
|
+
)
|
|
241
|
+
ones = np.setdiff1d(ones, np.concatenate([tens, fives]))
|
|
242
|
+
|
|
243
|
+
if has_negatives:
|
|
244
|
+
tens = tens[tens >= small]
|
|
245
|
+
tens = np.concatenate([tens, -tens, np.array([0.0])])
|
|
246
|
+
fives = fives[fives >= small]
|
|
247
|
+
fives = np.concatenate([fives, -fives])
|
|
248
|
+
ones = ones[ones >= small]
|
|
249
|
+
ones = np.concatenate([ones, -ones])
|
|
250
|
+
|
|
251
|
+
ticks = np.concatenate([tens, fives, ones])
|
|
252
|
+
# R attaches detail codes (10/5/1) per tick via attr().
|
|
253
|
+
# numpy arrays don't take arbitrary attributes on the stock
|
|
254
|
+
# dtype, but subclassing works. We attach via np.ndarray view.
|
|
255
|
+
detail_codes = np.concatenate([
|
|
256
|
+
np.full(len(tens), 10, dtype=int),
|
|
257
|
+
np.full(len(fives), 5, dtype=int),
|
|
258
|
+
np.full(len(ones), 1, dtype=int),
|
|
259
|
+
])
|
|
260
|
+
result = ticks.view(np.ndarray)
|
|
261
|
+
try:
|
|
262
|
+
result = result.copy()
|
|
263
|
+
result.detail = detail_codes # type: ignore[attr-defined]
|
|
264
|
+
except AttributeError:
|
|
265
|
+
pass
|
|
266
|
+
return result
|
|
267
|
+
|
|
268
|
+
return _minor_breaks
|