plot-io 0.1.0__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.
- plot_io/__init__.py +66 -0
- plot_io/_backend.py +114 -0
- plot_io/axis.py +574 -0
- plot_io/colorbar.py +342 -0
- plot_io/decorations.py +496 -0
- plot_io/defaults.py +766 -0
- plot_io/density.py +97 -0
- plot_io/enums.py +63 -0
- plot_io/facets.py +184 -0
- plot_io/figure.py +331 -0
- plot_io/labels.py +63 -0
- plot_io/legend.py +498 -0
- plot_io/limits.py +92 -0
- plot_io/maps/__init__.py +365 -0
- plot_io/maps/data.py +333 -0
- plot_io/markers.py +117 -0
- plot_io/numbers.py +289 -0
- plot_io/occupancy.py +219 -0
- plot_io/palettes.py +142 -0
- plot_io/plot.py +435 -0
- plot_io/render.py +1122 -0
- plot_io/scales.py +93 -0
- plot_io/series/__init__.py +498 -0
- plot_io/series/area.py +181 -0
- plot_io/series/bar.py +294 -0
- plot_io/series/box.py +401 -0
- plot_io/series/catdata.py +23 -0
- plot_io/series/catpoints.py +182 -0
- plot_io/series/contour.py +294 -0
- plot_io/series/curves.py +128 -0
- plot_io/series/ecdf.py +143 -0
- plot_io/series/errorbar.py +111 -0
- plot_io/series/errors.py +100 -0
- plot_io/series/fill.py +168 -0
- plot_io/series/heatmap.py +304 -0
- plot_io/series/hexbin.py +216 -0
- plot_io/series/hist.py +379 -0
- plot_io/series/hist2d.py +163 -0
- plot_io/series/kde.py +228 -0
- plot_io/series/kde2d.py +153 -0
- plot_io/series/line.py +173 -0
- plot_io/series/pie.py +304 -0
- plot_io/series/points.py +341 -0
- plot_io/series/regression.py +250 -0
- plot_io/series/scatter.py +103 -0
- plot_io/series/stem.py +112 -0
- plot_io/series/violin.py +321 -0
- plot_io/settings.py +281 -0
- plot_io/shapes.py +57 -0
- plot_io/sharing.py +160 -0
- plot_io/slots.py +52 -0
- plot_io/stacking.py +63 -0
- plot_io/stats.py +140 -0
- plot_io/style.py +71 -0
- plot_io/styles/__init__.py +234 -0
- plot_io/styles/library.py +732 -0
- plot_io/tables.py +141 -0
- plot_io/text.py +109 -0
- plot_io/ticks.py +284 -0
- plot_io/times.py +199 -0
- plot_io/values.py +161 -0
- plot_io-0.1.0.dist-info/METADATA +102 -0
- plot_io-0.1.0.dist-info/RECORD +65 -0
- plot_io-0.1.0.dist-info/WHEEL +4 -0
- plot_io-0.1.0.dist-info/licenses/LICENSE +28 -0
plot_io/numbers.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Numbers as text: one formatter for tick labels, colorbars and heatmap cell labels.
|
|
2
|
+
|
|
3
|
+
``fmt`` is ``"auto"``, a format string, a callable, or a preset (``"percent"``,
|
|
4
|
+
``"sci"``, ``"eng"``, ``"si"``, ``"int"``). A :class:`NumberStyle` carries the decimal
|
|
5
|
+
and thousands separators, the minus sign, a prefix and suffix, and the precision.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import decimal
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Callable, Sequence
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
__all__ = ["NumberStyle", "format_numbers", "format_axis", "PRESETS", "check_format",
|
|
15
|
+
"NUMBER_SETTINGS"]
|
|
16
|
+
|
|
17
|
+
PRESETS = ("auto", "percent", "sci", "eng", "si", "int")
|
|
18
|
+
_SI = {-24: "y", -21: "z", -18: "a", -15: "f", -12: "p", -9: "n", -6: "µ", -3: "m",
|
|
19
|
+
0: "", 3: "k", 6: "M", 9: "G", 12: "T", 15: "P", 18: "E", 21: "Z", 24: "Y"}
|
|
20
|
+
_MINUS = {"hyphen": "-", "minus": "−"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check_format(v: Any) -> Any:
|
|
24
|
+
if callable(v):
|
|
25
|
+
return v
|
|
26
|
+
if isinstance(v, str):
|
|
27
|
+
key = v.strip().lower()
|
|
28
|
+
if key in PRESETS:
|
|
29
|
+
return key
|
|
30
|
+
if "%" in v and "{" not in v: # strftime pattern (time axes)
|
|
31
|
+
return v
|
|
32
|
+
if "{" in v:
|
|
33
|
+
try:
|
|
34
|
+
v.format(1.5)
|
|
35
|
+
except (IndexError, KeyError, ValueError) as e:
|
|
36
|
+
raise ValueError(f"invalid format string {v!r}: {e}") from None
|
|
37
|
+
return v
|
|
38
|
+
raise ValueError(f"expected 'auto', a preset ({', '.join(PRESETS[1:])}), a format "
|
|
39
|
+
f"string or a function, got {v!r}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class NumberStyle:
|
|
43
|
+
"""Separators, minus sign, affixes and precision of formatted numbers."""
|
|
44
|
+
|
|
45
|
+
__slots__ = ("decimal", "thousands", "minus", "prefix", "suffix", "precision")
|
|
46
|
+
|
|
47
|
+
def __init__(self, decimal_separator: str = ".", thousands_separator: str = "",
|
|
48
|
+
minus: str = "hyphen", prefix: str = "", suffix: str = "",
|
|
49
|
+
precision: int | None = None) -> None:
|
|
50
|
+
self.decimal = decimal_separator
|
|
51
|
+
self.thousands = thousands_separator
|
|
52
|
+
self.minus = _MINUS[minus]
|
|
53
|
+
self.prefix = prefix
|
|
54
|
+
self.suffix = suffix
|
|
55
|
+
self.precision = precision
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def of(cls, obj: Any) -> "NumberStyle":
|
|
59
|
+
"""The style from an object's (inherited) settings."""
|
|
60
|
+
return cls(obj.get("decimal_separator"), obj.get("thousands_separator"),
|
|
61
|
+
obj.get("minus"), obj.get("prefix"), obj.get("suffix"),
|
|
62
|
+
obj.get("precision"))
|
|
63
|
+
|
|
64
|
+
def digits(self, s: str) -> str:
|
|
65
|
+
"""A plain decimal string (``-1234.5``) with this style's signs and separators."""
|
|
66
|
+
neg = s.startswith("-")
|
|
67
|
+
if neg:
|
|
68
|
+
s = s[1:]
|
|
69
|
+
whole, _, frac = s.partition(".")
|
|
70
|
+
if self.thousands and len(whole) > 3:
|
|
71
|
+
groups = []
|
|
72
|
+
while len(whole) > 3:
|
|
73
|
+
groups.append(whole[-3:])
|
|
74
|
+
whole = whole[:-3]
|
|
75
|
+
groups.append(whole)
|
|
76
|
+
whole = self.thousands.join(reversed(groups))
|
|
77
|
+
out = whole + (self.decimal + frac if frac else "")
|
|
78
|
+
return (self.minus if neg else "") + out
|
|
79
|
+
|
|
80
|
+
def exponent(self, k: int) -> str:
|
|
81
|
+
return (self.minus if k < 0 else "") + str(abs(k))
|
|
82
|
+
|
|
83
|
+
def wrap(self, label: Any) -> Any:
|
|
84
|
+
if not (self.prefix or self.suffix):
|
|
85
|
+
return label
|
|
86
|
+
if isinstance(label, str):
|
|
87
|
+
return self.prefix + label + self.suffix
|
|
88
|
+
return [self.prefix] + list(label) + [self.suffix]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _fixed(v: float, d: int) -> str:
|
|
92
|
+
"""``v`` with ``d`` decimals, rounding halves away from zero on the shortest decimal
|
|
93
|
+
form (1234.5 → 1235, 2.675 → 2.68)."""
|
|
94
|
+
if not math.isfinite(v):
|
|
95
|
+
return str(v)
|
|
96
|
+
q = decimal.Decimal(repr(float(v))).quantize(decimal.Decimal(1).scaleb(-d),
|
|
97
|
+
rounding=decimal.ROUND_HALF_UP)
|
|
98
|
+
s = f"{q:f}"
|
|
99
|
+
if s.startswith("-") and float(s) == 0:
|
|
100
|
+
s = s[1:]
|
|
101
|
+
return s
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _decimals(values: Sequence[float], precision: int | None, cap: int = 12) -> int:
|
|
105
|
+
"""The fewest decimals telling the values apart and representing them (or giving
|
|
106
|
+
``precision`` significant digits to the largest)."""
|
|
107
|
+
finite = [v for v in values if math.isfinite(v)]
|
|
108
|
+
if not finite:
|
|
109
|
+
return 0
|
|
110
|
+
big = max(abs(v) for v in finite)
|
|
111
|
+
if precision is not None:
|
|
112
|
+
if big == 0:
|
|
113
|
+
return 0
|
|
114
|
+
e = math.floor(math.log10(big))
|
|
115
|
+
d = precision - 1 - e
|
|
116
|
+
if abs(float(_fixed(big, max(0, d)))) >= 10.0 ** (e + 1):
|
|
117
|
+
d -= 1 # rounding reached the next power (9.96 → 10)
|
|
118
|
+
return max(0, min(cap, d))
|
|
119
|
+
span = max(finite) - min(finite)
|
|
120
|
+
tol = max(1e-12, 1e-9 * big, span * 1e-6)
|
|
121
|
+
for d in range(cap + 1):
|
|
122
|
+
labels = [_fixed(v, d) for v in finite]
|
|
123
|
+
if len(set(labels)) == len(set(finite)) and all(abs(float(s) - v) <= tol
|
|
124
|
+
for s, v in zip(labels, finite)):
|
|
125
|
+
return d
|
|
126
|
+
return cap
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _superscript(text: str) -> Any:
|
|
130
|
+
from .text import superscript
|
|
131
|
+
return superscript(text)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _exp_form(values: Sequence[float], style: NumberStyle, step: int,
|
|
135
|
+
si: bool = False) -> list[Any]:
|
|
136
|
+
"""Mantissa × 10ᵏ labels (``step`` 1: sci, 3: eng) or SI prefixes."""
|
|
137
|
+
parts: list[tuple[float, int] | None] = []
|
|
138
|
+
for v in values:
|
|
139
|
+
if not math.isfinite(v):
|
|
140
|
+
parts.append(None)
|
|
141
|
+
elif v == 0:
|
|
142
|
+
parts.append((0.0, 0))
|
|
143
|
+
else:
|
|
144
|
+
k = math.floor(math.log10(abs(v)) / step) * step
|
|
145
|
+
parts.append((v / 10.0 ** k, k))
|
|
146
|
+
mants = [p[0] for p in parts if p is not None]
|
|
147
|
+
prec = style.precision
|
|
148
|
+
if prec is None:
|
|
149
|
+
d = _decimals(mants, None, 10)
|
|
150
|
+
elif step == 1:
|
|
151
|
+
d = prec - 1
|
|
152
|
+
else:
|
|
153
|
+
bigm = max((abs(m) for m in mants), default=0.0)
|
|
154
|
+
d = max(0, prec - 1 - math.floor(math.log10(bigm))) if bigm else 0
|
|
155
|
+
out: list[Any] = []
|
|
156
|
+
for p in parts:
|
|
157
|
+
if p is None:
|
|
158
|
+
out.append("")
|
|
159
|
+
continue
|
|
160
|
+
m, k = p
|
|
161
|
+
dd = d
|
|
162
|
+
s = _fixed(m, dd)
|
|
163
|
+
if abs(float(s)) >= 10 ** step and m: # rounding reached the next power
|
|
164
|
+
m, k = m / 10 ** step, k + step
|
|
165
|
+
if prec is not None and step == 3:
|
|
166
|
+
dd = prec - 1 # the rounded mantissa is 1
|
|
167
|
+
s = _fixed(m, dd)
|
|
168
|
+
if si and k in _SI:
|
|
169
|
+
out.append(style.wrap(style.digits(s) + _SI[k]))
|
|
170
|
+
elif k == 0 or float(s) == 0:
|
|
171
|
+
out.append(style.wrap(style.digits(s)))
|
|
172
|
+
else:
|
|
173
|
+
out.append(style.wrap([style.digits(s) + "×10", _superscript(style.exponent(k))]))
|
|
174
|
+
return out
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
_NUMBER = re.compile(r"(-?)(\d{1,3}(?:,\d{3})+|\d+)(?:\.(\d+))?")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _restyle(text: str, style: NumberStyle) -> str:
|
|
181
|
+
"""Separators and minus applied to the numbers inside a formatted string."""
|
|
182
|
+
def sub(m: re.Match) -> str:
|
|
183
|
+
whole = m.group(2)
|
|
184
|
+
commas = "," in whole
|
|
185
|
+
whole = whole.replace(",", "")
|
|
186
|
+
s = (m.group(1) or "") + whole + ("." + m.group(3) if m.group(3) else "")
|
|
187
|
+
if commas and not style.thousands:
|
|
188
|
+
return NumberStyle(style.decimal, ",", "hyphen" if style.minus == "-" else "minus"
|
|
189
|
+
).digits(s)
|
|
190
|
+
return style.digits(s)
|
|
191
|
+
return _NUMBER.sub(sub, text)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def format_axis(values: Sequence[float], fmt: Any = "auto", style: NumberStyle | None = None,
|
|
195
|
+
sci_limits: tuple[int, int] | None = (-4, 5)) -> tuple[list[Any], int]:
|
|
196
|
+
"""Labels of a set of values formatted together, and the exponent of a common ×10ᵏ
|
|
197
|
+
factor (``"auto"`` beyond ``10^sci_limits``; 0: none). ``sci_limits=None``: no
|
|
198
|
+
factor, the labels carry their exponent (``"sci"``) instead."""
|
|
199
|
+
style = style or NumberStyle()
|
|
200
|
+
vals = list(values)
|
|
201
|
+
finite = [v for v in vals if math.isfinite(v)]
|
|
202
|
+
if callable(fmt):
|
|
203
|
+
out = []
|
|
204
|
+
for v in vals:
|
|
205
|
+
if not math.isfinite(v):
|
|
206
|
+
out.append("")
|
|
207
|
+
continue
|
|
208
|
+
r = fmt(v)
|
|
209
|
+
out.append(style.wrap(_restyle(r, style)) if isinstance(r, str) else r)
|
|
210
|
+
return out, 0
|
|
211
|
+
if fmt not in PRESETS:
|
|
212
|
+
return [style.wrap(_restyle(fmt.format(v), style)) if math.isfinite(v) else ""
|
|
213
|
+
for v in vals], 0
|
|
214
|
+
if fmt == "int":
|
|
215
|
+
return [style.wrap(style.digits(_fixed(math.copysign(math.floor(abs(v) + 0.5), v), 0)))
|
|
216
|
+
if math.isfinite(v) else "" for v in vals], 0
|
|
217
|
+
if fmt == "sci":
|
|
218
|
+
return _exp_form(vals, style, 1), 0
|
|
219
|
+
if fmt == "eng":
|
|
220
|
+
return _exp_form(vals, style, 3), 0
|
|
221
|
+
if fmt == "si":
|
|
222
|
+
return _exp_form(vals, style, 3, si=True), 0
|
|
223
|
+
scale = 100.0 if fmt == "percent" else 1.0
|
|
224
|
+
exp = 0
|
|
225
|
+
big = max((abs(v) for v in finite), default=0.0) * scale
|
|
226
|
+
if fmt == "auto" and big:
|
|
227
|
+
if sci_limits is None:
|
|
228
|
+
pass
|
|
229
|
+
elif big >= 10.0 ** sci_limits[1] or big < 10.0 ** sci_limits[0]:
|
|
230
|
+
exp = math.floor(math.log10(big))
|
|
231
|
+
k = scale * 10.0 ** -exp
|
|
232
|
+
scaled = [v * k for v in vals]
|
|
233
|
+
d = _decimals(scaled, style.precision)
|
|
234
|
+
pct = "%" if fmt == "percent" else ""
|
|
235
|
+
return [style.wrap(style.digits(_fixed(v, d)) + pct) if math.isfinite(v) else ""
|
|
236
|
+
for v in scaled], exp
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def format_numbers(values: Sequence[float], fmt: Any = "auto",
|
|
240
|
+
settings: Any = None) -> list[Any]:
|
|
241
|
+
"""Labels of values formatted together; ``settings`` is a :class:`NumberStyle` or an
|
|
242
|
+
object with the number settings. ``"auto"`` beyond 10⁵ or below 10⁻⁴ writes each
|
|
243
|
+
label in scientific form (there is no axis to carry a common factor)."""
|
|
244
|
+
style = settings if isinstance(settings, NumberStyle) or settings is None \
|
|
245
|
+
else NumberStyle.of(settings)
|
|
246
|
+
style = style or NumberStyle()
|
|
247
|
+
labels, exp = format_axis(values, fmt, style)
|
|
248
|
+
if exp:
|
|
249
|
+
return _exp_form(list(values), style, 1)
|
|
250
|
+
return labels
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _check_minus(v: Any) -> str:
|
|
254
|
+
if v not in _MINUS:
|
|
255
|
+
raise ValueError(f"expected 'hyphen' or 'minus', got {v!r}")
|
|
256
|
+
return v
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _check_precision(v: Any) -> int:
|
|
260
|
+
if isinstance(v, bool) or not isinstance(v, int) or not 1 <= v <= 15:
|
|
261
|
+
raise ValueError(f"expected an integer from 1 to 15, got {v!r}")
|
|
262
|
+
return v
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _check_str(v: Any) -> str:
|
|
266
|
+
if not isinstance(v, str):
|
|
267
|
+
raise ValueError(f"expected a string, got {v!r}")
|
|
268
|
+
return v
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def number_settings() -> dict[str, Any]:
|
|
272
|
+
"""The inherited number settings, for a ``SETTINGS`` table."""
|
|
273
|
+
from .settings import Setting
|
|
274
|
+
return {
|
|
275
|
+
"decimal_separator": Setting(_check_str, inherited=True,
|
|
276
|
+
doc="Decimal separator of numbers (`\".\"`)."),
|
|
277
|
+
"thousands_separator": Setting(_check_str, inherited=True,
|
|
278
|
+
doc="Thousands separator (`\"\"`: none)."),
|
|
279
|
+
"minus": Setting(_check_minus, inherited=True,
|
|
280
|
+
doc="`hyphen` (`-`) or `minus` (U+2212, from an installed font)."),
|
|
281
|
+
"prefix": Setting(_check_str, inherited=True, doc="Text before every number."),
|
|
282
|
+
"suffix": Setting(_check_str, inherited=True, doc="Text after every number."),
|
|
283
|
+
"precision": Setting(_check_precision, inherited=True,
|
|
284
|
+
doc="Significant digits of `auto`, `sci`, `eng`, `si` "
|
|
285
|
+
"(unset: as needed)."),
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
NUMBER_SETTINGS: Callable[[], dict[str, Any]] = number_settings
|
plot_io/occupancy.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""What the series cover inside the axes, on a coarse grid: used to place legends."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pdf_io import arrays as _arrays
|
|
7
|
+
|
|
8
|
+
__all__ = ["Occupancy", "thin", "N", "MAX_VERTICES", "MAX_POINTS"]
|
|
9
|
+
|
|
10
|
+
#: Cells across each side of the axes.
|
|
11
|
+
N = 64
|
|
12
|
+
#: Polylines and curves are thinned to at most this many vertices before they are marked.
|
|
13
|
+
MAX_VERTICES = 4096
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
#: Points beyond this many are sampled (every k-th) before they are marked.
|
|
17
|
+
MAX_POINTS = 50_000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _stride(n: int) -> int:
|
|
21
|
+
return max(1, -(-n // MAX_VERTICES))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def thin(*columns: Any, limit: int = MAX_POINTS) -> tuple[Any, ...]:
|
|
25
|
+
"""Every k-th item of each column, so that at most ``limit`` are left (data are thinned
|
|
26
|
+
before they are mapped to the page, which keeps legend placement fast)."""
|
|
27
|
+
n = min(len(c) for c in columns)
|
|
28
|
+
k = max(1, -(-n // limit))
|
|
29
|
+
if k == 1:
|
|
30
|
+
return columns
|
|
31
|
+
return tuple(c[:n:k] for c in columns)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Occupancy:
|
|
35
|
+
"""An ``N`` × ``N`` grid of cells over the axes rectangle (page units); a cell is
|
|
36
|
+
occupied when some series draws in it."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, rect: tuple[float, float, float, float]) -> None:
|
|
39
|
+
x0, y0, x1, y1 = rect
|
|
40
|
+
self.x0, self.y0 = x0, y0
|
|
41
|
+
self.cw = max(x1 - x0, 1e-9) / N
|
|
42
|
+
self.ch = max(y1 - y0, 1e-9) / N
|
|
43
|
+
self.cells = bytearray(N * N)
|
|
44
|
+
self._xe = [x0 + i * self.cw for i in range(N + 1)]
|
|
45
|
+
self._ye = [y0 + j * self.ch for j in range(N + 1)]
|
|
46
|
+
|
|
47
|
+
# -- marking ---------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def _col(self, x: float) -> int:
|
|
50
|
+
return min(N - 1, max(0, int((x - self.x0) / self.cw)))
|
|
51
|
+
|
|
52
|
+
def _row(self, y: float) -> int:
|
|
53
|
+
return min(N - 1, max(0, int((y - self.y0) / self.ch)))
|
|
54
|
+
|
|
55
|
+
def _span(self, r: int, c0: int, c1: int) -> None:
|
|
56
|
+
if c1 >= c0:
|
|
57
|
+
self.cells[r * N + c0:r * N + c1 + 1] = b"\x01" * (c1 - c0 + 1)
|
|
58
|
+
|
|
59
|
+
def points(self, px: Any, py: Any, radius: float = 0.0) -> None:
|
|
60
|
+
"""Marks the cells holding points (counted in C; points outside are ignored), and
|
|
61
|
+
the cells within ``radius`` (pt, a marker's half size) of those."""
|
|
62
|
+
if not len(px):
|
|
63
|
+
return
|
|
64
|
+
_, _, counts = _arrays.histogram2d(px, py, self._xe, self._ye)
|
|
65
|
+
kx = int(radius / self.cw + 0.5)
|
|
66
|
+
ky = int(radius / self.ch + 0.5)
|
|
67
|
+
cells = self.cells
|
|
68
|
+
for k, c in enumerate(counts):
|
|
69
|
+
if not c:
|
|
70
|
+
continue
|
|
71
|
+
if not kx and not ky:
|
|
72
|
+
cells[k] = 1
|
|
73
|
+
continue
|
|
74
|
+
r, col = divmod(k, N)
|
|
75
|
+
c0, c1 = max(0, col - kx), min(N - 1, col + kx)
|
|
76
|
+
for rr in range(max(0, r - ky), min(N - 1, r + ky) + 1):
|
|
77
|
+
self._span(rr, c0, c1)
|
|
78
|
+
|
|
79
|
+
def lines(self, px: Any, py: Any) -> None:
|
|
80
|
+
"""Marks the cells a polyline passes through (NaN breaks it); segments are sampled
|
|
81
|
+
at half a cell, so long segments count along their whole length (at most
|
|
82
|
+
``8 · MAX_VERTICES`` samples in all, spread by length)."""
|
|
83
|
+
n = min(len(px), len(py))
|
|
84
|
+
if n == 0:
|
|
85
|
+
return
|
|
86
|
+
s = _stride(n)
|
|
87
|
+
verts = [(px[i], py[i]) for i in range(0, n, s)]
|
|
88
|
+
step = 0.5 * min(self.cw, self.ch)
|
|
89
|
+
counts = []
|
|
90
|
+
for (x0, y0), (x1, y1) in zip(verts, verts[1:]):
|
|
91
|
+
d = max(abs(x1 - x0), abs(y1 - y0))
|
|
92
|
+
counts.append(int(d / step) if d == d else -1)
|
|
93
|
+
total = sum(c for c in counts if c > 0)
|
|
94
|
+
budget = 8 * MAX_VERTICES
|
|
95
|
+
scale = budget / total if total > budget else 1.0
|
|
96
|
+
xs, ys = [], []
|
|
97
|
+
for (x0, y0), (x1, y1), k in zip(verts, verts[1:], counts):
|
|
98
|
+
if k < 0:
|
|
99
|
+
continue
|
|
100
|
+
k = int(k * scale)
|
|
101
|
+
for j in range(k + 1):
|
|
102
|
+
t = j / (k + 1)
|
|
103
|
+
xs.append(x0 + (x1 - x0) * t)
|
|
104
|
+
ys.append(y0 + (y1 - y0) * t)
|
|
105
|
+
x, y = verts[-1]
|
|
106
|
+
if x == x and y == y:
|
|
107
|
+
xs.append(x)
|
|
108
|
+
ys.append(y)
|
|
109
|
+
self.points(xs, ys)
|
|
110
|
+
|
|
111
|
+
def rects(self, x0: Any, y0: Any, x1: Any, y1: Any) -> None:
|
|
112
|
+
"""Marks the cells under rectangles (any corner order; NaN rectangles skipped)."""
|
|
113
|
+
X0, Y0 = self.x0, self.y0
|
|
114
|
+
X1, Y1 = X0 + N * self.cw, Y0 + N * self.ch
|
|
115
|
+
for a, b, c, d in zip(x0, y0, x1, y1):
|
|
116
|
+
if a != a or b != b or c != c or d != d:
|
|
117
|
+
continue
|
|
118
|
+
a, c = min(a, c), max(a, c)
|
|
119
|
+
b, d = min(b, d), max(b, d)
|
|
120
|
+
if c < X0 or a > X1 or d < Y0 or b > Y1:
|
|
121
|
+
continue
|
|
122
|
+
c0, c1 = self._col(a), self._col(c)
|
|
123
|
+
for r in range(self._row(b), self._row(d) + 1):
|
|
124
|
+
self._span(r, c0, c1)
|
|
125
|
+
|
|
126
|
+
def band(self, t: Any, lo: Any, hi: Any, horizontal: bool = False) -> None:
|
|
127
|
+
"""Marks the region between two curves sharing the coordinate ``t`` (page x, or page
|
|
128
|
+
y when ``horizontal``): per grid column (row), from the least to the largest value
|
|
129
|
+
of the samples in it, with samples added at half a cell along ``t``."""
|
|
130
|
+
n = min(len(t), len(lo), len(hi))
|
|
131
|
+
if n == 0:
|
|
132
|
+
return
|
|
133
|
+
size = self.ch if horizontal else self.cw
|
|
134
|
+
index = self._row if horizontal else self._col
|
|
135
|
+
mins = [math.inf] * N
|
|
136
|
+
maxs = [-math.inf] * N
|
|
137
|
+
s = _stride(n)
|
|
138
|
+
prev: tuple[float, float, float] | None = None
|
|
139
|
+
|
|
140
|
+
def put(tv: float, a: float, b: float) -> None:
|
|
141
|
+
k = index(tv)
|
|
142
|
+
lo_, hi_ = (a, b) if a <= b else (b, a)
|
|
143
|
+
if lo_ < mins[k]:
|
|
144
|
+
mins[k] = lo_
|
|
145
|
+
if hi_ > maxs[k]:
|
|
146
|
+
maxs[k] = hi_
|
|
147
|
+
|
|
148
|
+
for i in range(0, n, s):
|
|
149
|
+
tv, a, b = t[i], lo[i], hi[i]
|
|
150
|
+
if tv != tv or a != a or b != b:
|
|
151
|
+
prev = None
|
|
152
|
+
continue
|
|
153
|
+
if prev is not None:
|
|
154
|
+
k = int(abs(tv - prev[0]) / (0.5 * size))
|
|
155
|
+
for j in range(1, k + 1):
|
|
156
|
+
f = j / (k + 1)
|
|
157
|
+
put(prev[0] + (tv - prev[0]) * f, prev[1] + (a - prev[1]) * f,
|
|
158
|
+
prev[2] + (b - prev[2]) * f)
|
|
159
|
+
put(tv, a, b)
|
|
160
|
+
prev = (tv, a, b)
|
|
161
|
+
T0 = self.y0 if horizontal else self.x0
|
|
162
|
+
T1 = T0 + N * size
|
|
163
|
+
for k in range(N):
|
|
164
|
+
if mins[k] > maxs[k]:
|
|
165
|
+
continue
|
|
166
|
+
centre = T0 + (k + 0.5) * size
|
|
167
|
+
if not T0 <= centre <= T1:
|
|
168
|
+
continue
|
|
169
|
+
if horizontal:
|
|
170
|
+
self._span(k, self._col(mins[k]), self._col(maxs[k]))
|
|
171
|
+
else:
|
|
172
|
+
for r in range(self._row(mins[k]), self._row(maxs[k]) + 1):
|
|
173
|
+
self.cells[r * N + k] = 1
|
|
174
|
+
|
|
175
|
+
# -- queries ---------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def _sums(self) -> list[int]:
|
|
178
|
+
"""Summed-area table: ``s[(r) * (N + 1) + c]`` = occupied cells above and left."""
|
|
179
|
+
s = [0] * ((N + 1) * (N + 1))
|
|
180
|
+
cells = self.cells
|
|
181
|
+
for r in range(N):
|
|
182
|
+
run = 0
|
|
183
|
+
base, prev = (r + 1) * (N + 1), r * (N + 1)
|
|
184
|
+
for c in range(N):
|
|
185
|
+
run += cells[r * N + c]
|
|
186
|
+
s[base + c + 1] = s[prev + c + 1] + run
|
|
187
|
+
return s
|
|
188
|
+
|
|
189
|
+
def covered(self, x: float, y: float, w: float, h: float, sums: list[int] | None = None) -> float:
|
|
190
|
+
"""Fraction of the cells whose centres lie in the box ``(x, y, w, h)`` that are
|
|
191
|
+
occupied (0 when the box holds no cell centre). ``sums`` (from :meth:`_sums`)
|
|
192
|
+
makes repeated queries constant time."""
|
|
193
|
+
c0 = max(0, math.ceil((x - self.x0) / self.cw - 0.5))
|
|
194
|
+
c1 = min(N - 1, math.floor((x + w - self.x0) / self.cw - 0.5))
|
|
195
|
+
r0 = max(0, math.ceil((y - self.y0) / self.ch - 0.5))
|
|
196
|
+
r1 = min(N - 1, math.floor((y + h - self.y0) / self.ch - 0.5))
|
|
197
|
+
if c1 < c0 or r1 < r0:
|
|
198
|
+
return 0.0
|
|
199
|
+
if sums is None:
|
|
200
|
+
hit = sum(self.cells[r * N + c0:r * N + c1 + 1].count(1) for r in range(r0, r1 + 1))
|
|
201
|
+
else:
|
|
202
|
+
M = N + 1
|
|
203
|
+
hit = (sums[(r1 + 1) * M + c1 + 1] - sums[r0 * M + c1 + 1]
|
|
204
|
+
- sums[(r1 + 1) * M + c0] + sums[r0 * M + c0])
|
|
205
|
+
return hit / ((c1 - c0 + 1) * (r1 - r0 + 1))
|
|
206
|
+
|
|
207
|
+
def free_boxes(self, w: float, h: float, pad: float) -> list[tuple[float, float]]:
|
|
208
|
+
"""Top-left corners, one cell apart, of the boxes ``w`` × ``h`` inside the axes
|
|
209
|
+
(``pad`` from their edges) that cover no occupied cell."""
|
|
210
|
+
sums = self._sums()
|
|
211
|
+
x_lo, x_hi = self.x0 + pad, self.x0 + N * self.cw - pad - w
|
|
212
|
+
y_lo, y_hi = self.y0 + pad, self.y0 + N * self.ch - pad - h
|
|
213
|
+
if x_hi < x_lo or y_hi < y_lo:
|
|
214
|
+
return []
|
|
215
|
+
nx = int((x_hi - x_lo) / self.cw)
|
|
216
|
+
ny = int((y_hi - y_lo) / self.ch)
|
|
217
|
+
xs = [x_lo + i * self.cw for i in range(nx + 1)] + [x_hi]
|
|
218
|
+
ys = [y_lo + j * self.ch for j in range(ny + 1)] + [y_hi]
|
|
219
|
+
return [(x, y) for y in ys for x in xs if self.covered(x, y, w, h, sums) == 0.0]
|
plot_io/palettes.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Palettes: the colors series take in turn.
|
|
2
|
+
|
|
3
|
+
A palette is a list of colors, a registered name, or a function ``n -> colors`` called
|
|
4
|
+
with the number of series that need a color (ggplot2's evenly spaced hues depend on it).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
__all__ = ["register_palette", "palette_names", "palette_colors", "check_palette", "hue"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _luv_to_hex(L: float, C: float, H: float) -> str:
|
|
15
|
+
"""CIE LCh(uv) (D65) to sRGB hex, as R's ``hcl()`` (channels clamped)."""
|
|
16
|
+
h = math.radians(H)
|
|
17
|
+
U, V = C * math.cos(h), C * math.sin(h)
|
|
18
|
+
xn, yn, zn = 95.047, 100.0, 108.883
|
|
19
|
+
un = 4 * xn / (xn + 15 * yn + 3 * zn)
|
|
20
|
+
vn = 9 * yn / (xn + 15 * yn + 3 * zn)
|
|
21
|
+
Y = yn * (((L + 16) / 116) ** 3 if L > 8 else L / 903.3)
|
|
22
|
+
u = U / (13 * L) + un
|
|
23
|
+
v = V / (13 * L) + vn
|
|
24
|
+
X = 9.0 * Y * u / (4 * v)
|
|
25
|
+
Z = -X / 3 - 5 * Y + 3 * Y / v
|
|
26
|
+
X, Y, Z = X / 100, Y / 100, Z / 100
|
|
27
|
+
rgb = (3.240479 * X - 1.537150 * Y - 0.498535 * Z,
|
|
28
|
+
-0.969256 * X + 1.875992 * Y + 0.041556 * Z,
|
|
29
|
+
0.055648 * X - 0.204043 * Y + 1.057311 * Z)
|
|
30
|
+
|
|
31
|
+
def gamma(c: float) -> float:
|
|
32
|
+
c = 1.055 * c ** (1 / 2.4) - 0.055 if c > 0.0031308 else 12.92 * c
|
|
33
|
+
return min(1.0, max(0.0, c))
|
|
34
|
+
|
|
35
|
+
return "#" + "".join("%02X" % round(255 * gamma(c)) for c in rgb)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def hue(n: int, c: float = 100.0, lum: float = 65.0, start: float = 15.0) -> tuple[str, ...]:
|
|
39
|
+
"""ggplot2's default discrete palette (``scales::hue_pal()``): ``n`` hues evenly
|
|
40
|
+
spaced from ``start`` degrees, at chroma ``c`` and luminance ``lum``."""
|
|
41
|
+
return tuple(_luv_to_hex(lum, c, start + 360.0 * i / n) for i in range(max(n, 1)))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _rgb(r: float, g: float, b: float) -> str:
|
|
45
|
+
return "#%02X%02X%02X" % tuple(round(255 * v) for v in (r, g, b))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_PALETTES: dict[str, Any] = {
|
|
49
|
+
# plot_io's default: Okabe–Ito, extended with indigo and dark brown
|
|
50
|
+
"okabe_ito": ("#0072B2", "#E69F00", "#009E73", "#D55E00", "#CC79A7", "#56B4E9",
|
|
51
|
+
"#F0E442", "#000000", "#332288", "#661100"),
|
|
52
|
+
# matplotlib ≥ 2.0 ("tab10", d3's category10)
|
|
53
|
+
"tab10": ("#1F77B4", "#FF7F0E", "#2CA02C", "#D62728", "#9467BD", "#8C564B", "#E377C2",
|
|
54
|
+
"#7F7F7F", "#BCBD22", "#17BECF"),
|
|
55
|
+
# matplotlib 1.x: b, g, r, c, m, y, k
|
|
56
|
+
"matplotlib_classic": ("#0000FF", "#008000", "#FF0000", "#00BFBF", "#BF00BF", "#BFBF00",
|
|
57
|
+
"#000000"),
|
|
58
|
+
# Vega-Lite (Tableau 10)
|
|
59
|
+
"tableau10": ("#4C78A8", "#F58518", "#E45756", "#72B7B2", "#54A24B", "#EECA3B",
|
|
60
|
+
"#B279A2", "#FF9DA6", "#9D755D", "#BAB0AC"),
|
|
61
|
+
# seaborn
|
|
62
|
+
"deep": ("#4C72B0", "#DD8452", "#55A868", "#C44E52", "#8172B3", "#937860", "#DA8BC3",
|
|
63
|
+
"#8C8C8C", "#CCB974", "#64B5CD"),
|
|
64
|
+
"muted": ("#4878D0", "#EE854A", "#6ACC64", "#D65F5F", "#956CB4", "#8C613C", "#DC7EC0",
|
|
65
|
+
"#797979", "#D5BB67", "#82C6E2"),
|
|
66
|
+
"pastel": ("#A1C9F4", "#FFB482", "#8DE5A1", "#FF9F9B", "#D0BBFF", "#DEBB9B", "#FAB0E4",
|
|
67
|
+
"#CFCFCF", "#FFFEA3", "#B9F2F0"),
|
|
68
|
+
"bright": ("#023EFF", "#FF7C00", "#1AC938", "#E8000B", "#8B2BE2", "#9F4800", "#F14CC1",
|
|
69
|
+
"#A3A3A3", "#FFC400", "#00D7FF"),
|
|
70
|
+
"dark": ("#001C7F", "#B1400D", "#12711C", "#8C0800", "#591E71", "#592F0D", "#A23582",
|
|
71
|
+
"#3C3C3C", "#B8850A", "#006374"),
|
|
72
|
+
"colorblind": ("#0173B2", "#DE8F05", "#029E73", "#D55E00", "#CC78BC", "#CA9161",
|
|
73
|
+
"#FBAFE4", "#949494", "#ECE133", "#56B4E9"),
|
|
74
|
+
# ggplot2 (n-dependent)
|
|
75
|
+
"hue": hue,
|
|
76
|
+
# MATLAB R2014b+ ("lines")
|
|
77
|
+
"matlab": tuple(_rgb(*c) for c in ((0, 0.4470, 0.7410), (0.8500, 0.3250, 0.0980),
|
|
78
|
+
(0.9290, 0.6940, 0.1250), (0.4940, 0.1840, 0.5560),
|
|
79
|
+
(0.4660, 0.6740, 0.1880), (0.3010, 0.7450, 0.9330),
|
|
80
|
+
(0.6350, 0.0780, 0.1840))),
|
|
81
|
+
# Chart.js 4 (colors plugin, border colors)
|
|
82
|
+
"chartjs": ("#36A2EB", "#FF6384", "#FF9F40", "#FFCD56", "#4BC0C0", "#9966FF", "#C9CBCF"),
|
|
83
|
+
# Observable Plot (d3's observable10)
|
|
84
|
+
"observable10": ("#4269D0", "#EFB118", "#FF725C", "#6CC5B0", "#3CA951", "#FF8AB7",
|
|
85
|
+
"#A463F2", "#97BBF5", "#9C6B4E", "#9498A0"),
|
|
86
|
+
# Excel 2013+ (Office 2013 theme accents, then their darker shades)
|
|
87
|
+
"office": ("#4472C4", "#ED7D31", "#A5A5A5", "#FFC000", "#5B9BD5", "#70AD47", "#264478",
|
|
88
|
+
"#9E480E", "#636363", "#997300"),
|
|
89
|
+
# The Economist (ggthemes economist_pal, pre-2017 chart colors)
|
|
90
|
+
"economist": ("#6794A7", "#014D64", "#76C0C1", "#01A2D9", "#7AD2F6", "#00887D",
|
|
91
|
+
"#ADADAD", "#7C260B", "#EE8F71", "#A18376"),
|
|
92
|
+
# FiveThirtyEight (matplotlib's fivethirtyeight style)
|
|
93
|
+
"fivethirtyeight": ("#008FD5", "#FC4F30", "#E5AE38", "#6D904F", "#8B8B8B", "#810F7C"),
|
|
94
|
+
# greys, for Tufte's style
|
|
95
|
+
"greys": ("#000000", "#666666", "#999999", "#CCCCCC"),
|
|
96
|
+
# plotly ("plotly" template colorway)
|
|
97
|
+
"plotly": ("#636EFA", "#EF553B", "#00CC96", "#AB63FA", "#FFA15A", "#19D3F3", "#FF6692",
|
|
98
|
+
"#B6E880", "#FF97FF", "#FECB52"),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def palette_names() -> list[str]:
|
|
103
|
+
return sorted(_PALETTES)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def register_palette(name: str, palette: Any) -> None:
|
|
107
|
+
"""Register a list of colors, or a function ``n -> colors``, as ``name``."""
|
|
108
|
+
_PALETTES[name.strip().lower()] = palette if callable(palette) else _colors(palette)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _colors(v: Any) -> tuple[Any, ...]:
|
|
112
|
+
from .settings import color
|
|
113
|
+
|
|
114
|
+
cols = tuple(color(c) for c in v)
|
|
115
|
+
if not cols:
|
|
116
|
+
raise ValueError("a palette needs at least one color")
|
|
117
|
+
return cols
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def check_palette(v: Any) -> Any:
|
|
121
|
+
"""A palette name, a function ``n -> colors``, or a list of colors."""
|
|
122
|
+
if isinstance(v, str):
|
|
123
|
+
key = v.strip().lower()
|
|
124
|
+
if key not in _PALETTES:
|
|
125
|
+
import difflib
|
|
126
|
+
near = difflib.get_close_matches(key, _PALETTES, n=1)
|
|
127
|
+
hint = f" (did you mean {near[0]!r}?)" if near else ""
|
|
128
|
+
raise ValueError(f"unknown palette {v!r}{hint}")
|
|
129
|
+
return key
|
|
130
|
+
if callable(v):
|
|
131
|
+
return v
|
|
132
|
+
return _colors(v)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def palette_colors(value: Any, n: int = 1) -> tuple[Any, ...]:
|
|
136
|
+
"""The colors of a palette for ``n`` series."""
|
|
137
|
+
if isinstance(value, str):
|
|
138
|
+
value = _PALETTES[value]
|
|
139
|
+
if callable(value):
|
|
140
|
+
f: Callable[[int], Any] = value
|
|
141
|
+
return _colors(f(max(n, 1)))
|
|
142
|
+
return tuple(value)
|