plot3 0.4.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.
plot3/stat2d.py ADDED
@@ -0,0 +1,1539 @@
1
+ """Positioned bars, ranges, ribbons, smoothers, and summaries.
2
+
3
+ Every layer here expands into kinds the viewer and the static exporter
4
+ already draw: ``poly`` (bars as rectangles, ribbons), ``line`` (error bars,
5
+ smoothers), and ``point`` (jitter, the dot of a point range). Nothing new has
6
+ to be taught to WebGL or to the PNG/SVG/PDF writer.
7
+
8
+ Categorical x is drawn at integer positions with the category names on the
9
+ axis (the ``_violin_levels`` hook build_spec already honours), so dodged
10
+ bars and the error bars on top of them can sit between the ticks.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ from typing import Any, Callable
17
+
18
+ import numpy as np
19
+ import pandas as pd
20
+
21
+ from plot3.geoms import _Geom, aes
22
+ from plot3.scales import col_values, ordered_levels, resolution
23
+ from plot3.special import qt
24
+ from plot3.table import has_column, materialize_columns
25
+
26
+ _ALPHA_BAR = 0.9
27
+ _ALPHA_BAND = 0.25
28
+
29
+
30
+ # ── positions ────────────────────────────────────────────────────────────────
31
+
32
+
33
+ def position_kind(value: Any, default: str) -> tuple[str, float | None]:
34
+ """``("stack" | "dodge" | "fill" | "identity", dodge width or None)``."""
35
+ if value is None:
36
+ return default, None
37
+ if isinstance(value, str):
38
+ kind = value.strip().lower()
39
+ width = None
40
+ else:
41
+ kind = str(getattr(value, "kind", "") or "")
42
+ width = getattr(value, "width", None)
43
+ if kind not in {"stack", "dodge", "fill", "identity"}:
44
+ raise ValueError(
45
+ "position must be 'stack', 'dodge', 'fill', or 'identity' "
46
+ f"(or position_dodge() etc.), not {value!r}"
47
+ )
48
+ return kind, None if width is None else float(width)
49
+
50
+
51
+ # ── axis helpers ─────────────────────────────────────────────────────────────
52
+
53
+
54
+ class _Axis:
55
+ """x as numbers, plus the category names when x is discrete."""
56
+
57
+ def __init__(self, kind: str, values: np.ndarray, levels: list[str] | None):
58
+ self.kind = kind # "num" | "dt" | "cat"
59
+ self.values = values
60
+ self.levels = levels
61
+
62
+ def out(self, positions: np.ndarray) -> Any:
63
+ """Positions back in the column type the x scale expects."""
64
+ if self.kind == "dt":
65
+ return pd.to_datetime(np.asarray(positions, dtype=np.float64), unit="s")
66
+ return np.asarray(positions, dtype=np.float64)
67
+
68
+ def step(self) -> float:
69
+ if self.kind == "cat":
70
+ return 1.0
71
+ finite = np.unique(self.values[np.isfinite(self.values)])
72
+ return float(resolution(finite, zero=False)) if finite.size else 1.0
73
+
74
+
75
+ def _axis(series: pd.Series, *, discrete: bool = False) -> _Axis:
76
+ if discrete and not isinstance(series.dtype, pd.CategoricalDtype):
77
+ # Counted values are categories, in ggplot2's level order (4, 6, 8).
78
+ levels = ["NA" if v is None else str(v) for v in ordered_levels(series.tolist())]
79
+ labels = series.map(lambda v: "NA" if pd.isna(v) else str(v))
80
+ index = {level: i for i, level in enumerate(levels)}
81
+ return _Axis("cat", labels.map(index).to_numpy(np.float64), levels)
82
+ kind, values, cats = col_values(series)
83
+ return _Axis(kind, np.asarray(values, dtype=np.float64), cats if kind == "cat" else None)
84
+
85
+
86
+ def _colour_groups(frame: pd.DataFrame, colour: str | None) -> tuple[np.ndarray, list[Any]]:
87
+ """Group index per row and the levels in legend order (one group if none)."""
88
+ if not colour or colour not in frame.columns:
89
+ return np.zeros(len(frame), dtype=int), [None]
90
+ kind, codes, cats = col_values(frame[colour])
91
+ if kind != "cat":
92
+ # A numeric colour is a continuous gradient, not a group.
93
+ return np.zeros(len(frame), dtype=int), [None]
94
+ return codes.astype(int), cats
95
+
96
+
97
+ def _frame(data: Any, cols: list[str]) -> pd.DataFrame:
98
+ return materialize_columns(data, [c for c in dict.fromkeys(cols) if c])
99
+
100
+
101
+ def _layer(kind: str, frame: pd.DataFrame, mapping: dict, geom: _Geom, **extra) -> _Geom:
102
+ out = _Geom(aes(**mapping), color=geom.const_color, alpha=geom.alpha)
103
+ out.kind = kind
104
+ out.data_override = frame.reset_index(drop=True)
105
+ out.const_color = geom.const_color
106
+ out.alpha = geom.alpha
107
+ out._replace_mapping = True
108
+ out.sort_x = False
109
+ for key, value in extra.items():
110
+ setattr(out, key, value)
111
+ return out
112
+
113
+
114
+ def _title(out: _Geom, axis: str, text: Any) -> None:
115
+ """Axis title when the ggplot() mapping does not name one."""
116
+ if text:
117
+ labels = dict(getattr(out, "_axis_labels", None) or {})
118
+ labels[axis] = str(text)
119
+ out._axis_labels = labels
120
+
121
+
122
+ def _colour_mapping(colour: str | None) -> dict:
123
+ return {"colour": colour} if colour else {}
124
+
125
+
126
+ def _levels_hook(out: _Geom, axis: _Axis) -> _Geom:
127
+ if axis.kind == "cat" and axis.levels is not None:
128
+ out._violin_levels = list(axis.levels)
129
+ return out
130
+
131
+
132
+ def _dodge_offsets(n_groups: int, width: float) -> np.ndarray:
133
+ """Centre offset of each group inside a dodged slot of ``width``."""
134
+ if n_groups <= 1:
135
+ return np.zeros(1)
136
+ slot = width / n_groups
137
+ return (np.arange(n_groups) - (n_groups - 1) / 2.0) * slot
138
+
139
+
140
+ # ── bars ─────────────────────────────────────────────────────────────────────
141
+
142
+
143
+ def wants_positioned_bars(geom: _Geom, mapping: dict, data: Any) -> bool:
144
+ """Stacked, dodged, or filled bars need the rectangle path."""
145
+ kind, _w = position_kind(getattr(geom, "position", None), "stack")
146
+ colour = mapping.get("color")
147
+ x = mapping.get("x")
148
+ grouped = bool(colour) and colour != x and has_column(data, colour)
149
+ if kind == "fill":
150
+ return True
151
+ if not grouped:
152
+ return False
153
+ if geom.kind == "bar":
154
+ return True # counts must be split by the colour group
155
+ return kind in {"stack", "dodge"}
156
+
157
+
158
+ def positioned_bars(geom: _Geom, mapping: dict, data: Any) -> _Geom:
159
+ xcol = mapping.get("x")
160
+ if not xcol:
161
+ raise ValueError(f"geom_{geom.kind}() requires aes(x=)")
162
+ colour = mapping.get("color")
163
+ colour = colour if colour and colour != xcol and has_column(data, colour) else None
164
+ kind, dodge_width = position_kind(getattr(geom, "position", None), "stack")
165
+ rel = float(getattr(geom, "width", 0.9))
166
+ if geom.kind == "bar":
167
+ frame = _frame(data, [xcol, colour])
168
+ keys = [xcol] + ([colour] if colour else [])
169
+ counts = frame.groupby(keys, sort=False, dropna=False, observed=True).size()
170
+ frame = counts.reset_index(name="__y")
171
+ axis = _axis(frame[xcol], discrete=True)
172
+ else:
173
+ ycol = mapping.get("y")
174
+ if not ycol:
175
+ raise ValueError("geom_col() requires aes(x=, y=)")
176
+ frame = _frame(data, [xcol, ycol, colour])
177
+ frame = frame.rename(columns={ycol: "__y"}) if ycol != "__y" else frame
178
+ axis = _axis(frame[xcol])
179
+ groups, levels = _colour_groups(frame, colour)
180
+ n_groups = len(levels)
181
+ xs = axis.values
182
+ ys = frame["__y"].to_numpy(np.float64)
183
+ full = axis.step() * rel
184
+ bottoms = np.zeros_like(ys)
185
+ tops = ys.copy()
186
+ lefts = xs - full / 2.0
187
+ rights = xs + full / 2.0
188
+ if kind in {"stack", "fill"}:
189
+ # ggplot2 puts the first level on top: stack from the last level up.
190
+ # Negative values stack downward on their own.
191
+ rank = (n_groups - 1) - groups
192
+ bottoms = np.empty_like(ys)
193
+ tops = np.empty_like(ys)
194
+ for x_value in np.unique(xs):
195
+ here = np.flatnonzero(xs == x_value)
196
+ here = here[np.argsort(rank[here], kind="stable")]
197
+ up = down = 0.0
198
+ for i in here:
199
+ if ys[i] >= 0:
200
+ bottoms[i], tops[i] = up, up + ys[i]
201
+ up += ys[i]
202
+ else:
203
+ bottoms[i], tops[i] = down, down + ys[i]
204
+ down += ys[i]
205
+ if kind == "fill":
206
+ total = up if up > 0 else 1.0
207
+ bottoms[here] /= total
208
+ tops[here] /= total
209
+ elif kind == "dodge":
210
+ width = full if dodge_width is None else axis.step() * dodge_width
211
+ offsets = _dodge_offsets(n_groups, width)
212
+ slot = full / max(n_groups, 1)
213
+ # position_dodge2(padding=): a gap between neighbouring bars.
214
+ slot *= 1.0 - float(getattr(getattr(geom, "position", None), "padding", 0.0) or 0.0)
215
+ centres = xs + offsets[groups]
216
+ lefts = centres - slot / 2.0
217
+ rights = centres + slot / 2.0
218
+ rows_x: list[float] = []
219
+ rows_y: list[float] = []
220
+ rows_c: list[Any] = []
221
+ starts: list[list[int]] = []
222
+ colour_values = frame[colour].tolist() if colour else None
223
+ for i in range(len(frame)):
224
+ if not (math.isfinite(bottoms[i]) and math.isfinite(tops[i])):
225
+ continue
226
+ start = len(rows_x)
227
+ # Left edge up, right edge down: the pairing poly drawing expects.
228
+ rows_x += [lefts[i], lefts[i], rights[i], rights[i]]
229
+ rows_y += [bottoms[i], tops[i], tops[i], bottoms[i]]
230
+ if colour_values is not None:
231
+ rows_c += [colour_values[i]] * 4
232
+ starts.append([start, 4])
233
+ out_frame = pd.DataFrame({"x": axis.out(np.asarray(rows_x)), "y": rows_y})
234
+ mapping_out = {"x": "x", "y": "y"}
235
+ if colour:
236
+ out_frame[colour] = rows_c
237
+ mapping_out["colour"] = colour
238
+ out = _layer(
239
+ "poly", out_frame, mapping_out, geom,
240
+ _groups=starts, _baseline_zero=True, linewidth=0.0,
241
+ )
242
+ out.alpha = geom.alpha if geom.alpha is not None else _ALPHA_BAR
243
+ _title(out, "y", "count" if geom.kind == "bar" else None)
244
+ if kind == "fill":
245
+ _title(out, "y", "proportion")
246
+ return _levels_hook(out, axis)
247
+
248
+
249
+ # ── jitter ───────────────────────────────────────────────────────────────────
250
+
251
+
252
+ def _point_position(geom: _Geom):
253
+ """(kind, settings) for how points move: jitter, jitterdodge, or nudge."""
254
+ pos = getattr(geom, "position", None)
255
+ if geom.kind == "jitter":
256
+ return "jitter", geom
257
+ if isinstance(pos, str):
258
+ kind = pos.strip().lower()
259
+ if kind not in {"identity", "jitter"}:
260
+ raise ValueError(
261
+ f"geom_point(position={pos!r}): use 'identity', 'jitter', position_jitter(), "
262
+ "position_jitterdodge(), or position_nudge()"
263
+ )
264
+ return kind, None
265
+ if pos is None:
266
+ return "identity", None
267
+ return str(getattr(pos, "kind", "identity")), pos
268
+
269
+
270
+ def jitter(geom: _Geom, mapping: dict, data: Any) -> _Geom:
271
+ """geom_jitter, and geom_point with position_jitter(),
272
+ position_jitterdodge(), or position_nudge()."""
273
+ name = "geom_jitter" if geom.kind == "jitter" else "geom_point"
274
+ xcol, ycol = mapping.get("x"), mapping.get("y")
275
+ if not xcol or not ycol:
276
+ raise ValueError(f"{name}() requires aes(x=, y=)")
277
+ kind, pos = _point_position(geom)
278
+ dodge = mapping.get("color") or mapping.get("__fillgroup") or mapping.get("group")
279
+ dodge = dodge if dodge and has_column(data, dodge) else None
280
+ keep = [c for c in (mapping.get("color"), mapping.get("size"), mapping.get("group"),
281
+ mapping.get("shape"), dodge) if c]
282
+ frame = _frame(data, [xcol, ycol, *keep])
283
+ axis = _axis(frame[xcol])
284
+ yaxis = _axis(frame[ycol])
285
+ rng = np.random.default_rng(getattr(pos if pos is not None else geom, "seed", 0))
286
+ xs, ys = axis.values.astype(np.float64), yaxis.values.astype(np.float64)
287
+ if kind == "nudge":
288
+ xs = xs + float(getattr(pos, "x", 0.0))
289
+ ys = ys + float(getattr(pos, "y", 0.0))
290
+ elif kind == "jitterdodge":
291
+ # ggplot2: dodge by group within each x, then jitter inside the
292
+ # group's slot (40% of the spacing, shared among n + 2 slots).
293
+ groups, levels = _colour_groups(frame, dodge)
294
+ n = max(1, len(levels))
295
+ step = axis.step()
296
+ xs = xs + _dodge_offsets(n, step * float(pos.dodge_width))[groups]
297
+ width = 0.4 * step if pos.jitter_width is None else float(pos.jitter_width)
298
+ width = width / (n + 2)
299
+ xs = xs + rng.uniform(-width, width, len(frame))
300
+ if pos.jitter_height > 0:
301
+ ys = ys + rng.uniform(-pos.jitter_height, pos.jitter_height, len(frame))
302
+ else:
303
+ source = pos if pos is not None else geom
304
+ width = getattr(source, "width", None)
305
+ height = getattr(source, "height", None)
306
+ # ggplot2: 40% of the resolution in each direction, both ways.
307
+ width = 0.4 * axis.step() if width is None else float(width) * (axis.step() if axis.kind == "cat" else 1.0)
308
+ height = 0.4 * yaxis.step() if height is None else float(height)
309
+ xs = xs + rng.uniform(-width, width, len(frame))
310
+ if height > 0:
311
+ ys = ys + rng.uniform(-height, height, len(frame))
312
+ out_frame = pd.DataFrame({"x": axis.out(xs), "y": yaxis.out(ys)})
313
+ mapping_out = {"x": "x", "y": "y"}
314
+ for key, col in (("colour", mapping.get("color")), ("size", mapping.get("size")),
315
+ ("group", mapping.get("group")), ("shape", mapping.get("shape"))):
316
+ if col:
317
+ out_frame[col] = frame[col].to_numpy()
318
+ mapping_out[key] = col
319
+ out = _layer("point", out_frame, mapping_out, geom)
320
+ out.size = getattr(geom, "size", None)
321
+ out.shape = getattr(geom, "shape", None)
322
+ out = _levels_hook(out, axis)
323
+ if yaxis.kind == "cat" and yaxis.levels is not None and kind != "nudge":
324
+ raise ValueError(f"{name}() needs a numeric y to jitter")
325
+ if yaxis.kind == "cat" and yaxis.levels is not None:
326
+ out._y_levels = list(yaxis.levels)
327
+ _title(out, "x", xcol)
328
+ _title(out, "y", ycol)
329
+ return out
330
+
331
+
332
+ # ── ranges ───────────────────────────────────────────────────────────────────
333
+
334
+
335
+ def _range_frame(geom: _Geom, mapping: dict, data: Any, need_y: bool):
336
+ name = f"geom_{geom.kind}"
337
+ xcol = mapping.get("x")
338
+ lo, hi = mapping.get("ymin"), mapping.get("ymax")
339
+ ycol = mapping.get("y")
340
+ if not xcol or not lo or not hi or (need_y and not ycol):
341
+ wanted = "x=, y=, ymin=, ymax=" if need_y else "x=, ymin=, ymax="
342
+ raise ValueError(f"{name}() requires aes({wanted})")
343
+ colour = mapping.get("color")
344
+ colour = colour if colour and has_column(data, colour) else None
345
+ # Dodge by the colour group, or by a fill the layer is not coloured by.
346
+ dodge = colour or mapping.get("__fillgroup") or mapping.get("group")
347
+ dodge = dodge if dodge and has_column(data, dodge) else None
348
+ frame = _frame(data, [xcol, lo, hi, ycol if need_y else None, colour, dodge])
349
+ return frame, xcol, lo, hi, ycol, colour, dodge
350
+
351
+
352
+ def _dodged_x(geom: _Geom, axis: _Axis, groups: np.ndarray, n_groups: int) -> np.ndarray:
353
+ kind, width = position_kind(getattr(geom, "position", None), "identity")
354
+ if kind != "dodge" or n_groups <= 1:
355
+ return axis.values
356
+ width = 0.9 if width is None else width
357
+ return axis.values + _dodge_offsets(n_groups, axis.step() * width)[groups]
358
+
359
+
360
+ def ranges(geom: _Geom, mapping: dict, data: Any):
361
+ """geom_errorbar, geom_linerange, geom_pointrange."""
362
+ kind = geom.kind
363
+ frame, xcol, lo, hi, ycol, colour, dodge = _range_frame(
364
+ geom, mapping, data, kind == "pointrange"
365
+ )
366
+ axis = _axis(frame[xcol])
367
+ groups, levels = _colour_groups(frame, dodge)
368
+ xs = _dodged_x(geom, axis, groups, len(levels))
369
+ y_lo = frame[lo].to_numpy(np.float64)
370
+ y_hi = frame[hi].to_numpy(np.float64)
371
+ cap = 0.0
372
+ if kind == "errorbar":
373
+ dodge_kind, _w = position_kind(getattr(geom, "position", None), "identity")
374
+ slot = axis.step() * (1.0 / len(levels) if dodge_kind == "dodge" else 1.0)
375
+ cap = 0.5 * slot * float(getattr(geom, "width", 0.5))
376
+ rows_x: list[float] = []
377
+ rows_y: list[float] = []
378
+ rows_c: list[Any] = []
379
+ starts: list[list[int]] = []
380
+ colour_values = frame[colour].tolist() if colour else None
381
+ for i in range(len(frame)):
382
+ x, a, b = xs[i], y_lo[i], y_hi[i]
383
+ if not (math.isfinite(x) and math.isfinite(a) and math.isfinite(b)):
384
+ continue
385
+ if cap > 0:
386
+ # One path per bar: top cap, down the stem, bottom cap.
387
+ path_x = [x - cap, x + cap, x, x, x - cap, x + cap]
388
+ path_y = [b, b, b, a, a, a]
389
+ else:
390
+ path_x, path_y = [x, x], [a, b]
391
+ starts.append([len(rows_x), len(path_x)])
392
+ rows_x += path_x
393
+ rows_y += path_y
394
+ if colour_values is not None:
395
+ rows_c += [colour_values[i]] * len(path_x)
396
+ line_frame = pd.DataFrame({"x": axis.out(np.asarray(rows_x)), "y": rows_y})
397
+ line_map = {"x": "x", "y": "y"}
398
+ if colour:
399
+ line_frame[colour] = rows_c
400
+ line_map["colour"] = colour
401
+ linewidth = float(getattr(geom, "linewidth", 1.0) or 1.0)
402
+ line = _layer(
403
+ "line", line_frame, line_map, geom, _groups=starts, linewidth=linewidth,
404
+ _ink_default=not colour,
405
+ )
406
+ inherited = (getattr(geom, "_axis_labels", None) or {}).get("y")
407
+ _title(line, "y", inherited or ycol or lo)
408
+ _levels_hook(line, axis)
409
+ if kind != "pointrange":
410
+ return line
411
+ dot_frame = pd.DataFrame({"x": axis.out(xs), "y": frame[ycol].to_numpy(np.float64)})
412
+ dot_map = {"x": "x", "y": "y"}
413
+ if colour:
414
+ dot_frame[colour] = frame[colour].to_numpy()
415
+ dot_map["colour"] = colour
416
+ dot = _layer("point", dot_frame, dot_map, geom, _ink_default=not colour)
417
+ dot.size = getattr(geom, "size", None) or 7.0
418
+ dot.alpha = geom.alpha if geom.alpha is not None else 1.0
419
+ _title(dot, "y", inherited or ycol)
420
+ _levels_hook(dot, axis)
421
+ return [line, dot]
422
+
423
+
424
+ # ── ribbons ──────────────────────────────────────────────────────────────────
425
+
426
+
427
+ def _band(xs, lo, hi, colour_value, colour, axis: _Axis):
428
+ order = np.argsort(xs, kind="stable")
429
+ xs, lo, hi = xs[order], lo[order], hi[order]
430
+ ok = np.isfinite(xs) & np.isfinite(lo) & np.isfinite(hi)
431
+ xs, lo, hi = xs[ok], lo[ok], hi[ok]
432
+ if xs.size < 2:
433
+ return None
434
+ # Lower edge left to right, upper edge right to left: pairs (i, n-1-i)
435
+ # share an x, which the poly strip expects.
436
+ px = np.concatenate([xs, xs[::-1]])
437
+ py = np.concatenate([lo, hi[::-1]])
438
+ frame = pd.DataFrame({"x": axis.out(px), "y": py})
439
+ if colour:
440
+ frame[colour] = [colour_value] * len(px)
441
+ return frame
442
+
443
+
444
+ def _bands_layer(pieces, colour, geom, axis, alpha):
445
+ frames, starts, at = [], [], 0
446
+ for piece in pieces:
447
+ if piece is None:
448
+ continue
449
+ starts.append([at, len(piece)])
450
+ at += len(piece)
451
+ frames.append(piece)
452
+ if not frames:
453
+ return None
454
+ frame = pd.concat(frames, ignore_index=True)
455
+ mapping = {"x": "x", "y": "y", **_colour_mapping(colour)}
456
+ out = _layer("poly", frame, mapping, geom, _groups=starts, linewidth=0.0)
457
+ out.alpha = geom.alpha if geom.alpha is not None else alpha
458
+ return _levels_hook(out, axis)
459
+
460
+
461
+ def ribbon(geom: _Geom, mapping: dict, data: Any) -> _Geom:
462
+ xcol, lo, hi = mapping.get("x"), mapping.get("ymin"), mapping.get("ymax")
463
+ if not xcol or not lo or not hi:
464
+ raise ValueError("geom_ribbon() requires aes(x=, ymin=, ymax=)")
465
+ colour = mapping.get("color")
466
+ colour = colour if colour and has_column(data, colour) else None
467
+ frame = _frame(data, [xcol, lo, hi, colour])
468
+ axis = _axis(frame[xcol])
469
+ groups, levels = _colour_groups(frame, colour)
470
+ colour_values = frame[colour].to_numpy() if colour else None
471
+ pieces = []
472
+ for g in range(len(levels)):
473
+ rows = np.flatnonzero(groups == g)
474
+ pieces.append(
475
+ _band(
476
+ axis.values[rows],
477
+ frame[lo].to_numpy(np.float64)[rows],
478
+ frame[hi].to_numpy(np.float64)[rows],
479
+ colour_values[rows[0]] if colour and rows.size else None,
480
+ colour,
481
+ axis,
482
+ )
483
+ )
484
+ out = _bands_layer(pieces, colour, geom, axis, 0.3)
485
+ if out is None:
486
+ raise ValueError("geom_ribbon() needs at least two rows per group")
487
+ _title(out, "y", lo)
488
+ return out
489
+
490
+
491
+ # ── smoothers ────────────────────────────────────────────────────────────────
492
+
493
+
494
+ def _lm(x: np.ndarray, y: np.ndarray, grid: np.ndarray, level: float):
495
+ n = x.size
496
+ design = np.column_stack([np.ones(n), x])
497
+ coef, *_ = np.linalg.lstsq(design, y, rcond=None)
498
+ fit = coef[0] + coef[1] * grid
499
+ if n <= 2:
500
+ return fit, None
501
+ resid = y - design @ coef
502
+ sigma2 = float(resid @ resid) / (n - 2)
503
+ inv = np.linalg.pinv(design.T @ design)
504
+ g = np.column_stack([np.ones(grid.size), grid])
505
+ se = np.sqrt(np.maximum(np.einsum("ij,jk,ik->i", g, inv, g) * sigma2, 0.0))
506
+ return fit, (se, n - 2)
507
+
508
+
509
+ def _loess_row(x: np.ndarray, x0: float, q: int, degree: int) -> np.ndarray | None:
510
+ """Weights l(x0) so that fit(x0) = l @ y (local polynomial, tricube)."""
511
+ dist = np.abs(x - x0)
512
+ if q >= x.size:
513
+ radius = float(dist.max()) * (q / x.size)
514
+ else:
515
+ radius = float(np.partition(dist, q - 1)[q - 1])
516
+ radius = max(radius, 1e-12)
517
+ w = np.clip(1.0 - (dist / radius) ** 3, 0.0, None) ** 3
518
+ keep = w > 0
519
+ if np.count_nonzero(keep) <= degree:
520
+ return None
521
+ xc = x[keep] - x0
522
+ basis = np.column_stack([xc ** k for k in range(degree + 1)])
523
+ wb = basis * w[keep, None]
524
+ gram = basis.T @ wb
525
+ try:
526
+ row = np.linalg.solve(gram, wb.T)[0]
527
+ except np.linalg.LinAlgError:
528
+ row = (np.linalg.pinv(gram) @ wb.T)[0]
529
+ full = np.zeros(x.size)
530
+ full[keep] = row
531
+ return full
532
+
533
+
534
+ def _loess(x: np.ndarray, y: np.ndarray, grid: np.ndarray, level: float, span: float):
535
+ """R's loess defaults: degree 2, tricube weights, ``span`` of the points."""
536
+ n = x.size
537
+ degree = 2 if n > 3 else 1
538
+ q = max(degree + 2, int(math.ceil(span * n)))
539
+ rows = [_loess_row(x, g, q, degree) for g in grid]
540
+ fit = np.array([float(r @ y) if r is not None else math.nan for r in rows])
541
+ # Residual scale and equivalent degrees of freedom from the smoother's
542
+ # own rows at the data (a subsample keeps big n affordable).
543
+ idx = np.arange(n) if n <= 600 else np.linspace(0, n - 1, 600).astype(int)
544
+ diag, resid = [], []
545
+ for i in idx:
546
+ r = _loess_row(x, float(x[i]), q, degree)
547
+ if r is None:
548
+ continue
549
+ diag.append(r[i])
550
+ resid.append(y[i] - float(r @ y))
551
+ if len(resid) < 3:
552
+ return fit, None
553
+ trace = float(np.mean(diag)) * n
554
+ df = max(n - trace, 1.0)
555
+ sigma2 = float(np.mean(np.square(resid))) * n / df
556
+ se = np.array([
557
+ math.sqrt(sigma2 * float(r @ r)) if r is not None else math.nan for r in rows
558
+ ])
559
+ return fit, (se, df)
560
+
561
+
562
+ def smooth(geom: _Geom, mapping: dict, data: Any):
563
+ xcol, ycol = mapping.get("x"), mapping.get("y")
564
+ if not xcol or not ycol:
565
+ raise ValueError("geom_smooth() requires aes(x=, y=)")
566
+ colour = mapping.get("color")
567
+ colour = colour if colour and colour not in {xcol, ycol} and has_column(data, colour) else None
568
+ frame = _frame(data, [xcol, ycol, colour])
569
+ axis = _axis(frame[xcol])
570
+ if axis.kind == "cat":
571
+ raise ValueError("geom_smooth() needs a numeric or date x")
572
+ method = str(getattr(geom, "method", "loess")).lower()
573
+ if method not in {"loess", "lm"}:
574
+ raise ValueError("geom_smooth(method=) is 'loess' or 'lm'")
575
+ level = float(getattr(geom, "level", 0.95))
576
+ span = float(getattr(geom, "span", 0.75))
577
+ n_grid = int(getattr(geom, "n", 80))
578
+ se_on = bool(getattr(geom, "se", True))
579
+ groups, levels = _colour_groups(frame, colour)
580
+ colour_values = frame[colour].to_numpy() if colour else None
581
+ yv = frame[ycol].to_numpy(np.float64)
582
+ lines, bands, starts, at = [], [], [], 0
583
+ for g in range(len(levels)):
584
+ rows = np.flatnonzero(groups == g)
585
+ x, y = axis.values[rows], yv[rows]
586
+ ok = np.isfinite(x) & np.isfinite(y)
587
+ x, y = x[ok], y[ok]
588
+ if np.unique(x).size < 2:
589
+ continue
590
+ grid = np.linspace(float(x.min()), float(x.max()), n_grid)
591
+ if method == "lm" or np.unique(x).size < 5:
592
+ fit, spread = _lm(x, y, grid, level)
593
+ else:
594
+ fit, spread = _loess(x, y, grid, level, span)
595
+ value = colour_values[rows[0]] if colour else None
596
+ line = pd.DataFrame({"x": axis.out(grid), "y": fit})
597
+ if colour:
598
+ line[colour] = [value] * grid.size
599
+ starts.append([at, grid.size])
600
+ at += grid.size
601
+ lines.append(line)
602
+ if se_on and spread is not None:
603
+ se, df = spread
604
+ t = float(qt(0.5 + level / 2.0, df))
605
+ bands.append(_band(grid, fit - t * se, fit + t * se, value, colour, axis))
606
+ if not lines:
607
+ raise ValueError("geom_smooth() needs at least two distinct x values")
608
+ line_map = {"x": "x", "y": "y", **_colour_mapping(colour)}
609
+ curve = _layer(
610
+ "line", pd.concat(lines, ignore_index=True), line_map, geom,
611
+ _groups=starts, linewidth=float(getattr(geom, "linewidth", 2.0) or 2.0),
612
+ )
613
+ _title(curve, "y", ycol)
614
+ _title(curve, "x", xcol)
615
+ if geom.const_color is None and not colour:
616
+ curve.const_color = "#3366FF" # ggplot2's smoother blue
617
+ band_layer = _bands_layer(bands, colour, geom, axis, _ALPHA_BAND) if bands else None
618
+ if band_layer is None:
619
+ return curve
620
+ if geom.const_color is None and not colour:
621
+ band_layer.const_color = "#999999"
622
+ band_layer.alpha = geom.alpha if geom.alpha is not None else 0.4
623
+ _title(band_layer, "y", ycol)
624
+ return [band_layer, curve]
625
+
626
+
627
+ # ── summaries ────────────────────────────────────────────────────────────────
628
+
629
+
630
+ def _mean_se(v: np.ndarray, mult: float = 1.0):
631
+ m = float(np.mean(v))
632
+ se = float(np.std(v, ddof=1) / math.sqrt(v.size)) if v.size > 1 else 0.0
633
+ return m, m - mult * se, m + mult * se
634
+
635
+
636
+ def _mean_cl_normal(v: np.ndarray, level: float = 0.95):
637
+ m = float(np.mean(v))
638
+ if v.size < 2:
639
+ return m, m, m
640
+ se = float(np.std(v, ddof=1) / math.sqrt(v.size))
641
+ t = float(qt(0.5 + level / 2.0, v.size - 1))
642
+ return m, m - t * se, m + t * se
643
+
644
+
645
+ def _mean_sdl(v: np.ndarray, mult: float = 2.0):
646
+ m = float(np.mean(v))
647
+ sd = float(np.std(v, ddof=1)) if v.size > 1 else 0.0
648
+ return m, m - mult * sd, m + mult * sd
649
+
650
+
651
+ def _median_hilow(v: np.ndarray, conf: float = 0.95):
652
+ lo, mid, hi = np.quantile(v, [0.5 - conf / 2.0, 0.5, 0.5 + conf / 2.0])
653
+ return float(mid), float(lo), float(hi)
654
+
655
+
656
+ SUMMARIES: dict[str, Callable[..., tuple[float, float, float]]] = {
657
+ "mean_se": _mean_se,
658
+ "mean_cl_normal": _mean_cl_normal,
659
+ "mean_sdl": _mean_sdl,
660
+ "median_hilow": _median_hilow,
661
+ }
662
+
663
+
664
+ def summary(geom: _Geom, mapping: dict, data: Any):
665
+ xcol, ycol = mapping.get("x"), mapping.get("y")
666
+ if not xcol or not ycol:
667
+ raise ValueError("stat_summary() requires aes(x=, y=)")
668
+ fun_data = getattr(geom, "fun_data", "mean_se")
669
+ if callable(fun_data):
670
+ fn = fun_data
671
+ else:
672
+ if fun_data not in SUMMARIES:
673
+ raise ValueError(
674
+ f"stat_summary(fun_data=) is one of {sorted(SUMMARIES)}, not {fun_data!r}"
675
+ )
676
+ fn = SUMMARIES[fun_data]
677
+ fun_args = dict(getattr(geom, "fun_args", None) or {})
678
+ colour = mapping.get("color")
679
+ colour = colour if colour and colour not in {xcol, ycol} and has_column(data, colour) else None
680
+ fill_group = mapping.get("__fillgroup")
681
+ fill_group = (
682
+ fill_group
683
+ if not colour and fill_group and fill_group not in {xcol, ycol} and has_column(data, fill_group)
684
+ else None
685
+ )
686
+ split = colour or fill_group
687
+ frame = _frame(data, [xcol, ycol, split])
688
+ keys = [xcol] + ([split] if split else [])
689
+ rows = []
690
+ for key, piece in frame.groupby(keys, sort=False, dropna=False, observed=True):
691
+ values = piece[ycol].to_numpy(np.float64)
692
+ values = values[np.isfinite(values)]
693
+ if values.size == 0:
694
+ continue
695
+ y, lo, hi = fn(values, **fun_args)
696
+ key = key if isinstance(key, tuple) else (key,)
697
+ row = {xcol: key[0], "__y": y, "__ymin": lo, "__ymax": hi}
698
+ if split:
699
+ row[split] = key[1]
700
+ rows.append(row)
701
+ stats = pd.DataFrame(rows)
702
+ if stats.empty:
703
+ raise ValueError("stat_summary() found no numeric y values")
704
+ shape = str(getattr(geom, "geom", "pointrange"))
705
+ stat_map = {"x": xcol, "y": "__y", "ymin": "__ymin", "ymax": "__ymax"}
706
+ if colour:
707
+ stat_map["color"] = colour
708
+ elif fill_group:
709
+ stat_map["__fillgroup"] = fill_group
710
+ proxy = _Geom(color=geom.const_color, alpha=geom.alpha)
711
+ proxy.position = getattr(geom, "position", None)
712
+ proxy.width = getattr(geom, "width", 0.5)
713
+ proxy.linewidth = getattr(geom, "linewidth", 1.0)
714
+ proxy.size = getattr(geom, "size", None)
715
+ _title(proxy, "y", ycol)
716
+ if shape in {"pointrange", "errorbar", "linerange"}:
717
+ proxy.kind = shape
718
+ return ranges(proxy, stat_map, stats)
719
+ if shape in {"col", "bar"}:
720
+ proxy.kind = "col"
721
+ proxy.width = getattr(geom, "width", 0.9)
722
+ if split:
723
+ # Bars are filled shapes: the fill group colours and dodges them.
724
+ proxy.position = proxy.position or "dodge"
725
+ return positioned_bars(proxy, {"x": xcol, "y": "__y", "color": split}, stats)
726
+ # One bar per x: the ordinary column path keeps its hover tooltips.
727
+ out = _Geom(aes(x=xcol, y="__y"), color=geom.const_color, alpha=geom.alpha)
728
+ out.kind = "col"
729
+ out.width = proxy.width
730
+ out.data_override = stats
731
+ out._replace_mapping = True
732
+ out.const_color = geom.const_color
733
+ _title(out, "y", ycol)
734
+ return out
735
+ if shape == "point":
736
+ dot = _layer("point", stats.rename(columns={"__y": "y", xcol: "x"}),
737
+ {"x": "x", "y": "y", **_colour_mapping(colour)}, geom)
738
+ dot.size = getattr(geom, "size", None)
739
+ return dot
740
+ raise ValueError("stat_summary(geom=) is 'pointrange', 'errorbar', 'linerange', 'col', or 'point'")
741
+
742
+
743
+
744
+ # ── tiles, areas, steps, segments, rectangles ────────────────────────────────
745
+
746
+
747
+ def _rect_rows(lefts, rights, bottoms, tops, keep_cols: dict):
748
+ """Rectangles as 4-point polygons (left edge up, right edge down)."""
749
+ xs, ys, groups = [], [], []
750
+ extra = {name: [] for name in keep_cols}
751
+ for i in range(len(lefts)):
752
+ values = (lefts[i], rights[i], bottoms[i], tops[i])
753
+ if not all(np.isfinite(values)):
754
+ continue
755
+ groups.append([len(xs), 4])
756
+ xs += [lefts[i], lefts[i], rights[i], rights[i]]
757
+ ys += [bottoms[i], tops[i], tops[i], bottoms[i]]
758
+ for name, column in keep_cols.items():
759
+ extra[name] += [column[i]] * 4
760
+ return xs, ys, groups, extra
761
+
762
+
763
+ def tile(geom: _Geom, mapping: dict, data: Any) -> _Geom:
764
+ """geom_tile / geom_raster: a heatmap cell per row."""
765
+ xcol, ycol = mapping.get("x"), mapping.get("y")
766
+ if not xcol or not ycol:
767
+ raise ValueError("geom_tile() requires aes(x=, y=) and usually fill=")
768
+ colour = mapping.get("color")
769
+ colour = colour if colour and has_column(data, colour) else None
770
+ frame = _frame(data, [xcol, ycol, colour])
771
+ xaxis, yaxis = _axis(frame[xcol]), _axis(frame[ycol])
772
+ width = getattr(geom, "width", None)
773
+ height = getattr(geom, "height", None)
774
+ w = xaxis.step() if width is None else float(width) * (1.0 if xaxis.kind != "cat" else 1.0)
775
+ h = yaxis.step() if height is None else float(height)
776
+ xs, ys = xaxis.values, yaxis.values
777
+ keep = {colour: frame[colour].tolist()} if colour else {}
778
+ rx, ry, groups, extra = _rect_rows(xs - w / 2, xs + w / 2, ys - h / 2, ys + h / 2, keep)
779
+ out_frame = pd.DataFrame({"x": xaxis.out(np.asarray(rx)), "y": yaxis.out(np.asarray(ry))})
780
+ out_map = {"x": "x", "y": "y"}
781
+ if colour:
782
+ out_frame[colour] = extra[colour]
783
+ out_map["colour"] = colour
784
+ alpha = geom.alpha if geom.alpha is not None else 1.0
785
+ # Opaque cells get a hairline in their own colour: neighbours then meet
786
+ # without the pale antialiased seam between them.
787
+ out = _layer("poly", out_frame, out_map, geom, _groups=groups,
788
+ linewidth=0.6 if alpha >= 0.9 else 0.01)
789
+ out.alpha = alpha
790
+ _title(out, "x", xcol)
791
+ _title(out, "y", ycol)
792
+ _levels_hook(out, xaxis)
793
+ if yaxis.kind == "cat" and yaxis.levels is not None:
794
+ out._y_levels = list(yaxis.levels)
795
+ return out
796
+
797
+
798
+ def area(geom: _Geom, mapping: dict, data: Any):
799
+ """geom_area: filled from 0, groups stacked on a shared x grid (ggplot2)."""
800
+ xcol, ycol = mapping.get("x"), mapping.get("y")
801
+ if not xcol or not ycol:
802
+ raise ValueError("geom_area() requires aes(x=, y=)")
803
+ colour = mapping.get("color")
804
+ colour = colour if colour and colour not in {xcol, ycol} and has_column(data, colour) else None
805
+ frame = _frame(data, [xcol, ycol, colour])
806
+ axis = _axis(frame[xcol])
807
+ if axis.kind == "cat":
808
+ raise ValueError("geom_area() needs a numeric or date x")
809
+ groups, levels = _colour_groups(frame, colour)
810
+ kind, _w = position_kind(getattr(geom, "position", None), "stack")
811
+ grid = np.unique(axis.values[np.isfinite(axis.values)])
812
+ yv = frame[ycol].to_numpy(np.float64)
813
+ series = []
814
+ for g in range(len(levels)):
815
+ rows = np.flatnonzero(groups == g)
816
+ order = np.argsort(axis.values[rows])
817
+ gx, gy = axis.values[rows][order], yv[rows][order]
818
+ ok = np.isfinite(gx) & np.isfinite(gy)
819
+ gx, gy = gx[ok], gy[ok]
820
+ # Outside a group's own x range it adds nothing to the stack.
821
+ series.append(np.interp(grid, gx, gy, left=0.0, right=0.0) if gx.size else np.zeros_like(grid))
822
+ bottoms = [np.zeros_like(grid) for _ in series]
823
+ tops = [s.copy() for s in series]
824
+ if kind in {"stack", "fill"} and len(series) > 1:
825
+ running = np.zeros_like(grid)
826
+ for g in reversed(range(len(series))): # first level on top
827
+ bottoms[g] = running.copy()
828
+ running = running + series[g]
829
+ tops[g] = running.copy()
830
+ if kind == "fill":
831
+ total = np.where(running > 0, running, 1.0)
832
+ bottoms = [b / total for b in bottoms]
833
+ tops = [t / total for t in tops]
834
+ colour_values = frame[colour].to_numpy() if colour else None
835
+ pieces = []
836
+ for g in range(len(series)):
837
+ rows = np.flatnonzero(groups == g)
838
+ value = colour_values[rows[0]] if colour and rows.size else None
839
+ pieces.append(_band(grid, bottoms[g], tops[g], value, colour, axis))
840
+ out = _bands_layer(pieces, colour, geom, axis, 0.85)
841
+ if out is None:
842
+ raise ValueError("geom_area() needs at least two x values")
843
+ out._baseline_zero = True
844
+ _title(out, "x", xcol)
845
+ _title(out, "y", "proportion" if kind == "fill" else ycol)
846
+ return out
847
+
848
+
849
+ def step(geom: _Geom, mapping: dict, data: Any) -> _Geom:
850
+ """geom_step: horizontal then vertical ("hv"), "vh", or "mid"."""
851
+ xcol, ycol = mapping.get("x"), mapping.get("y")
852
+ if not xcol or not ycol:
853
+ raise ValueError("geom_step() requires aes(x=, y=)")
854
+ direction = str(getattr(geom, "direction", "hv"))
855
+ if direction not in {"hv", "vh", "mid"}:
856
+ raise ValueError("geom_step(direction=) is 'hv', 'vh', or 'mid'")
857
+ colour = mapping.get("color")
858
+ colour = colour if colour and has_column(data, colour) else None
859
+ group = mapping.get("group")
860
+ split = colour or (group if group and has_column(data, group) else None)
861
+ frame = _frame(data, [xcol, ycol, split])
862
+ axis = _axis(frame[xcol])
863
+ groups, levels = _colour_groups(frame, split)
864
+ xs_all, ys_all = axis.values, frame[ycol].to_numpy(np.float64)
865
+ out_x, out_y, out_c, starts = [], [], [], []
866
+ for g in range(len(levels)):
867
+ rows = np.flatnonzero(groups == g)
868
+ order = rows[np.argsort(xs_all[rows], kind="stable")]
869
+ px, py = _step_path(xs_all[order], ys_all[order], direction)
870
+ if len(px) < 2:
871
+ continue
872
+ starts.append([len(out_x), len(px)])
873
+ out_x += px
874
+ out_y += py
875
+ if colour:
876
+ out_c += [frame[colour].iloc[order[0]]] * len(px)
877
+ frame_out = pd.DataFrame({"x": axis.out(np.asarray(out_x)), "y": out_y})
878
+ out_map = {"x": "x", "y": "y"}
879
+ if colour:
880
+ frame_out[colour] = out_c
881
+ out_map["colour"] = colour
882
+ out = _layer("line", frame_out, out_map, geom, _groups=starts,
883
+ linewidth=float(getattr(geom, "linewidth", 2.0) or 2.0))
884
+ out.linetype = getattr(geom, "linetype", None)
885
+ _title(out, "x", xcol)
886
+ _title(out, "y", getattr(geom, "_y_name", None) or ycol)
887
+ _levels_hook(out, axis)
888
+ return out
889
+
890
+
891
+ def _step_path(xs, ys, direction):
892
+ px, py = [], []
893
+ for i in range(len(xs)):
894
+ if i == 0:
895
+ px.append(float(xs[0]))
896
+ py.append(float(ys[0]))
897
+ continue
898
+ x0, y0, x1, y1 = float(xs[i - 1]), float(ys[i - 1]), float(xs[i]), float(ys[i])
899
+ if direction == "hv":
900
+ px += [x1, x1]
901
+ py += [y0, y1]
902
+ elif direction == "vh":
903
+ px += [x0, x1]
904
+ py += [y1, y1]
905
+ else:
906
+ mid = (x0 + x1) / 2
907
+ px += [mid, mid, x1]
908
+ py += [y0, y1, y1]
909
+ return px, py
910
+
911
+
912
+ def segment(geom: _Geom, mapping: dict, data: Any) -> _Geom:
913
+ """geom_segment: a line from (x, y) to (xend, yend) for every row."""
914
+ need = ["x", "y", "xend", "yend"]
915
+ if any(not mapping.get(k) for k in need):
916
+ raise ValueError("geom_segment() requires aes(x=, y=, xend=, yend=)")
917
+ colour = mapping.get("color")
918
+ colour = colour if colour and has_column(data, colour) else None
919
+ frame = _frame(data, [mapping[k] for k in need] + [colour])
920
+ cols = [frame[mapping[k]].to_numpy(np.float64) for k in need]
921
+ out_x, out_y, out_c, starts = [], [], [], []
922
+ for i in range(len(frame)):
923
+ x, y, xe, ye = (c[i] for c in cols)
924
+ if not all(np.isfinite([x, y, xe, ye])):
925
+ continue
926
+ starts.append([len(out_x), 2])
927
+ out_x += [x, xe]
928
+ out_y += [y, ye]
929
+ if colour:
930
+ out_c += [frame[colour].iloc[i]] * 2
931
+ frame_out = pd.DataFrame({"x": out_x, "y": out_y})
932
+ out_map = {"x": "x", "y": "y"}
933
+ if colour:
934
+ frame_out[colour] = out_c
935
+ out_map["colour"] = colour
936
+ out = _layer("line", frame_out, out_map, geom, _groups=starts,
937
+ linewidth=float(getattr(geom, "linewidth", 1.0) or 1.0), _ink_default=not colour)
938
+ out.linetype = getattr(geom, "linetype", None)
939
+ out.arrow = getattr(geom, "arrow", None)
940
+ _title(out, "x", mapping["x"])
941
+ _title(out, "y", mapping["y"])
942
+ return out
943
+
944
+
945
+ def rect(geom: _Geom, mapping: dict, data: Any) -> _Geom:
946
+ """geom_rect: a rectangle from xmin..xmax and ymin..ymax for every row."""
947
+ need = ["xmin", "xmax", "ymin", "ymax"]
948
+ if any(not mapping.get(k) for k in need):
949
+ raise ValueError("geom_rect() requires aes(xmin=, xmax=, ymin=, ymax=)")
950
+ colour = mapping.get("color")
951
+ colour = colour if colour and has_column(data, colour) else None
952
+ frame = _frame(data, [mapping[k] for k in need] + [colour])
953
+ x0, x1, y0, y1 = (frame[mapping[k]].to_numpy(np.float64) for k in need)
954
+ keep = {colour: frame[colour].tolist()} if colour else {}
955
+ rx, ry, groups, extra = _rect_rows(x0, x1, y0, y1, keep)
956
+ out_frame = pd.DataFrame({"x": rx, "y": ry})
957
+ out_map = {"x": "x", "y": "y"}
958
+ if colour:
959
+ out_frame[colour] = extra[colour]
960
+ out_map["colour"] = colour
961
+ out = _layer("poly", out_frame, out_map, geom, _groups=groups, linewidth=0.01)
962
+ out.alpha = geom.alpha if geom.alpha is not None else 0.6
963
+ _title(out, "x", mapping["xmin"])
964
+ _title(out, "y", mapping["ymin"])
965
+ return out
966
+
967
+
968
+ # ── distributions: Q-Q and ECDF ──────────────────────────────────────────────
969
+
970
+
971
+ def _ppoints(n: int) -> np.ndarray:
972
+ """R's ppoints: (i - a) / (n + 1 - 2a), a = 3/8 up to 10 points, else 1/2."""
973
+ a = 3.0 / 8.0 if n <= 10 else 0.5
974
+ return (np.arange(1, n + 1) - a) / (n + 1 - 2 * a)
975
+
976
+
977
+ def _sample_groups(geom, mapping, data, who):
978
+ sample = mapping.get("sample") or mapping.get("y")
979
+ if not sample:
980
+ raise ValueError(f"{who}() requires aes(sample=)")
981
+ colour = mapping.get("color")
982
+ colour = colour if colour and colour != sample and has_column(data, colour) else None
983
+ frame = _frame(data, [sample, colour])
984
+ groups, levels = _colour_groups(frame, colour)
985
+ values = frame[sample].to_numpy(np.float64)
986
+ out = []
987
+ for g in range(len(levels)):
988
+ rows = np.flatnonzero(groups == g)
989
+ v = np.sort(values[rows][np.isfinite(values[rows])])
990
+ label = frame[colour].iloc[rows[0]] if colour and rows.size else None
991
+ if v.size:
992
+ out.append((v, label))
993
+ return sample, colour, out
994
+
995
+
996
+ def qq(geom: _Geom, mapping: dict, data: Any) -> _Geom:
997
+ """geom_qq / stat_qq: sample quantiles against the normal distribution."""
998
+ from plot3.special import qnorm
999
+
1000
+ sample, colour, parts = _sample_groups(geom, mapping, data, "geom_qq")
1001
+ xs, ys, cs = [], [], []
1002
+ for values, label in parts:
1003
+ theory = np.asarray(qnorm(_ppoints(values.size)), dtype=np.float64)
1004
+ xs += theory.tolist()
1005
+ ys += values.tolist()
1006
+ cs += [label] * values.size
1007
+ frame = pd.DataFrame({"x": xs, "y": ys})
1008
+ out_map = {"x": "x", "y": "y"}
1009
+ if colour:
1010
+ frame[colour] = cs
1011
+ out_map["colour"] = colour
1012
+ out = _layer("point", frame, out_map, geom)
1013
+ out.size = getattr(geom, "size", None)
1014
+ _title(out, "x", "theoretical")
1015
+ _title(out, "y", "sample")
1016
+ return out
1017
+
1018
+
1019
+ def qq_line(geom: _Geom, mapping: dict, data: Any) -> _Geom:
1020
+ """geom_qq_line: the line through the first and third quartiles."""
1021
+ from plot3.special import qnorm
1022
+
1023
+ sample, colour, parts = _sample_groups(geom, mapping, data, "geom_qq_line")
1024
+ xs, ys, cs, starts = [], [], [], []
1025
+ q_theory = np.asarray(qnorm(np.array([0.25, 0.75])), dtype=np.float64)
1026
+ for values, label in parts:
1027
+ if values.size < 2:
1028
+ continue
1029
+ q_sample = np.quantile(values, [0.25, 0.75]) # R's quantile type 7
1030
+ slope = (q_sample[1] - q_sample[0]) / (q_theory[1] - q_theory[0])
1031
+ intercept = q_sample[0] - slope * q_theory[0]
1032
+ theory = np.asarray(qnorm(_ppoints(values.size)), dtype=np.float64)
1033
+ ends = np.array([theory.min(), theory.max()])
1034
+ starts.append([len(xs), 2])
1035
+ xs += ends.tolist()
1036
+ ys += (intercept + slope * ends).tolist()
1037
+ cs += [label] * 2
1038
+ frame = pd.DataFrame({"x": xs, "y": ys})
1039
+ out_map = {"x": "x", "y": "y"}
1040
+ if colour:
1041
+ frame[colour] = cs
1042
+ out_map["colour"] = colour
1043
+ out = _layer("line", frame, out_map, geom, _groups=starts,
1044
+ linewidth=float(getattr(geom, "linewidth", 1.5) or 1.5), _ink_default=not colour)
1045
+ out.linetype = getattr(geom, "linetype", None)
1046
+ _title(out, "x", "theoretical")
1047
+ _title(out, "y", "sample")
1048
+ return out
1049
+
1050
+
1051
+ def ecdf(geom: _Geom, mapping: dict, data: Any) -> _Geom:
1052
+ """stat_ecdf: the empirical cumulative distribution as a step line."""
1053
+ xcol = mapping.get("x")
1054
+ if not xcol:
1055
+ raise ValueError("stat_ecdf() requires aes(x=)")
1056
+ colour = mapping.get("color")
1057
+ colour = colour if colour and colour != xcol and has_column(data, colour) else None
1058
+ frame = _frame(data, [xcol, colour])
1059
+ groups, levels = _colour_groups(frame, colour)
1060
+ values = frame[xcol].to_numpy(np.float64)
1061
+ finite = values[np.isfinite(values)]
1062
+ span = float(finite.max() - finite.min()) if finite.size else 1.0
1063
+ pad = 0.04 * (span or 1.0) if getattr(geom, "pad", True) else 0.0
1064
+ rows_out = []
1065
+ for g in range(len(levels)):
1066
+ rows = np.flatnonzero(groups == g)
1067
+ v = np.sort(values[rows][np.isfinite(values[rows])])
1068
+ if v.size == 0:
1069
+ continue
1070
+ # F(x) at each distinct value: ties make one step, not several.
1071
+ distinct = np.unique(v)
1072
+ heights = np.searchsorted(v, distinct, side="right") / v.size
1073
+ xs = np.concatenate([[distinct[0] - pad], distinct, [distinct[-1] + pad]])
1074
+ ys = np.concatenate([[0.0], heights, [1.0]])
1075
+ label = frame[colour].iloc[rows[0]] if colour else None
1076
+ for x, y in zip(xs, ys):
1077
+ rows_out.append({"__x": x, "__y": y, **({colour: label} if colour else {})})
1078
+ table = pd.DataFrame(rows_out)
1079
+ step_geom = _Geom(color=geom.const_color, alpha=geom.alpha)
1080
+ step_geom.kind = "step"
1081
+ step_geom.direction = "hv"
1082
+ step_geom.linewidth = getattr(geom, "linewidth", 2.0)
1083
+ step_geom._y_name = "ECDF"
1084
+ out = step(step_geom, {"x": "__x", "y": "__y", **({"color": colour} if colour else {})}, table)
1085
+ _title(out, "x", xcol)
1086
+ _title(out, "y", "ECDF")
1087
+ return out
1088
+
1089
+
1090
+ # ── crossbars, horizontal error bars, polygons ───────────────────────────────
1091
+
1092
+
1093
+ def crossbar(geom: _Geom, mapping: dict, data: Any):
1094
+ """geom_crossbar: a box from ymin to ymax and a thick line at y."""
1095
+ frame, xcol, lo, hi, ycol, colour, dodge = _range_frame(geom, mapping, data, True)
1096
+ fill = mapping.get("__fillgroup")
1097
+ fill = fill if fill and has_column(data, fill) and fill != colour else None
1098
+ if fill and fill not in frame.columns:
1099
+ frame = _frame(data, [xcol, lo, hi, ycol, colour, fill])
1100
+ dodge = dodge or fill
1101
+ axis = _axis(frame[xcol])
1102
+ groups, levels = _colour_groups(frame, dodge)
1103
+ xs = _dodged_x(geom, axis, groups, len(levels))
1104
+ dodge_kind, _w = position_kind(getattr(geom, "position", None), "identity")
1105
+ slot = axis.step() * (1.0 / len(levels) if dodge_kind == "dodge" else 1.0)
1106
+ half = 0.5 * slot * float(getattr(geom, "width", 0.9))
1107
+ y_lo, y_hi = frame[lo].to_numpy(np.float64), frame[hi].to_numpy(np.float64)
1108
+ y_mid = frame[ycol].to_numpy(np.float64)
1109
+ box_x, box_y, box_c, box_starts = [], [], [], []
1110
+ mid_x, mid_y, mid_c, mid_starts = [], [], [], []
1111
+ tag = colour or fill
1112
+ tags = frame[tag].tolist() if tag else None
1113
+ for i in range(len(frame)):
1114
+ x, a, b, m = xs[i], y_lo[i], y_hi[i], y_mid[i]
1115
+ if not all(math.isfinite(v) for v in (x, a, b, m)):
1116
+ continue
1117
+ box_starts.append([len(box_x), 5])
1118
+ box_x += [x - half, x + half, x + half, x - half, x - half]
1119
+ box_y += [a, a, b, b, a]
1120
+ mid_starts.append([len(mid_x), 2])
1121
+ mid_x += [x - half, x + half]
1122
+ mid_y += [m, m]
1123
+ if tags is not None:
1124
+ box_c += [tags[i]] * 5
1125
+ mid_c += [tags[i]] * 2
1126
+ layers = []
1127
+ linewidth = float(getattr(geom, "linewidth", 1.0) or 1.0)
1128
+ if fill:
1129
+ # Filled boxes under dark lines, as ggplot2 draws crossbars.
1130
+ fill_frame = pd.DataFrame({"x": axis.out(np.asarray(box_x)), "y": box_y, fill: box_c})
1131
+ body = _layer("poly", fill_frame, {"x": "x", "y": "y", "colour": fill}, geom,
1132
+ _groups=box_starts, linewidth=0.0, _polygon=True)
1133
+ body.alpha = geom.alpha if geom.alpha is not None else 1.0
1134
+ layers.append(_levels_hook(body, axis))
1135
+ line_colour = colour if colour else None
1136
+ for xs_part, ys_part, cs_part, starts, width in (
1137
+ (box_x, box_y, box_c, box_starts, linewidth),
1138
+ (mid_x, mid_y, mid_c, mid_starts, linewidth * float(getattr(geom, "fatten", 2.5))),
1139
+ ):
1140
+ part = pd.DataFrame({"x": axis.out(np.asarray(xs_part)), "y": ys_part})
1141
+ part_map = {"x": "x", "y": "y"}
1142
+ if line_colour:
1143
+ part[line_colour] = cs_part
1144
+ part_map["colour"] = line_colour
1145
+ line = _layer("line", part, part_map, geom, _groups=starts, linewidth=width,
1146
+ _ink_default=not line_colour)
1147
+ if not line_colour:
1148
+ line.const_color = geom.const_color
1149
+ _title(line, "y", ycol)
1150
+ layers.append(_levels_hook(line, axis))
1151
+ return layers
1152
+
1153
+
1154
+ def errorbarh(geom: _Geom, mapping: dict, data: Any) -> _Geom:
1155
+ """geom_errorbarh: from xmin to xmax at y, with caps up and down."""
1156
+ ycol, lo, hi = mapping.get("y"), mapping.get("xmin"), mapping.get("xmax")
1157
+ if not ycol or not lo or not hi:
1158
+ raise ValueError("geom_errorbarh() requires aes(y=, xmin=, xmax=)")
1159
+ colour = mapping.get("color")
1160
+ colour = colour if colour and has_column(data, colour) else None
1161
+ frame = _frame(data, [ycol, lo, hi, colour])
1162
+ yaxis = _axis(frame[ycol])
1163
+ cap = 0.5 * yaxis.step() * float(getattr(geom, "height", 0.5))
1164
+ x_lo, x_hi = frame[lo].to_numpy(np.float64), frame[hi].to_numpy(np.float64)
1165
+ ys = yaxis.values.astype(np.float64)
1166
+ rows_x, rows_y, rows_c, starts = [], [], [], []
1167
+ tags = frame[colour].tolist() if colour else None
1168
+ for i in range(len(frame)):
1169
+ y, a, b = ys[i], x_lo[i], x_hi[i]
1170
+ if not all(math.isfinite(v) for v in (y, a, b)):
1171
+ continue
1172
+ starts.append([len(rows_x), 6])
1173
+ rows_x += [a, a, a, b, b, b]
1174
+ rows_y += [y - cap, y + cap, y, y, y - cap, y + cap]
1175
+ if tags is not None:
1176
+ rows_c += [tags[i]] * 6
1177
+ out_frame = pd.DataFrame({"x": rows_x, "y": yaxis.out(np.asarray(rows_y))})
1178
+ out_map = {"x": "x", "y": "y"}
1179
+ if colour:
1180
+ out_frame[colour] = rows_c
1181
+ out_map["colour"] = colour
1182
+ out = _layer("line", out_frame, out_map, geom, _groups=starts,
1183
+ linewidth=float(getattr(geom, "linewidth", 1.0) or 1.0), _ink_default=not colour)
1184
+ if yaxis.kind == "cat" and yaxis.levels is not None:
1185
+ out._y_levels = list(yaxis.levels)
1186
+ _title(out, "x", lo)
1187
+ _title(out, "y", ycol)
1188
+ return out
1189
+
1190
+
1191
+ def polygon(geom: _Geom, mapping: dict, data: Any) -> _Geom:
1192
+ """geom_polygon: one closed shape per group, corners in row order."""
1193
+ xcol, ycol = mapping.get("x"), mapping.get("y")
1194
+ if not xcol or not ycol:
1195
+ raise ValueError("geom_polygon() requires aes(x=, y=)")
1196
+ colour = mapping.get("color")
1197
+ colour = colour if colour and has_column(data, colour) else None
1198
+ group = mapping.get("group")
1199
+ group = group if group and has_column(data, group) else None
1200
+ frame = _frame(data, [xcol, ycol, colour, group]).reset_index(drop=True)
1201
+ key_cols = [c for c in (group, colour) if c]
1202
+ if key_cols:
1203
+ keys = frame[key_cols].astype(str).agg("\x1f".join, axis=1)
1204
+ order = pd.unique(keys)
1205
+ rank = {k: i for i, k in enumerate(order)}
1206
+ frame = frame.iloc[np.argsort(keys.map(rank).to_numpy(), kind="stable")].reset_index(drop=True)
1207
+ keys = frame[key_cols].astype(str).agg("\x1f".join, axis=1).to_numpy()
1208
+ cut = np.flatnonzero(keys[1:] != keys[:-1]) + 1
1209
+ bounds = np.concatenate([[0], cut, [len(frame)]])
1210
+ else:
1211
+ bounds = np.array([0, len(frame)])
1212
+ starts = [[int(a), int(b - a)] for a, b in zip(bounds[:-1], bounds[1:]) if b - a >= 3]
1213
+ out_frame = pd.DataFrame({"x": frame[xcol].to_numpy(np.float64), "y": frame[ycol].to_numpy(np.float64)})
1214
+ out_map = {"x": "x", "y": "y"}
1215
+ if colour:
1216
+ out_frame[colour] = frame[colour].to_numpy()
1217
+ out_map["colour"] = colour
1218
+ out = _layer("poly", out_frame, out_map, geom, _groups=starts,
1219
+ linewidth=float(getattr(geom, "linewidth", 0.5)), _polygon=True)
1220
+ out.alpha = geom.alpha if geom.alpha is not None else 1.0
1221
+ _title(out, "x", xcol)
1222
+ _title(out, "y", ycol)
1223
+ return out
1224
+
1225
+
1226
+ # ── 2D distributions: bins, hexagons, densities, contours, ellipses ──────────
1227
+
1228
+
1229
+ def _xy_numeric(geom: _Geom, mapping: dict, data: Any, name: str, extra=()):
1230
+ xcol, ycol = mapping.get("x"), mapping.get("y")
1231
+ if not xcol or not ycol:
1232
+ raise ValueError(f"{name}() requires aes(x=, y=)")
1233
+ frame = _frame(data, [xcol, ycol, *[c for c in extra if c]])
1234
+ xs = frame[xcol].to_numpy(np.float64)
1235
+ ys = frame[ycol].to_numpy(np.float64)
1236
+ ok = np.isfinite(xs) & np.isfinite(ys)
1237
+ if not ok.any():
1238
+ raise ValueError(f"{name}() needs numeric x and y")
1239
+ return frame.loc[ok].reset_index(drop=True), xcol, ycol, xs[ok], ys[ok]
1240
+
1241
+
1242
+ def _bin_edges(values: np.ndarray, bins, binwidth) -> np.ndarray:
1243
+ lo, hi = float(values.min()), float(values.max())
1244
+ if hi <= lo:
1245
+ lo, hi = lo - 0.5, hi + 0.5
1246
+ if binwidth is not None:
1247
+ w = float(binwidth)
1248
+ start = math.floor(lo / w) * w
1249
+ count = max(1, int(math.ceil((hi - start) / w + 1e-9)))
1250
+ return start + w * np.arange(count + 1)
1251
+ return np.linspace(lo, hi, int(bins) + 1)
1252
+
1253
+
1254
+ def bin_2d(geom: _Geom, mapping: dict, data: Any) -> _Geom:
1255
+ """geom_bin_2d: rows counted in rectangles, drawn as tiles by count."""
1256
+ frame, xcol, ycol, xs, ys = _xy_numeric(geom, mapping, data, "geom_bin_2d")
1257
+ bins = getattr(geom, "bins", 30)
1258
+ bw = getattr(geom, "binwidth", None)
1259
+ bx, by = (bins, bins) if not isinstance(bins, (tuple, list)) else bins
1260
+ wx, wy = (bw, bw) if not isinstance(bw, (tuple, list)) else bw
1261
+ ex, ey = _bin_edges(xs, bx, wx), _bin_edges(ys, by, wy)
1262
+ counts, _, _ = np.histogram2d(xs, ys, bins=[ex, ey])
1263
+ cx, cy = 0.5 * (ex[:-1] + ex[1:]), 0.5 * (ey[:-1] + ey[1:])
1264
+ ii, jj = np.nonzero(counts)
1265
+ cells = pd.DataFrame({"x": cx[ii], "y": cy[jj], "count": counts[ii, jj]})
1266
+ proxy = _Geom(alpha=geom.alpha)
1267
+ proxy.kind = "tile"
1268
+ proxy.width = float(np.median(np.diff(ex)))
1269
+ proxy.height = float(np.median(np.diff(ey)))
1270
+ out = tile(proxy, {"x": "x", "y": "y", "color": "count"}, cells)
1271
+ out._colour_title = "count"
1272
+ _title(out, "x", xcol)
1273
+ _title(out, "y", ycol)
1274
+ return out
1275
+
1276
+
1277
+ def hex_bins(geom: _Geom, mapping: dict, data: Any) -> _Geom:
1278
+ """geom_hex: rows counted in hexagons (pointy side up), filled by count."""
1279
+ frame, xcol, ycol, xs, ys = _xy_numeric(geom, mapping, data, "geom_hex")
1280
+ bins = getattr(geom, "bins", 30)
1281
+ bw = getattr(geom, "binwidth", None)
1282
+ if bw is not None:
1283
+ w, h = (bw, bw) if not isinstance(bw, (tuple, list)) else (float(bw[0]), float(bw[1]))
1284
+ else:
1285
+ bx, by = (bins, bins) if not isinstance(bins, (tuple, list)) else bins
1286
+ w = (xs.max() - xs.min()) / bx or 1.0
1287
+ h = (ys.max() - ys.min()) / by or 1.0
1288
+ # In (u, v), with u = x / w and v = y / h * sqrt(3)/2, the hexagons are
1289
+ # regular, so the nearest centre is the hexagon a point falls in.
1290
+ u = (xs - xs.min()) / w
1291
+ v = (ys - ys.min()) / h
1292
+ k = math.sqrt(3.0) / 2.0
1293
+ row0 = np.floor(v)
1294
+ best_r = np.zeros_like(u)
1295
+ best_c = np.zeros_like(u)
1296
+ best_d = np.full(u.shape, np.inf)
1297
+ for dr in (0.0, 1.0):
1298
+ r = row0 + dr
1299
+ offset = np.where(np.mod(r, 2) == 1, 0.5, 0.0)
1300
+ c = np.round(u - offset)
1301
+ d = (u - (c + offset)) ** 2 + ((v - r) * k) ** 2
1302
+ better = d < best_d
1303
+ best_r, best_c, best_d = np.where(better, r, best_r), np.where(better, c, best_c), np.where(better, d, best_d)
1304
+ keys, counts = np.unique(np.stack([best_r, best_c], axis=1), axis=0, return_counts=True)
1305
+ rows = []
1306
+ for (r, c), n in zip(keys, counts):
1307
+ cx = xs.min() + (c + (0.5 if int(r) % 2 else 0.0)) * w
1308
+ cy = ys.min() + r * h
1309
+ for dx, dy in ((0, 2 / 3), (0.5, 1 / 3), (0.5, -1 / 3), (0, -2 / 3), (-0.5, -1 / 3), (-0.5, 1 / 3)):
1310
+ rows.append((cx + dx * w, cy + dy * h, f"{int(r)}:{int(c)}", float(n)))
1311
+ cells = pd.DataFrame(rows, columns=["x", "y", "hex", "count"])
1312
+ proxy = _Geom(alpha=geom.alpha)
1313
+ proxy.kind = "polygon"
1314
+ proxy.linewidth = 0.3
1315
+ out = polygon(proxy, {"x": "x", "y": "y", "group": "hex", "color": "count"}, cells)
1316
+ out._colour_title = "count"
1317
+ _title(out, "x", xcol)
1318
+ _title(out, "y", ycol)
1319
+ return out
1320
+
1321
+
1322
+ def _bandwidth(values: np.ndarray) -> float:
1323
+ """MASS::bandwidth.nrd / 4: the normal kernel's sd, as kde2d uses it."""
1324
+ sd = float(np.std(values, ddof=1)) if values.size > 1 else 0.0
1325
+ q75, q25 = np.percentile(values, [75, 25])
1326
+ spread = min(sd, (q75 - q25) / 1.34) or sd or 1.0
1327
+ return 1.06 * spread * values.size ** (-0.2)
1328
+
1329
+
1330
+ def _kde2d(xs, ys, n, lims=None, h=None):
1331
+ """Gaussian kernel density on an n x n grid (MASS::kde2d)."""
1332
+ x0, x1 = (xs.min(), xs.max()) if lims is None else lims[:2]
1333
+ y0, y1 = (ys.min(), ys.max()) if lims is None else lims[2:]
1334
+ gx, gy = np.linspace(x0, x1, n), np.linspace(y0, y1, n)
1335
+ hx, hy = (_bandwidth(xs), _bandwidth(ys)) if h is None else (float(h[0]) / 4, float(h[1]) / 4)
1336
+ kx = np.exp(-0.5 * ((gx[:, None] - xs[None, :]) / hx) ** 2) / (hx * math.sqrt(2 * math.pi))
1337
+ ky = np.exp(-0.5 * ((gy[:, None] - ys[None, :]) / hy) ** 2) / (hy * math.sqrt(2 * math.pi))
1338
+ z = (ky @ kx.T) / xs.size # (ny, nx), the shape the contour code takes
1339
+ return gx, gy, z
1340
+
1341
+
1342
+ def _levels(z: np.ndarray, bins, breaks) -> list[float]:
1343
+ if breaks is not None:
1344
+ return [float(b) for b in breaks]
1345
+ from plot3.scales import nice_ticks
1346
+
1347
+ lo, hi = float(np.nanmin(z)), float(np.nanmax(z))
1348
+ return [t for t in nice_ticks(lo, hi, int(bins or 10)) if lo < t < hi]
1349
+
1350
+
1351
+ def _contour_layer(geom, pieces, colour_col, xcol, ycol, default_colour):
1352
+ from plot3.contour import _contour_lines
1353
+
1354
+ rows, starts, values = [], [], []
1355
+ for gx, gy, z, level_value in pieces:
1356
+ for level in _levels(z, getattr(geom, "bins", None), getattr(geom, "breaks", None)):
1357
+ for line in _contour_lines(gx, gy, z, level):
1358
+ if len(line) < 2:
1359
+ continue
1360
+ starts.append([len(rows), len(line)])
1361
+ rows.extend((x, y) for x, y in line)
1362
+ values.extend([level_value] * len(line))
1363
+ if not rows:
1364
+ raise ValueError("no contour lines: the surface is flat")
1365
+ frame = pd.DataFrame(rows, columns=["x", "y"])
1366
+ mapping = {"x": "x", "y": "y"}
1367
+ if colour_col:
1368
+ frame[colour_col] = values
1369
+ mapping["colour"] = colour_col
1370
+ out = _layer("line", frame, mapping, geom, _groups=starts,
1371
+ linewidth=float(getattr(geom, "linewidth", 1.0) or 1.0))
1372
+ if not colour_col and geom.const_color is None:
1373
+ out.const_color = default_colour
1374
+ _title(out, "x", xcol)
1375
+ _title(out, "y", ycol)
1376
+ return out
1377
+
1378
+
1379
+ def density_2d(geom: _Geom, mapping: dict, data: Any):
1380
+ """geom_density_2d: contour lines of a 2D kernel density, per group."""
1381
+ colour = mapping.get("color")
1382
+ colour = colour if colour and has_column(data, colour) else None
1383
+ frame, xcol, ycol, xs, ys = _xy_numeric(geom, mapping, data, "geom_density_2d", [colour])
1384
+ n = int(getattr(geom, "n", 100))
1385
+ lims = (xs.min(), xs.max(), ys.min(), ys.max())
1386
+ groups, levels = _colour_groups(frame, colour)
1387
+ if getattr(geom, "kind", "") == "density_2d_filled":
1388
+ return _density_bands(geom, xs, ys, n, lims, xcol, ycol)
1389
+ pieces = []
1390
+ for g, level in enumerate(levels):
1391
+ pick = groups == g
1392
+ if pick.sum() < 3:
1393
+ continue
1394
+ gx, gy, z = _kde2d(xs[pick], ys[pick], n, lims, getattr(geom, "h", None))
1395
+ pieces.append((gx, gy, z, level))
1396
+ return _contour_layer(geom, pieces, colour if levels != [None] else None, xcol, ycol, "#3366FF")
1397
+
1398
+
1399
+ def _density_bands(geom, xs, ys, n, lims, xcol, ycol):
1400
+ """geom_density_2d_filled: the density in bands between contour levels,
1401
+ one colour per band (viridis, as ggplot2), drawn as fine cells."""
1402
+ from plot3.scales import fmt_ticks
1403
+
1404
+ n = min(n, 60)
1405
+ gx, gy, z = _kde2d(xs, ys, n, lims, getattr(geom, "h", None))
1406
+ top = float(z.max())
1407
+ # Fewer bands than lines by default, so the legend stays beside the panel.
1408
+ inner = _levels(z, getattr(geom, "bins", None) or 6, getattr(geom, "breaks", None))
1409
+ edges = [0.0] + [v for v in inner if v > 0] + [top]
1410
+ band = np.clip(np.searchsorted(edges, z, side="left") - 1, 0, len(edges) - 2)
1411
+ labels = fmt_ticks(edges[:-1]) + [f"{top:.3g}"]
1412
+ names = [f"({labels[i]}, {labels[i + 1]}]" for i in range(len(edges) - 1)]
1413
+ X, Y = np.meshgrid(gx, gy)
1414
+ cells = pd.DataFrame({"x": X.ravel(), "y": Y.ravel(), "level": [names[b] for b in band.ravel()]})
1415
+ proxy = _Geom(alpha=geom.alpha)
1416
+ proxy.kind = "tile"
1417
+ proxy.width = float(gx[1] - gx[0])
1418
+ proxy.height = float(gy[1] - gy[0])
1419
+ out = tile(proxy, {"x": "x", "y": "y", "color": "level"}, cells)
1420
+ # Bands in order, lowest first, whatever their labels sort as.
1421
+ out.data_override["level"] = pd.Categorical(out.data_override["level"], categories=names)
1422
+ out._default_discrete = "viridis"
1423
+ out._colour_title = "level"
1424
+ _title(out, "x", xcol)
1425
+ _title(out, "y", ycol)
1426
+ return out
1427
+
1428
+
1429
+ def contour(geom: _Geom, mapping: dict, data: Any):
1430
+ """geom_contour: contour lines of z on a regular x-y grid."""
1431
+ xcol, ycol, zcol = mapping.get("x"), mapping.get("y"), mapping.get("z")
1432
+ if not xcol or not ycol or not zcol:
1433
+ raise ValueError("geom_contour() requires aes(x=, y=, z=) on gridded data")
1434
+ frame = _frame(data, [xcol, ycol, zcol])
1435
+ gx = np.unique(frame[xcol].to_numpy(np.float64))
1436
+ gy = np.unique(frame[ycol].to_numpy(np.float64))
1437
+ if gx.size < 2 or gy.size < 2:
1438
+ raise ValueError("geom_contour() needs a grid: several x and several y values")
1439
+ z = np.full((gy.size, gx.size), np.nan)
1440
+ ix = np.searchsorted(gx, frame[xcol].to_numpy(np.float64))
1441
+ iy = np.searchsorted(gy, frame[ycol].to_numpy(np.float64))
1442
+ z[iy, ix] = frame[zcol].to_numpy(np.float64)
1443
+ out = _contour_layer(geom, [(gx, gy, z, None)], None, xcol, ycol, "#3366FF")
1444
+ out._contour_z = zcol
1445
+ return out
1446
+
1447
+
1448
+ def _robust_t(points: np.ndarray, nu: float = 5.0, iterations: int = 100):
1449
+ """Centre and scatter under a multivariate t (MASS::cov.trob)."""
1450
+ centre = points.mean(axis=0)
1451
+ cov = np.cov(points, rowvar=False)
1452
+ p = points.shape[1]
1453
+ for _ in range(iterations):
1454
+ diff = points - centre
1455
+ try:
1456
+ inv = np.linalg.inv(cov)
1457
+ except np.linalg.LinAlgError:
1458
+ break
1459
+ d2 = np.einsum("ij,jk,ik->i", diff, inv, diff)
1460
+ w = (nu + p) / (nu + d2)
1461
+ new_centre = (w[:, None] * points).sum(axis=0) / w.sum()
1462
+ diff = points - new_centre
1463
+ new_cov = (w[:, None] * diff).T @ diff / len(points)
1464
+ done = np.allclose(new_centre, centre, rtol=1e-8) and np.allclose(new_cov, cov, rtol=1e-8)
1465
+ centre, cov = new_centre, new_cov
1466
+ if done:
1467
+ break
1468
+ return centre, cov
1469
+
1470
+
1471
+ def ellipse(geom: _Geom, mapping: dict, data: Any):
1472
+ """stat_ellipse: a confidence ellipse per group (ggplot2's stat_ellipse).
1473
+
1474
+ ``type="t"`` (default) uses a robust multivariate t fit, ``"norm"`` the
1475
+ sample covariance, and ``"euclid"`` a circle of radius ``level``.
1476
+ """
1477
+ from plot3.special import _qf1
1478
+
1479
+ colour = mapping.get("color")
1480
+ colour = colour if colour and has_column(data, colour) else None
1481
+ group = mapping.get("group")
1482
+ group = group if group and has_column(data, group) else None
1483
+ frame, xcol, ycol, xs, ys = _xy_numeric(geom, mapping, data, "stat_ellipse", [colour, group])
1484
+ groups, levels = _colour_groups(frame, colour or group)
1485
+ level = float(getattr(geom, "level", 0.95))
1486
+ kind = getattr(geom, "type", "t")
1487
+ segments = int(getattr(geom, "segments", 51))
1488
+ angles = np.linspace(0, 2 * np.pi, segments)
1489
+ unit = np.column_stack([np.cos(angles), np.sin(angles)])
1490
+ rows, starts, tags = [], [], []
1491
+ for g, lev in enumerate(levels):
1492
+ pts = np.column_stack([xs[groups == g], ys[groups == g]])
1493
+ if len(pts) < 3:
1494
+ continue
1495
+ if kind == "euclid":
1496
+ centre, shape, radius = pts.mean(axis=0), np.eye(2), level
1497
+ else:
1498
+ centre, shape = _robust_t(pts) if kind == "t" else (pts.mean(axis=0), np.cov(pts, rowvar=False))
1499
+ radius = math.sqrt(2.0 * _qf1(level, 2.0, len(pts) - 1.0))
1500
+ try:
1501
+ chol = np.linalg.cholesky(shape)
1502
+ except np.linalg.LinAlgError:
1503
+ continue
1504
+ curve = centre + radius * unit @ chol.T
1505
+ starts.append([len(rows), len(curve)])
1506
+ rows.extend(map(tuple, curve))
1507
+ tags.extend([lev] * len(curve))
1508
+ if not rows:
1509
+ raise ValueError("stat_ellipse() needs at least 3 points per group")
1510
+ out_frame = pd.DataFrame(rows, columns=["x", "y"])
1511
+ out_map = {"x": "x", "y": "y"}
1512
+ if colour:
1513
+ out_frame[colour] = tags
1514
+ out_map["colour"] = colour
1515
+ out = _layer("line", out_frame, out_map, geom, _groups=starts,
1516
+ linewidth=float(getattr(geom, "linewidth", 1.0) or 1.0), _ink_default=not colour)
1517
+ _title(out, "x", xcol)
1518
+ _title(out, "y", ycol)
1519
+ return out
1520
+
1521
+
1522
+ def count_points(geom: _Geom, mapping: dict, data: Any) -> _Geom:
1523
+ """geom_count: one point per distinct (x, y), sized by how many rows
1524
+ share it (ggplot2's stat_sum, ``n``)."""
1525
+ xcol, ycol = mapping.get("x"), mapping.get("y")
1526
+ if not xcol or not ycol:
1527
+ raise ValueError("geom_count() requires aes(x=, y=)")
1528
+ colour = mapping.get("color")
1529
+ colour = colour if colour and colour not in {xcol, ycol} and has_column(data, colour) else None
1530
+ keys = [xcol, ycol] + ([colour] if colour else [])
1531
+ frame = _frame(data, keys)
1532
+ counted = frame.groupby(keys, sort=False, observed=True).size().reset_index(name="n")
1533
+ out_map = {"x": xcol, "y": ycol, "size": "n", **_colour_mapping(colour)}
1534
+ out = _layer("point", counted, out_map, geom)
1535
+ out.size = None
1536
+ out.shape = getattr(geom, "shape", None)
1537
+ _title(out, "x", xcol)
1538
+ _title(out, "y", ycol)
1539
+ return out