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
@@ -0,0 +1,341 @@
1
+ """Markers shared by scatter, line and box series: settings and drawing."""
2
+
3
+ import array
4
+ from typing import Any
5
+
6
+ from .. import _backend
7
+ from ..maps import ColorMap
8
+ from ..markers import area_factor, check_marker, resolve
9
+ from ..settings import Setting, boolean, either, number, one_of
10
+ from ..style import resolve_color, with_alpha
11
+ from ..values import Values, is_computed, paint, size_value
12
+
13
+ __all__ = ["paint", "colors_only", "size_value", "marker_value", "marker_settings",
14
+ "MarkerStyle", "map_colors", "to_values", "order_settings", "order_index",
15
+ "permuted", "density_points"]
16
+
17
+
18
+ def colors_only(v: Any) -> Any:
19
+ """A color or a list of colors (series whose colors are per value set, not mapped)."""
20
+ out = paint(v)
21
+ if isinstance(out, Values) or is_computed(out):
22
+ raise ValueError("expected a color or a list of colors (values to map are not "
23
+ "accepted here)")
24
+ return out
25
+
26
+
27
+ def marker_value(v: Any) -> Any:
28
+ if isinstance(v, (list, tuple)):
29
+ return [check_marker(m) for m in v]
30
+ return check_marker(v)
31
+
32
+
33
+ def to_values(v: Any) -> Any:
34
+ if isinstance(v, dict):
35
+ return v
36
+ return _backend.doubles(v)
37
+
38
+
39
+ def marker_settings(prefix: str = "") -> dict[str, Setting]:
40
+ """Settings of markers, named ``<prefix>marker``, ``<prefix>size``…"""
41
+ p = prefix
42
+ size_name = "size" if not p else "marker_size"
43
+ color_name = "color" if not p else "marker_color"
44
+ return {
45
+ "marker": Setting(marker_value,
46
+ doc="Marker name, `Path` or path data, or one per point."),
47
+ size_name: Setting(size_value,
48
+ doc="Marker size (pt): the diameter of the circle of equal area; "
49
+ "one value or one per point; numbers per point are values to map "
50
+ "when `size_map` is set; or `\"density\"`.", length=True),
51
+ color_name: Setting(lambda v: paint(v, color_name),
52
+ doc="Fill: a color, colors per point, numbers per point (mapped "
53
+ "through `color_map`) or `\"density\"`."),
54
+ f"{p}line_color": Setting(lambda v: paint(v, f"{p}line_color"),
55
+ doc="Outline: as the fill color."),
56
+ f"{p}line_width": Setting(number(lo=0), doc="Outline width (pt).", length=True),
57
+ f"{p}filled": Setting(either(boolean, number(0, 1)),
58
+ doc="`True`: filled with the color; `False`: hollow, outlined "
59
+ "in the color; a number: filled at that opacity and "
60
+ "outlined in the color."),
61
+ "size_basis": Setting(one_of("area", "box"),
62
+ doc="`area`: shapes of the same size have the same area; "
63
+ "`box`: size is the width of the shape's box."),
64
+ }
65
+
66
+
67
+ def permuted(v: Any, idx: Any) -> Any:
68
+ """A per-point column (list, ``array('d')``, ``(n, 4)`` buffer) reordered by ``idx``
69
+ (gathered in C by pdf_io for numeric columns); single values unchanged."""
70
+ from pdf_io import arrays as _arrays
71
+ if isinstance(v, list):
72
+ return [v[i] for i in idx]
73
+ if isinstance(v, array.array):
74
+ n = len(idx)
75
+ if len(v) == 4 * n and n != 4: # flat RGBA
76
+ return _arrays.take(memoryview(v).cast("B").cast("d", (len(v) // 4, 4)), idx)
77
+ return _arrays.take(v, idx)
78
+ if isinstance(v, memoryview) and v.ndim == 2:
79
+ m: Any = v
80
+ k = m.shape[1]
81
+ out_mv: Any = memoryview(_arrays.take(m, idx)).cast("B")
82
+ return out_mv.cast("d", (len(idx), k))
83
+ return v
84
+
85
+
86
+ def order_index(series: Any, n: int) -> Any:
87
+ """The drawing order of a series' ``order`` setting, or ``None`` (call order)."""
88
+ from ..density import Density
89
+ o = series.get("order")
90
+ if o is None:
91
+ return None
92
+ descending = False
93
+ if isinstance(o, str):
94
+ descending = o.startswith("-")
95
+ dens = next((series.get(k) for k in ("color", "size", "alpha")
96
+ if k in series.SETTINGS and isinstance(series.get(k), Density)), None)
97
+ o = dens if dens is not None else series.cached("order_density", Density)
98
+ if isinstance(o, Density):
99
+ keys = series.point_values(o)
100
+ elif isinstance(o, Values):
101
+ keys = o.data
102
+ else:
103
+ keys = o
104
+ if len(keys) != n:
105
+ raise ValueError(f"{len(keys)} order keys for {n} points")
106
+ return _backend.argsort(keys, descending=descending)
107
+
108
+
109
+ def order_value(v: Any) -> Any:
110
+ """``order``: ``"density"``, ``"-density"``, a ``Density`` or sort keys per point."""
111
+ from ..density import Density
112
+ if isinstance(v, Density):
113
+ return v
114
+ if isinstance(v, str):
115
+ if v.strip().lower() in ("density", "-density"):
116
+ return v.strip().lower()
117
+ raise ValueError(f"expected 'density', '-density' or sort keys, got {v!r}")
118
+ return _backend.doubles(v.to_numpy() if hasattr(v, "to_numpy") else v)
119
+
120
+
121
+ def alpha_value(v: Any) -> Any:
122
+ """``alpha``: a number in [0, 1], or ``"density"`` / a ``Density``."""
123
+ from ..density import Density
124
+ if isinstance(v, Density):
125
+ return v
126
+ if isinstance(v, str) and v.strip().lower() == "density":
127
+ return Density()
128
+ return number(0, 1)(v)
129
+
130
+
131
+ def map_colors(cmap: ColorMap, values: Any, limits: tuple[float, ...]) -> Any:
132
+ """Colors of ``values`` through a map with limits ``(flo, fhi, …)`` (after its scale
133
+ function)."""
134
+ from ..scales import get_scale
135
+ flo, fhi = limits[0], limits[1]
136
+ span = fhi - flo
137
+ k = 1.0 / span if span else 0.0
138
+ fn = get_scale(cmap.scale).function
139
+ ts = _backend.scaled(values, k, -flo * k, fn)
140
+ return cmap.colors_at(ts)
141
+
142
+
143
+ def _faded(paint: Any, alpha: float) -> Any:
144
+ """A resolved paint (a color, a list of colors or flat RGBA values) at ``alpha``."""
145
+ if isinstance(paint, array.array):
146
+ out = array.array("d", paint)
147
+ out[3::4] = _backend.scaled(out[3::4], alpha)
148
+ return out
149
+ if isinstance(paint, list):
150
+ return [with_alpha(c, alpha) if c is not None else None for c in paint]
151
+ if isinstance(paint, memoryview): # (n, 4) colors from a ColorMap
152
+ flat = _faded(array.array("d", paint.cast("B").cast("d")), alpha)
153
+ return memoryview(flat).cast("B").cast("d", (len(flat) // 4, 4))
154
+ return with_alpha(paint, alpha)
155
+
156
+
157
+ class MarkerStyle:
158
+ """Resolved marker arguments for ``Canvas.draw_markers``."""
159
+
160
+ def __init__(self, series: Any, ctx: Any, prefix: str = "", default_color: Any = None,
161
+ n: int = 0, glyph: bool = False, order: Any = None) -> None:
162
+ self.glyph_only = glyph
163
+ self.order = None if glyph else order
164
+ g = series.get
165
+ p = prefix
166
+ size_name = "size" if not p else "marker_size"
167
+ color_name = "color" if not p else "marker_color"
168
+ alpha = g("alpha")
169
+ if is_computed(alpha) and glyph:
170
+ alpha = series.get("alpha_range")[1]
171
+ elif is_computed(alpha):
172
+ lo, hi = series.get("alpha_range")
173
+ vals = self._take(series.point_values(alpha))
174
+ _, vmin, vmax = _backend.extent(vals, None)
175
+ span = (vmax - vmin) if vmax > vmin else 1.0
176
+ k = (hi - lo) / span
177
+ alpha = _backend.scaled(vals, k, lo - k * vmin, None, nan=0.0)
178
+ self.opacity = 1.0 if alpha is None else alpha
179
+ marker = g("marker")
180
+ basis = g("size_basis")
181
+ size = g(size_name)
182
+ if isinstance(marker, list):
183
+ marker = self._take(marker)
184
+ if isinstance(size, array.array):
185
+ size = self._take(size)
186
+ if isinstance(marker, list) and glyph:
187
+ marker = marker[0]
188
+ if isinstance(marker, list):
189
+ if len(marker) != n:
190
+ raise ValueError(f"{len(marker)} markers for {n} points")
191
+ resolved = [resolve(m) for m in marker]
192
+ self.marker: Any = [r[0] for r in resolved]
193
+ factor: Any = array.array("d", [area_factor(m) if basis == "area" else 1.0
194
+ for m in marker])
195
+ else:
196
+ self.marker = resolve(marker)[0]
197
+ factor = area_factor(marker) if basis == "area" else 1.0
198
+ smap = series.size_map_obj(size_name) if hasattr(series, "size_map_obj") else None
199
+ if smap is not None and glyph:
200
+ size = (smap.sizes[0] + smap.sizes[1]) / 2
201
+ elif smap is not None:
202
+ from ..scales import get_scale
203
+ values = self._take(series.point_values(size))
204
+ flo, fhi = ctx.maps[id(smap)][:2]
205
+ size = smap.map(values, flo, fhi, get_scale(smap.scale).function)
206
+ if isinstance(size, array.array) and glyph:
207
+ finite = sorted(v for v in size[::max(1, len(size) // 1000)] if v == v)
208
+ size = finite[len(finite) // 2] if finite else 4.0
209
+ if isinstance(size, array.array):
210
+ if len(size) != n:
211
+ raise ValueError(f"{len(size)} sizes for {n} points")
212
+ if isinstance(factor, array.array):
213
+ size = array.array("d", [s * f for s, f in zip(size, factor)])
214
+ elif factor != 1.0:
215
+ size = _backend.scaled(size, factor)
216
+ elif isinstance(factor, array.array):
217
+ size = array.array("d", [size * f for f in factor])
218
+ else:
219
+ size = size * factor
220
+ self.size = size
221
+ fill = g(color_name)
222
+ if fill is None:
223
+ fill = default_color
224
+ self.fill = self._paint(fill, series, ctx, n)
225
+ self.stroke = self._paint(g(f"{p}line_color"), series, ctx, n)
226
+ self.line_width = g(f"{p}line_width")
227
+ filled = g(f"{p}filled")
228
+ if filled is not True and self.fill is not None:
229
+ if self.stroke is None:
230
+ self.stroke = self.fill
231
+ self.fill = None if filled is False else _faded(self.fill, filled)
232
+ if self.stroke is None:
233
+ self.line_width = None
234
+
235
+ def _paint(self, value: Any, series: Any, ctx: Any, n: int) -> Any:
236
+ if value is None:
237
+ return None
238
+ mapped = isinstance(value, Values) or is_computed(value)
239
+ if mapped and self.glyph_only:
240
+ return series.color_map_obj()(0.5)
241
+ if isinstance(value, list) and self.glyph_only:
242
+ return resolve_color(next((c for c in value if c is not None), None), ctx.palette)
243
+ if isinstance(value, memoryview) and self.glyph_only:
244
+ return tuple(value[0, k] for k in range(4)) if len(value) else None
245
+ if mapped:
246
+ values = self._take(series.point_values(value))
247
+ if len(values) != n:
248
+ raise ValueError(f"{len(values)} color values for {n} points")
249
+ cmap = series.color_map_obj()
250
+ return map_colors(cmap, values, ctx.maps[id(cmap)])
251
+ if isinstance(value, memoryview):
252
+ if len(value) != n:
253
+ raise ValueError(f"{len(value)} colors for {n} points")
254
+ return self._take(value)
255
+ if isinstance(value, list):
256
+ if len(value) != n:
257
+ raise ValueError(f"{len(value)} colors for {n} points")
258
+ return [resolve_color(c, ctx.palette) for c in self._take(value)]
259
+ return resolve_color(value, ctx.palette)
260
+
261
+ def first_color(self, which: str = "fill") -> Any:
262
+ v = self.fill if which == "fill" else self.stroke
263
+ if v is None or isinstance(v, (str, tuple)):
264
+ return v
265
+ if isinstance(v, list):
266
+ return next((c for c in v if c is not None), None)
267
+ if isinstance(v, array.array): # flat RGBA
268
+ return tuple(v[:4]) if len(v) >= 4 else None
269
+ if isinstance(v, memoryview) and v.ndim == 2:
270
+ m: Any = v
271
+ return tuple(m[0, k] for k in range(m.shape[1])) if len(m) else None
272
+ try:
273
+ return tuple(v[0])
274
+ except (TypeError, IndexError):
275
+ return None
276
+
277
+ def _take(self, v: Any) -> Any:
278
+ """A per-point input in drawing order (``order``), before colors are mapped."""
279
+ return v if self.order is None or v is None else permuted(v, self.order)
280
+
281
+ def draw(self, canvas: Any, xs: Any, ys: Any, simplify: Any) -> None:
282
+ if self.fill is None and self.stroke is None:
283
+ return
284
+ canvas.draw_markers(xs, ys, marker=self.marker, size=self.size, fill=self.fill,
285
+ stroke=self.stroke, line_width=self.line_width,
286
+ opacity=self.opacity, simplify=simplify)
287
+
288
+ def glyph(self, canvas: Any, cx: float, cy: float, max_size: float) -> None:
289
+ """One marker for a legend entry."""
290
+ size = self.size
291
+ if not isinstance(size, (int, float)):
292
+ finite = [s for s in size if s == s]
293
+ size = sorted(finite)[len(finite) // 2] if finite else 4.0
294
+ size = min(size, max_size)
295
+ marker = self.marker[0] if isinstance(self.marker, list) else self.marker
296
+ fill = self.first_color("fill")
297
+ stroke = self.first_color("stroke")
298
+ if fill is None and stroke is None:
299
+ return
300
+ canvas.draw_markers([cx], [cy], marker=marker, size=size, fill=fill, stroke=stroke,
301
+ line_width=self.line_width, opacity=self.opacity)
302
+
303
+
304
+ def order_settings() -> dict[str, Setting]:
305
+ """``order``, ``alpha`` (numbers or a density) and ``alpha_range``."""
306
+ from ..settings import pair
307
+ return {
308
+ "order": Setting(order_value,
309
+ doc="Drawing order: `\"density\"` (densest drawn last), "
310
+ "`\"-density\"`, or sort keys per point (ascending); default: "
311
+ "call order."),
312
+ "alpha": Setting(alpha_value, inherited=True,
313
+ doc="Opacity, multiplying the colors' own; or `\"density\"` / a "
314
+ "`Density`, mapped linearly to `alpha_range`."),
315
+ "alpha_range": Setting(pair(number(0, 1)),
316
+ doc="Opacities of the least and most dense points when "
317
+ "`alpha` is a density."),
318
+ }
319
+
320
+
321
+ def density_points(series: Any, xs: Any, ys: Any) -> tuple[Any, Any]:
322
+ """Points ``(xs, ys)`` on the plot's axis positions (log axes in log space)."""
323
+ from ..scales import get_scale
324
+ out = []
325
+ for v, axis in ((xs, series.plot.x_axis), (ys, series.plot.y_axis)):
326
+ name = axis.get("scale")
327
+ fn = get_scale(name, axis.get("linthresh")).function \
328
+ if name not in (None, "category", "time") else None
329
+ out.append(_backend.scaled(v, 1.0, 0.0, fn) if fn else _backend.doubles(v))
330
+ return out[0], out[1]
331
+
332
+
333
+ def density_stats(series: Any) -> dict[str, Any]:
334
+ """``{"density": values}`` when a setting of the series maps a density."""
335
+ from ..density import Density
336
+ for k in ("color", "line_color", "size", "alpha", "order"):
337
+ if k in series.SETTINGS and isinstance(series.get(k), Density):
338
+ return {"density": series.point_values(series.get(k))}
339
+ if "order" in series.SETTINGS and isinstance(series.get("order"), str):
340
+ return {"density": series.point_values(series.cached("order_density", Density))}
341
+ return {}
@@ -0,0 +1,250 @@
1
+ """Regression: a fitted line (polynomial or LOWESS) with a confidence band."""
2
+
3
+ import array
4
+ import math
5
+ from typing import Any
6
+
7
+ from .. import _backend, stats
8
+ from ..settings import Setting, boolean, color, either, literal, number, table
9
+ from ..style import dash_of, with_alpha
10
+ from . import Series, common
11
+ from .points import MarkerStyle, marker_settings
12
+
13
+ __all__ = ["Regression", "t_quantile"]
14
+
15
+
16
+ def _solve(a: list[list[float]], b: list[float]) -> list[float]:
17
+ """Gaussian elimination with partial pivoting."""
18
+ n = len(b)
19
+ m = [row[:] + [bv] for row, bv in zip(a, b)]
20
+ for i in range(n):
21
+ p = max(range(i, n), key=lambda r: abs(m[r][i]))
22
+ if abs(m[p][i]) < 1e-300:
23
+ raise ValueError("the fit is singular (too few distinct x values for the order)")
24
+ m[i], m[p] = m[p], m[i]
25
+ for r in range(i + 1, n):
26
+ f = m[r][i] / m[i][i]
27
+ for c in range(i, n + 1):
28
+ m[r][c] -= f * m[i][c]
29
+ x = [0.0] * n
30
+ for i in range(n - 1, -1, -1):
31
+ x[i] = (m[i][n] - sum(m[i][c] * x[c] for c in range(i + 1, n))) / m[i][i]
32
+ return x
33
+
34
+
35
+ def _inverse(a: list[list[float]]) -> list[list[float]]:
36
+ n = len(a)
37
+ cols = [_solve(a, [1.0 if r == c else 0.0 for r in range(n)]) for c in range(n)]
38
+ return [[cols[c][r] for c in range(n)] for r in range(n)]
39
+
40
+
41
+ def _betacf(a: float, b: float, x: float) -> float:
42
+ qab, qap, qam = a + b, a + 1.0, a - 1.0
43
+ c, d = 1.0, 1.0 - qab * x / qap
44
+ d = 1.0 / (d if abs(d) > 1e-300 else 1e-300)
45
+ h = d
46
+ for m in range(1, 300):
47
+ m2 = 2 * m
48
+ aa = m * (b - m) * x / ((qam + m2) * (a + m2))
49
+ d = 1.0 + aa * d
50
+ d = 1.0 / (d if abs(d) > 1e-300 else 1e-300)
51
+ c = 1.0 + aa / c if abs(c) > 1e-300 else 1e300
52
+ h *= d * c
53
+ aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2))
54
+ d = 1.0 + aa * d
55
+ d = 1.0 / (d if abs(d) > 1e-300 else 1e-300)
56
+ c = 1.0 + aa / c if abs(c) > 1e-300 else 1e300
57
+ de = d * c
58
+ h *= de
59
+ if abs(de - 1.0) < 1e-15:
60
+ break
61
+ return h
62
+
63
+
64
+ def _betai(a: float, b: float, x: float) -> float:
65
+ if x <= 0:
66
+ return 0.0
67
+ if x >= 1:
68
+ return 1.0
69
+ lb = math.lgamma(a + b) - math.lgamma(a) - math.lgamma(b) + a * math.log(x) \
70
+ + b * math.log(1 - x)
71
+ if x < (a + 1) / (a + b + 2):
72
+ return math.exp(lb) * _betacf(a, b, x) / a
73
+ return 1.0 - math.exp(lb) * _betacf(b, a, 1 - x) / b
74
+
75
+
76
+ def _t_cdf(t: float, df: float) -> float:
77
+ x = df / (df + t * t)
78
+ p = 0.5 * _betai(df / 2.0, 0.5, x)
79
+ return 1.0 - p if t > 0 else p
80
+
81
+
82
+ def t_quantile(p: float, df: float) -> float:
83
+ """The ``p`` quantile of Student's t distribution with ``df`` degrees of freedom."""
84
+ if df <= 0:
85
+ return math.inf
86
+ lo, hi = -1e3, 1e3
87
+ for _ in range(200):
88
+ mid = (lo + hi) / 2
89
+ if _t_cdf(mid, df) < p:
90
+ lo = mid
91
+ else:
92
+ hi = mid
93
+ return (lo + hi) / 2
94
+
95
+
96
+ class Regression(Series):
97
+ """A regression of ``y`` on ``x``: a polynomial of ``order`` (least squares) with a
98
+ confidence band of the mean, or a LOWESS curve; ``scatter`` also draws the points."""
99
+
100
+ SETTINGS = table(
101
+ order=Setting(number(lo=1, hi=5, integer=True), doc="Polynomial order (1–5)."),
102
+ lowess=Setting(boolean, doc="Fit a LOWESS curve instead (no band)."),
103
+ frac=Setting(number(0, 1), doc="Fraction of the points in each LOWESS fit."),
104
+ ci=Setting(either(literal(None), number(0, 100)),
105
+ doc="Confidence level of the band (%); `None`: no band."),
106
+ logx=Setting(boolean, doc="Fit y against log(x) (x > 0)."),
107
+ scatter=Setting(boolean, doc="Draw the points too."),
108
+ truncate=Setting(boolean, doc="Draw the fit over the data range only (`False`: "
109
+ "over the axis range)."),
110
+ points=Setting(number(lo=2, integer=True), doc="Points of the fitted curve."),
111
+ color=Setting(color, doc="Line, band and point color; default the next palette color."),
112
+ line_width=Setting(number(lo=0), doc="Line width (pt).", length=True),
113
+ line_style=Setting(lambda v: v, doc="Line style."),
114
+ band_alpha=Setting(number(0, 1), doc="Opacity of the band."),
115
+ **{**marker_settings("marker_")},
116
+ **common("name", "alpha", "z", "rasterize", "simplify", "font_size", "font_family",
117
+ "text_color"),
118
+ )
119
+
120
+ def __init__(self, x: Any, y: Any, plot: Any = None, **settings: Any) -> None:
121
+ self.x, self.y = _backend.doubles(x), _backend.doubles(y)
122
+ if len(self.x) != len(self.y):
123
+ raise ValueError(f"x has {len(self.x)} values and y {len(self.y)}")
124
+ super().__init__(plot=plot, **settings)
125
+
126
+ @property
127
+ def stats(self) -> dict[str, Any]:
128
+ """``coefficients`` (highest power last, in x or log x), ``r2``, ``se`` (residual
129
+ standard error), and the curve: ``x``, ``y``, ``low``, ``high``."""
130
+ return self.cached("stats", self._compute)
131
+
132
+ def _pairs(self) -> list[tuple[float, float]]:
133
+ logx = self.get("logx")
134
+ return [(math.log(a) if logx else a, b) for a, b in zip(self.x, self.y)
135
+ if math.isfinite(a) and math.isfinite(b) and (a > 0 or not logx)]
136
+
137
+ def _compute(self) -> dict[str, Any]:
138
+ logx = self.get("logx")
139
+ pts = self._pairs()
140
+ n = len(pts)
141
+ npts = self.get("points")
142
+ if n < 2:
143
+ return {"coefficients": [], "r2": math.nan, "se": math.nan, "x": array.array("d"),
144
+ "y": array.array("d"), "low": None, "high": None}
145
+ lo = min(p[0] for p in pts)
146
+ hi = max(p[0] for p in pts)
147
+ grid = [lo + (hi - lo) * i / (npts - 1) for i in range(npts)]
148
+ if self.get("lowess"):
149
+ xs, fit = stats.lowess([p[0] for p in pts], [p[1] for p in pts], self.get("frac"))
150
+ gx = array.array("d", (math.exp(v) for v in xs) if logx else xs)
151
+ return {"coefficients": [], "r2": math.nan, "se": math.nan, "x": gx, "y": fit,
152
+ "low": None, "high": None}
153
+ k = min(self.get("order"), n - 1) + 1
154
+ mx = math.fsum(p[0] for p in pts) / n
155
+ sx = math.sqrt(math.fsum((p[0] - mx) ** 2 for p in pts) / n) or 1.0
156
+
157
+ def row(v: float) -> list[float]:
158
+ u = (v - mx) / sx
159
+ return [u ** j for j in range(k)]
160
+
161
+ rows = [row(p[0]) for p in pts]
162
+ ata = [[math.fsum(r[i] * r[j] for r in rows) for j in range(k)] for i in range(k)]
163
+ aty = [math.fsum(r[i] * p[1] for r, p in zip(rows, pts)) for i in range(k)]
164
+ beta = _solve(ata, aty)
165
+ fitted = [sum(b * v for b, v in zip(beta, r)) for r in rows]
166
+ my = math.fsum(p[1] for p in pts) / n
167
+ ss_res = math.fsum((p[1] - f) ** 2 for p, f in zip(pts, fitted))
168
+ ss_tot = math.fsum((p[1] - my) ** 2 for p in pts)
169
+ dof = n - k
170
+ se = math.sqrt(ss_res / dof) if dof > 0 else math.nan
171
+ gy = [sum(b * v for b, v in zip(beta, row(g))) for g in grid]
172
+ low = high = None
173
+ ci = self.get("ci")
174
+ if ci is not None and dof > 0:
175
+ inv = _inverse(ata)
176
+ t = t_quantile(0.5 + ci / 200.0, dof)
177
+ low, high = array.array("d"), array.array("d")
178
+ for g, yv in zip(grid, gy):
179
+ r = row(g)
180
+ var = sum(r[i] * inv[i][j] * r[j] for i in range(k) for j in range(k))
181
+ h = t * se * math.sqrt(max(var, 0.0))
182
+ low.append(yv - h)
183
+ high.append(yv + h)
184
+ # coefficients in powers of x (or log x)
185
+ coef = [0.0] * k
186
+ for j, b in enumerate(beta):
187
+ for i in range(j + 1):
188
+ coef[i] += b * math.comb(j, i) * (-mx) ** (j - i) / sx ** j
189
+ gx = array.array("d", (math.exp(v) for v in grid) if logx else grid)
190
+ return {"coefficients": coef, "r2": 1.0 - ss_res / ss_tot if ss_tot else math.nan,
191
+ "se": se, "x": gx, "y": array.array("d", gy), "low": low, "high": high}
192
+
193
+ def values(self, role: str) -> Any:
194
+ st = self.stats
195
+ out = array.array("d", self.x if role == "x" else self.y)
196
+ if role == "y":
197
+ for v in (st["low"], st["high"]):
198
+ if v is not None:
199
+ out.extend(v)
200
+ return out
201
+
202
+ def occupy(self, ctx: Any, occ: Any) -> None:
203
+ st = self.stats
204
+ if self.get("scatter"):
205
+ from ..occupancy import thin
206
+ x, y = thin(self.x, self.y)
207
+ occ.points(ctx.xmap.many(x), ctx.ymap.many(y))
208
+ if len(st["x"]):
209
+ px = ctx.xmap.many(st["x"])
210
+ occ.lines(px, ctx.ymap.many(st["y"]))
211
+ if st["low"] is not None:
212
+ occ.band(px, ctx.ymap.many(st["low"]), ctx.ymap.many(st["high"]))
213
+
214
+ def legend_points(self) -> tuple[Any, Any]:
215
+ return self.x, self.y
216
+
217
+ def _color(self, ctx: Any) -> Any:
218
+ return with_alpha(self.base_color(ctx), self.get("alpha"))
219
+
220
+ def draw(self, ctx: Any) -> None:
221
+ st = self.stats
222
+ c = self._color(ctx)
223
+ canvas = ctx.canvas
224
+ if self.get("scatter"):
225
+ style = MarkerStyle(self, ctx, "marker_", c, len(self.x))
226
+ style.draw(canvas, ctx.xmap.many(self.x), ctx.ymap.many(self.y), self.simplify_arg())
227
+ if c is None or not len(st["x"]):
228
+ return
229
+ px = ctx.xmap.many(st["x"])
230
+ if st["low"] is not None:
231
+ canvas.fill_between(px, ctx.ymap.many(st["low"]), ctx.ymap.many(st["high"]),
232
+ color=c, opacity=self.get("band_alpha"))
233
+ lw = self.get("line_width")
234
+ if lw:
235
+ canvas.draw_lines(px, ctx.ymap.many(st["y"]), color=c, width=lw,
236
+ dash=dash_of(self.get("line_style"), lw))
237
+
238
+ def legend_colors(self, ctx: Any) -> tuple[Any, Any]:
239
+ return None, self._color(ctx)
240
+
241
+ def legend_glyph(self, ctx: Any, x: float, y: float, w: float, h: float) -> None:
242
+ c = self._color(ctx)
243
+ if c is None:
244
+ return
245
+ canvas = ctx.canvas
246
+ if self.get("ci") is not None and not self.get("lowess"):
247
+ canvas.fill_style = with_alpha(c, self.get("band_alpha"))
248
+ canvas.fill_rect(x, y + h * 0.2, w, h * 0.6)
249
+ canvas.draw_lines([x, x + w], [y + h / 2, y + h / 2], color=c,
250
+ width=min(self.get("line_width"), h / 3))