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/__init__.py +301 -0
- plot3/__version__.py +1 -0
- plot3/aesexpr.py +271 -0
- plot3/build.py +3948 -0
- plot3/calculus.py +1179 -0
- plot3/compose.py +285 -0
- plot3/contour.py +476 -0
- plot3/craft.py +142 -0
- plot3/encode.py +68 -0
- plot3/expr.py +1557 -0
- plot3/flip.py +245 -0
- plot3/function.py +1301 -0
- plot3/geoms.py +2558 -0
- plot3/ggplot.py +713 -0
- plot3/io.py +76 -0
- plot3/jupyter.py +514 -0
- plot3/latexin.py +616 -0
- plot3/masking.py +494 -0
- plot3/mathtext.py +842 -0
- plot3/payload.py +216 -0
- plot3/remote.py +220 -0
- plot3/scales.py +387 -0
- plot3/scaling.py +636 -0
- plot3/special.py +407 -0
- plot3/stat2d.py +1539 -0
- plot3/static.py +3760 -0
- plot3/stats3d.py +462 -0
- plot3/table.py +775 -0
- plot3/themes.py +104 -0
- plot3/viewer.py +3354 -0
- plot3-0.4.0.dist-info/METADATA +504 -0
- plot3-0.4.0.dist-info/RECORD +35 -0
- plot3-0.4.0.dist-info/WHEEL +5 -0
- plot3-0.4.0.dist-info/licenses/LICENSE +21 -0
- plot3-0.4.0.dist-info/top_level.txt +1 -0
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
|