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 ADDED
@@ -0,0 +1,301 @@
1
+ """plot3 — grammar-of-graphics plotting on three.js."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from plot3.__version__ import __version__
6
+ from plot3.geoms import (
7
+ aes,
8
+ annotate,
9
+ area,
10
+ arrow,
11
+ coord_3d,
12
+ coord_equal,
13
+ coord_fixed,
14
+ expand_limits,
15
+ coord_cartesian,
16
+ coord_flip,
17
+ coord_polar,
18
+ derivative,
19
+ facet_grid,
20
+ facet_wrap,
21
+ label_both,
22
+ label_value,
23
+ labeller,
24
+ as_labeller,
25
+ geom_bar,
26
+ geom_box3d,
27
+ geom_boxplot,
28
+ geom_col,
29
+ geom_density,
30
+ geom_abline,
31
+ geom_area,
32
+ geom_crossbar,
33
+ geom_errorbar,
34
+ geom_errorbarh,
35
+ geom_freqpoly,
36
+ geom_bin_2d,
37
+ geom_blank,
38
+ guide_legend,
39
+ guide_colourbar,
40
+ guide_colorbar,
41
+ guide_none,
42
+ position_dodge2,
43
+ stat_bin,
44
+ stat_count,
45
+ stat_density,
46
+ stat_function,
47
+ stat_smooth,
48
+ vars,
49
+ geom_count,
50
+ geom_hex,
51
+ geom_density_2d,
52
+ geom_density_2d_filled,
53
+ geom_density2d,
54
+ stat_density_2d,
55
+ geom_contour,
56
+ stat_ellipse,
57
+ geom_polygon,
58
+ geom_function,
59
+ geom_hline,
60
+ geom_histogram,
61
+ geom_isosurface,
62
+ geom_jitter,
63
+ geom_label,
64
+ geom_line,
65
+ geom_linerange,
66
+ geom_path,
67
+ geom_point,
68
+ geom_point3d,
69
+ geom_pointrange,
70
+ geom_qq,
71
+ geom_qq_line,
72
+ geom_raster,
73
+ geom_rect,
74
+ geom_ribbon,
75
+ geom_rug,
76
+ geom_segment,
77
+ geom_smooth,
78
+ geom_step,
79
+ geom_surface,
80
+ geom_text,
81
+ geom_tile,
82
+ geom_vector_field,
83
+ geom_vline,
84
+ geom_violin,
85
+ ggtitle,
86
+ guides,
87
+ labs,
88
+ position_dodge,
89
+ position_jitter,
90
+ position_jitterdodge,
91
+ position_nudge,
92
+ position_fill,
93
+ position_stack,
94
+ stat_density_3d,
95
+ stat_ecdf,
96
+ stat_qq,
97
+ stat_qq_line,
98
+ stat_summary,
99
+ tangent,
100
+ xlab,
101
+ ylab,
102
+ scale_color_continuous,
103
+ scale_color_viridis_c,
104
+ scale_colour_continuous,
105
+ scale_colour_viridis_c,
106
+ scale_fill_viridis_c,
107
+ scale_x_log10,
108
+ scale_y_log10,
109
+ theme,
110
+ theme_bw,
111
+ theme_classic,
112
+ theme_dark,
113
+ theme_light,
114
+ theme_minimal,
115
+ theme_lidar,
116
+ theme_grey,
117
+ theme_gray,
118
+ theme_linedraw,
119
+ element_blank,
120
+ element_line,
121
+ element_rect,
122
+ element_text,
123
+ theme_void,
124
+ slider,
125
+ transition_states,
126
+ transition_time,
127
+ )
128
+ from plot3.compose import plot_annotation, plot_layout
129
+ from plot3.scaling import * # noqa: F401,F403 - scale_* functions
130
+ from plot3.scaling import __all__ as _scaling_all
131
+ from plot3.ggplot import autohide, ggsave, ggplot # show via ggplot.show
132
+ from plot3.io import read_bin
133
+ from plot3.jupyter import (
134
+ disable_r_style,
135
+ enable_r_style,
136
+ load_ipython_extension,
137
+ register_plot3,
138
+ )
139
+ from plot3.payload import (
140
+ PAYLOAD_VERSION,
141
+ build_payload,
142
+ display_payload,
143
+ load_payload,
144
+ render_payload,
145
+ save_payload,
146
+ validate_payload,
147
+ )
148
+ from plot3.remote import (
149
+ MIME_PLOT3,
150
+ has_craft_host,
151
+ is_remote_kernel,
152
+ remote_ggplot_payload,
153
+ show_remote,
154
+ )
155
+
156
+ __all__ = [
157
+ "ggplot",
158
+ "aes",
159
+ "geom_point",
160
+ "geom_point3d",
161
+ "geom_surface",
162
+ "geom_isosurface",
163
+ "stat_density_3d",
164
+ "geom_line",
165
+ "geom_path",
166
+ "geom_col",
167
+ "geom_bar",
168
+ "geom_histogram",
169
+ "geom_boxplot",
170
+ "geom_density",
171
+ "geom_function",
172
+ "geom_vector_field",
173
+ "geom_violin",
174
+ "geom_jitter",
175
+ "geom_errorbar",
176
+ "geom_linerange",
177
+ "geom_pointrange",
178
+ "geom_ribbon",
179
+ "geom_smooth",
180
+ "stat_summary",
181
+ "geom_hline",
182
+ "geom_vline",
183
+ "geom_abline",
184
+ "geom_text",
185
+ "geom_label",
186
+ "geom_tile",
187
+ "geom_raster",
188
+ "geom_area",
189
+ "geom_step",
190
+ "geom_segment",
191
+ "geom_rect",
192
+ "geom_qq",
193
+ "geom_qq_line",
194
+ "stat_qq",
195
+ "stat_qq_line",
196
+ "stat_ecdf",
197
+ "coord_flip",
198
+ "annotate",
199
+ "arrow",
200
+ "position_dodge",
201
+ "position_stack",
202
+ "position_fill",
203
+ "area",
204
+ "tangent",
205
+ "derivative",
206
+ "facet_wrap",
207
+ "facet_grid",
208
+ "coord_3d",
209
+ "coord_equal",
210
+ "coord_polar",
211
+ "labs",
212
+ "scale_colour_continuous",
213
+ "scale_color_continuous",
214
+ "scale_colour_viridis_c",
215
+ "scale_color_viridis_c",
216
+ "scale_x_log10",
217
+ "scale_y_log10",
218
+ "transition_time",
219
+ "transition_states",
220
+ "slider",
221
+ "theme",
222
+ "theme_bw",
223
+ "theme_classic",
224
+ "theme_dark",
225
+ "theme_light",
226
+ "theme_minimal",
227
+ "theme_lidar",
228
+ "theme_grey",
229
+ "theme_gray",
230
+ "theme_linedraw",
231
+ "element_blank",
232
+ "element_line",
233
+ "element_rect",
234
+ "element_text",
235
+ "geom_box3d",
236
+ "theme_void",
237
+ "scale_fill_viridis_c",
238
+ "ggtitle",
239
+ "xlab",
240
+ "ylab",
241
+ "guides",
242
+ "coord_cartesian",
243
+ "geom_rug",
244
+ "geom_crossbar",
245
+ "geom_errorbarh",
246
+ "geom_freqpoly",
247
+ "geom_bin_2d",
248
+ "geom_blank", "guide_legend", "guide_colourbar", "guide_colorbar", "guide_none",
249
+ "position_dodge2", "stat_bin", "stat_count", "stat_density", "stat_function",
250
+ "stat_smooth", "vars",
251
+ "geom_count",
252
+ "geom_hex",
253
+ "geom_density_2d",
254
+ "geom_density_2d_filled",
255
+ "geom_density2d",
256
+ "stat_density_2d",
257
+ "geom_contour",
258
+ "stat_ellipse",
259
+ "geom_polygon",
260
+ "coord_fixed",
261
+ "label_both",
262
+ "label_value",
263
+ "labeller",
264
+ "as_labeller",
265
+ "expand_limits",
266
+ "position_jitter",
267
+ "position_jitterdodge",
268
+ "position_nudge",
269
+ "ggsave",
270
+ "plot_layout",
271
+ "plot_annotation",
272
+ "read_bin",
273
+ "autohide",
274
+ "enable_r_style",
275
+ "disable_r_style",
276
+ "load_ipython_extension",
277
+ "register_plot3",
278
+ "PAYLOAD_VERSION",
279
+ "build_payload",
280
+ "render_payload",
281
+ "validate_payload",
282
+ "save_payload",
283
+ "load_payload",
284
+ "display_payload",
285
+ "MIME_PLOT3",
286
+ "is_remote_kernel",
287
+ "has_craft_host",
288
+ "remote_ggplot_payload",
289
+ "show_remote",
290
+ "__version__",
291
+ *_scaling_all,
292
+ ]
293
+
294
+ # Auto-register magics when loaded inside IPython (CRAFT / Jupyter).
295
+ try:
296
+ from IPython import get_ipython as _get_ipython
297
+
298
+ if _get_ipython is not None and _get_ipython() is not None:
299
+ register_plot3(quiet=False)
300
+ except Exception:
301
+ pass
plot3/__version__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0"
plot3/aesexpr.py ADDED
@@ -0,0 +1,271 @@
1
+ """Expressions in aes(): ``aes(ymin="mean - se")``, ``aes(colour="factor(cyl)")``.
2
+
3
+ ggplot2 evaluates ``aes()`` in the data, so R code maps computed values
4
+ directly. plot3 reads such a string as an expression over the data's
5
+ columns: arithmetic, comparisons, and a short list of functions, evaluated
6
+ with NumPy. Nothing else runs, so a string from a file cannot execute code.
7
+ The computed values become a column named by the expression, which then
8
+ titles the axis or legend, as in ggplot2.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ast
14
+ import re
15
+ from typing import Any
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+
20
+ from plot3.table import ColumnNotFound, detect_backend, has_column
21
+
22
+
23
+ def _factor(values):
24
+ from plot3.scales import ordered_levels
25
+
26
+ array = np.asarray(values)
27
+ if array.dtype.kind == "f" and np.all(np.isnan(array) | (array == np.round(array))):
28
+ # factor(cyl) reads 4, 6, 8, not 4.0, 6.0, 8.0.
29
+ array = pd.array(np.where(np.isnan(array), 0, array).astype(np.int64), dtype="Int64")
30
+ array[np.isnan(np.asarray(values, dtype=np.float64))] = pd.NA
31
+ series = pd.Series(array)
32
+ levels = ordered_levels([v for v in pd.unique(series) if not pd.isna(v)])
33
+ return pd.Categorical(series, categories=levels)
34
+
35
+
36
+ def _round(values, digits=0):
37
+ return np.round(np.asarray(values, dtype=np.float64), int(digits))
38
+
39
+
40
+ def _ifelse(cond, yes, no):
41
+ return np.where(np.asarray(cond, dtype=bool), yes, no)
42
+
43
+
44
+ def _scalar(fn):
45
+ return lambda values: fn(np.asarray(values, dtype=np.float64))
46
+
47
+
48
+ _FUNCTIONS = {
49
+ "log": np.log, "log10": np.log10, "log2": np.log2, "log1p": np.log1p,
50
+ "exp": np.exp, "sqrt": np.sqrt, "abs": np.abs, "floor": np.floor,
51
+ "ceiling": np.ceil, "ceil": np.ceil, "sin": np.sin, "cos": np.cos, "tan": np.tan,
52
+ "round": _round, "ifelse": _ifelse, "pmin": np.fmin, "pmax": np.fmax,
53
+ "factor": _factor, "as_factor": _factor,
54
+ "as_numeric": lambda v: pd.to_numeric(pd.Series(np.asarray(v)), errors="coerce").to_numpy(),
55
+ "as_character": lambda v: np.asarray(v).astype(str),
56
+ "mean": _scalar(np.nanmean), "median": _scalar(np.nanmedian),
57
+ "sd": _scalar(lambda a: np.nanstd(a, ddof=1)), "min": _scalar(np.nanmin),
58
+ "max": _scalar(np.nanmax), "sum": _scalar(np.nansum),
59
+ }
60
+ _CONSTANTS = {"True": True, "False": False, "pi": np.pi}
61
+ _BINARY = {
62
+ ast.Add: np.add, ast.Sub: np.subtract, ast.Mult: np.multiply, ast.Div: np.true_divide,
63
+ ast.Pow: np.power, ast.Mod: np.mod, ast.FloorDiv: np.floor_divide,
64
+ ast.BitAnd: np.logical_and, ast.BitOr: np.logical_or,
65
+ }
66
+ _COMPARE = {
67
+ ast.Eq: np.equal, ast.NotEq: np.not_equal, ast.Lt: np.less, ast.LtE: np.less_equal,
68
+ ast.Gt: np.greater, ast.GtE: np.greater_equal,
69
+ }
70
+ _STAT_FORMS = re.compile(r"^\s*(after_stat|stat|after_scale|stage)\s*\(|^\.\.\w+\.\.$")
71
+
72
+
73
+ def _translate(text: str) -> tuple[str, dict[str, str]]:
74
+ """R spellings to Python ones; backtick names to placeholders."""
75
+ names: dict[str, str] = {}
76
+
77
+ def tick(match):
78
+ key = f"__col{len(names)}"
79
+ names[key] = match.group(1)
80
+ return key
81
+
82
+ out = re.sub(r"`([^`]+)`", tick, text)
83
+ out = out.replace("^", "**")
84
+ out = re.sub(r"\bas\.(factor|numeric|character)\s*\(", r"as_\1(", out)
85
+ out = re.sub(r"\bTRUE\b", "True", out)
86
+ out = re.sub(r"\bFALSE\b", "False", out)
87
+ out = re.sub(r"!(?!=)", " not ", out)
88
+ return out, names
89
+
90
+
91
+ def parse(text: str):
92
+ """(tree, backtick names) when ``text`` is an expression, else None.
93
+
94
+ A bare name or number is not an expression: it is a column (or a typo
95
+ the missing-column message explains)."""
96
+ if not isinstance(text, str) or _STAT_FORMS.search(text):
97
+ return None
98
+ source, names = _translate(text)
99
+ try:
100
+ tree = ast.parse(source.strip(), mode="eval")
101
+ except SyntaxError:
102
+ return None
103
+ if isinstance(tree.body, (ast.Name, ast.Constant)):
104
+ return None
105
+ for node in ast.walk(tree):
106
+ if isinstance(node, ast.Call):
107
+ if not isinstance(node.func, ast.Name) or node.func.id not in _FUNCTIONS:
108
+ return None
109
+ elif isinstance(node, (ast.Attribute, ast.Subscript, ast.Lambda, ast.ListComp,
110
+ ast.DictComp, ast.SetComp, ast.GeneratorExp, ast.Starred)):
111
+ return None
112
+ return tree, names
113
+
114
+
115
+ def columns_used(tree, names: dict[str, str]) -> list[str]:
116
+ """Names read as values. A column may share a function's name (mean):
117
+ only a name being called is the function."""
118
+ called = {id(node.func) for node in ast.walk(tree) if isinstance(node, ast.Call)}
119
+ used = []
120
+ for node in ast.walk(tree):
121
+ if isinstance(node, ast.Name) and id(node) not in called and node.id not in _CONSTANTS:
122
+ used.append(names.get(node.id, node.id))
123
+ return list(dict.fromkeys(used))
124
+
125
+
126
+ def _evaluate(node, frame: pd.DataFrame, names: dict[str, str]):
127
+ if isinstance(node, ast.Expression):
128
+ return _evaluate(node.body, frame, names)
129
+ if isinstance(node, ast.Constant):
130
+ return node.value
131
+ if isinstance(node, ast.Name):
132
+ if node.id in _CONSTANTS:
133
+ return _CONSTANTS[node.id]
134
+ column = frame[names.get(node.id, node.id)]
135
+ if pd.api.types.is_numeric_dtype(column) and not pd.api.types.is_bool_dtype(column):
136
+ return column.to_numpy(dtype=np.float64, na_value=np.nan)
137
+ return column.to_numpy()
138
+ if isinstance(node, ast.BinOp) and type(node.op) in _BINARY:
139
+ return _BINARY[type(node.op)](_evaluate(node.left, frame, names), _evaluate(node.right, frame, names))
140
+ if isinstance(node, ast.UnaryOp):
141
+ value = _evaluate(node.operand, frame, names)
142
+ if isinstance(node.op, ast.USub):
143
+ return np.negative(value)
144
+ if isinstance(node.op, ast.UAdd):
145
+ return value
146
+ if isinstance(node.op, (ast.Not, ast.Invert)):
147
+ return np.logical_not(value)
148
+ if isinstance(node, ast.BoolOp):
149
+ values = [_evaluate(v, frame, names) for v in node.values]
150
+ combine = np.logical_and if isinstance(node.op, ast.And) else np.logical_or
151
+ out = values[0]
152
+ for value in values[1:]:
153
+ out = combine(out, value)
154
+ return out
155
+ if isinstance(node, ast.Compare):
156
+ left = _evaluate(node.left, frame, names)
157
+ out = None
158
+ for op, right_node in zip(node.ops, node.comparators):
159
+ if type(op) not in _COMPARE:
160
+ raise ValueError(f"aes() expressions do not support {type(op).__name__}")
161
+ right = _evaluate(right_node, frame, names)
162
+ step = _COMPARE[type(op)](left, right)
163
+ out = step if out is None else np.logical_and(out, step)
164
+ left = right
165
+ return out
166
+ if isinstance(node, ast.Call):
167
+ args = [_evaluate(a, frame, names) for a in node.args]
168
+ kwargs = {k.arg: _evaluate(k.value, frame, names) for k in node.keywords}
169
+ return _FUNCTIONS[node.func.id](*args, **kwargs)
170
+ raise ValueError(f"aes() expressions do not support {type(node).__name__}")
171
+
172
+
173
+ def _rows(data: Any, cols: list[str]) -> pd.DataFrame:
174
+ """The columns as pandas, every row kept (missing values too)."""
175
+ backend = detect_backend(data)
176
+ if backend == "pandas":
177
+ return data.loc[:, cols]
178
+ from plot3.table import _polars_to_pandas, resolve_polars
179
+
180
+ import polars as pl
181
+
182
+ frame = resolve_polars(data) if backend == "tidy" else data
183
+ if isinstance(frame, pl.LazyFrame):
184
+ frame = frame.collect()
185
+ return _polars_to_pandas(frame.select(cols))
186
+
187
+
188
+ def with_expressions(data: Any, texts) -> Any:
189
+ """``data`` plus one column per expression in ``texts`` that is not
190
+ already a column. Other backends than pandas and polars are returned
191
+ unchanged (their columns are positions)."""
192
+ if data is None:
193
+ return data
194
+ wanted = []
195
+ for text in dict.fromkeys(t for t in texts if isinstance(t, str)):
196
+ if has_column(data, text):
197
+ continue
198
+ parsed = parse(text)
199
+ if parsed is not None:
200
+ wanted.append((text, parsed))
201
+ if not wanted:
202
+ return data
203
+ backend = detect_backend(data)
204
+ if backend not in {"pandas", "polars", "tidy"}:
205
+ return data
206
+ computed: dict[str, Any] = {}
207
+ for text, (tree, names) in wanted:
208
+ used = columns_used(tree, names)
209
+ missing = [c for c in used if not has_column(data, c)]
210
+ if missing:
211
+ raise ColumnNotFound(missing, data)
212
+ frame = _rows(data, used)
213
+ value = _evaluate(tree, frame, names)
214
+ if np.ndim(value) == 0:
215
+ value = np.full(len(frame), value)
216
+ computed[text] = value
217
+ if backend == "pandas":
218
+ out = data.copy()
219
+ for text, value in computed.items():
220
+ out[text] = value
221
+ return out
222
+ import polars as pl
223
+
224
+ frame = data
225
+ if backend == "tidy":
226
+ from plot3.table import resolve_polars
227
+
228
+ frame = resolve_polars(data)
229
+ if isinstance(frame, pl.LazyFrame):
230
+ frame = frame.collect()
231
+ series = []
232
+ for text, value in computed.items():
233
+ if isinstance(value, pd.Categorical):
234
+ # polars keeps the levels' order as an Enum.
235
+ levels = [str(c) for c in value.categories]
236
+ series.append(pl.Series(text, np.asarray(value.astype(str)), dtype=pl.Enum(levels)))
237
+ else:
238
+ series.append(pl.Series(text, np.asarray(value)))
239
+ return frame.with_columns(series)
240
+
241
+
242
+ def add_expression_columns(g):
243
+ """A copy of the figure whose data carry a column for each aes()
244
+ expression its layers use."""
245
+ import copy
246
+
247
+ def texts(mapping) -> list:
248
+ return [v for k, v in dict(mapping or {}).items()]
249
+
250
+ shared = []
251
+ layers = []
252
+ changed = False
253
+ for layer in g.layers:
254
+ mapping = texts(g.mapping) + texts(getattr(layer, "mapping", None))
255
+ own = getattr(layer, "layer_data", None)
256
+ if own is not None:
257
+ new = with_expressions(own, mapping)
258
+ if new is not own:
259
+ layer = copy.copy(layer)
260
+ layer.layer_data = new
261
+ changed = True
262
+ elif getattr(layer, "kind", None) not in {"function", "vector"}:
263
+ shared.extend(mapping)
264
+ layers.append(layer)
265
+ data = with_expressions(g.data, shared) if g.data is not None else None
266
+ if data is g.data and not changed:
267
+ return g
268
+ out = copy.copy(g)
269
+ out.data = data
270
+ out.layers = layers
271
+ return out