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.
Files changed (65) hide show
  1. plot_io/__init__.py +66 -0
  2. plot_io/_backend.py +114 -0
  3. plot_io/axis.py +574 -0
  4. plot_io/colorbar.py +342 -0
  5. plot_io/decorations.py +496 -0
  6. plot_io/defaults.py +766 -0
  7. plot_io/density.py +97 -0
  8. plot_io/enums.py +63 -0
  9. plot_io/facets.py +184 -0
  10. plot_io/figure.py +331 -0
  11. plot_io/labels.py +63 -0
  12. plot_io/legend.py +498 -0
  13. plot_io/limits.py +92 -0
  14. plot_io/maps/__init__.py +365 -0
  15. plot_io/maps/data.py +333 -0
  16. plot_io/markers.py +117 -0
  17. plot_io/numbers.py +289 -0
  18. plot_io/occupancy.py +219 -0
  19. plot_io/palettes.py +142 -0
  20. plot_io/plot.py +435 -0
  21. plot_io/render.py +1122 -0
  22. plot_io/scales.py +93 -0
  23. plot_io/series/__init__.py +498 -0
  24. plot_io/series/area.py +181 -0
  25. plot_io/series/bar.py +294 -0
  26. plot_io/series/box.py +401 -0
  27. plot_io/series/catdata.py +23 -0
  28. plot_io/series/catpoints.py +182 -0
  29. plot_io/series/contour.py +294 -0
  30. plot_io/series/curves.py +128 -0
  31. plot_io/series/ecdf.py +143 -0
  32. plot_io/series/errorbar.py +111 -0
  33. plot_io/series/errors.py +100 -0
  34. plot_io/series/fill.py +168 -0
  35. plot_io/series/heatmap.py +304 -0
  36. plot_io/series/hexbin.py +216 -0
  37. plot_io/series/hist.py +379 -0
  38. plot_io/series/hist2d.py +163 -0
  39. plot_io/series/kde.py +228 -0
  40. plot_io/series/kde2d.py +153 -0
  41. plot_io/series/line.py +173 -0
  42. plot_io/series/pie.py +304 -0
  43. plot_io/series/points.py +341 -0
  44. plot_io/series/regression.py +250 -0
  45. plot_io/series/scatter.py +103 -0
  46. plot_io/series/stem.py +112 -0
  47. plot_io/series/violin.py +321 -0
  48. plot_io/settings.py +281 -0
  49. plot_io/shapes.py +57 -0
  50. plot_io/sharing.py +160 -0
  51. plot_io/slots.py +52 -0
  52. plot_io/stacking.py +63 -0
  53. plot_io/stats.py +140 -0
  54. plot_io/style.py +71 -0
  55. plot_io/styles/__init__.py +234 -0
  56. plot_io/styles/library.py +732 -0
  57. plot_io/tables.py +141 -0
  58. plot_io/text.py +109 -0
  59. plot_io/ticks.py +284 -0
  60. plot_io/times.py +199 -0
  61. plot_io/values.py +161 -0
  62. plot_io-0.1.0.dist-info/METADATA +102 -0
  63. plot_io-0.1.0.dist-info/RECORD +65 -0
  64. plot_io-0.1.0.dist-info/WHEEL +4 -0
  65. 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)