plot3 0.6.7__tar.gz → 0.7.0__tar.gz
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-0.6.7 → plot3-0.7.0}/PKG-INFO +1 -1
- plot3-0.7.0/plot3/__version__.py +1 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/build.py +73 -21
- {plot3-0.6.7 → plot3-0.7.0}/plot3/encode.py +8 -4
- plot3-0.7.0/plot3/kde.py +124 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/scales.py +13 -3
- {plot3-0.6.7 → plot3-0.7.0}/plot3/stat2d.py +6 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/viewer.py +35 -5
- {plot3-0.6.7 → plot3-0.7.0}/plot3.egg-info/PKG-INFO +1 -1
- {plot3-0.6.7 → plot3-0.7.0}/plot3.egg-info/SOURCES.txt +2 -0
- plot3-0.7.0/tests/test_fast_paths.py +106 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_math_layers.py +34 -0
- plot3-0.6.7/plot3/__version__.py +0 -1
- {plot3-0.6.7 → plot3-0.7.0}/LICENSE +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/README.md +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/__init__.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/aesexpr.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/calculus.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/compose.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/contour.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/craft.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/expr.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/flip.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/function.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/geoms.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/ggplot.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/io.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/jupyter.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/latexin.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/masking.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/mathtext.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/payload.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/remote.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/scaling.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/special.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/static.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/stats3d.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/table.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3/themes.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3.egg-info/dependency_links.txt +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3.egg-info/requires.txt +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/plot3.egg-info/top_level.txt +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/pyproject.toml +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/setup.cfg +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_2d_density.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_3d.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_animation.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_annotate.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_api_public.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_array_backend.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_arrow.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_build_contract.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_contour.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_everyday_ggplot2.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_expr.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_facets_compose.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_geom_function.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_geoms_ds.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_ggplot2_extras2.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_ggplot2_parity.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_ggplot_extras.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_grammar.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_latex_input.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_lidar_scene.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_masking.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_mathtext.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_payload.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_payload_display.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_point_cloud.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_r_oracle_parity.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_readme.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_remote_bridge.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_review_fixes.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_save_menu.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_scales_api.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_scales_themes.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_special.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_stat2d.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_static_save.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_stats_semantic.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_step3_geoms.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_table_backends.py +0 -0
- {plot3-0.6.7 → plot3-0.7.0}/tests/test_titles_theme.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.7.0"
|
|
@@ -10,6 +10,7 @@ import pandas as pd
|
|
|
10
10
|
import copy
|
|
11
11
|
import os
|
|
12
12
|
|
|
13
|
+
from plot3.kde import EXACT_WORK, gaussian_1d
|
|
13
14
|
from plot3.encode import encode_codes, encode_norm, pack_u16, pack_u32, pack_u8
|
|
14
15
|
from plot3.mathtext import split_math
|
|
15
16
|
from plot3.geoms import (
|
|
@@ -100,6 +101,11 @@ def _kde_1d(
|
|
|
100
101
|
if hi <= lo:
|
|
101
102
|
hi = lo + 1.0
|
|
102
103
|
grid = np.linspace(lo, hi, n)
|
|
104
|
+
if x.size * n > EXACT_WORK:
|
|
105
|
+
# Large inputs: linear binning and an FFT, as R's density() does.
|
|
106
|
+
sums = gaussian_1d(x, lo, hi, n, bw)
|
|
107
|
+
if sums is not None:
|
|
108
|
+
return grid, sums / x.size
|
|
103
109
|
# (n_grid, n_obs)
|
|
104
110
|
u = (grid[:, None] - x[None, :]) / bw
|
|
105
111
|
dens = np.exp(-0.5 * u * u).sum(axis=1) / (
|
|
@@ -1522,6 +1528,26 @@ def _rug_series(rug, g):
|
|
|
1522
1528
|
return out
|
|
1523
1529
|
|
|
1524
1530
|
|
|
1531
|
+
def _remap_codes(codes, remap: dict, default) -> np.ndarray:
|
|
1532
|
+
"""``remap.get(int(c), default)`` for every code, as one table lookup."""
|
|
1533
|
+
c = np.asarray(codes)
|
|
1534
|
+
if c.dtype.kind not in "biuf" or (c.dtype.kind == "f" and not np.isfinite(c).all()):
|
|
1535
|
+
# Same per-value rule, errors included (int() of NaN raises).
|
|
1536
|
+
return np.array([remap.get(int(x), default) for x in c.ravel()]).reshape(c.shape)
|
|
1537
|
+
ints = c.astype(np.int64)
|
|
1538
|
+
out = np.full(ints.shape, default)
|
|
1539
|
+
if remap and ints.size:
|
|
1540
|
+
keys = np.fromiter(remap.keys(), dtype=np.int64, count=len(remap))
|
|
1541
|
+
values = np.array(list(remap.values()))
|
|
1542
|
+
order = np.argsort(keys)
|
|
1543
|
+
keys, values = keys[order], values[order]
|
|
1544
|
+
at = np.clip(np.searchsorted(keys, ints), 0, len(keys) - 1)
|
|
1545
|
+
hit = keys[at] == ints
|
|
1546
|
+
out = out.astype(np.result_type(out, values))
|
|
1547
|
+
out[hit] = values[at[hit]]
|
|
1548
|
+
return out
|
|
1549
|
+
|
|
1550
|
+
|
|
1525
1551
|
def _rug_positions(scale, series) -> np.ndarray:
|
|
1526
1552
|
"""Values in the scale's own units; NaN where they have no place."""
|
|
1527
1553
|
kind, pos, cats = col_values(series)
|
|
@@ -1531,6 +1557,13 @@ def _rug_positions(scale, series) -> np.ndarray:
|
|
|
1531
1557
|
lookup = np.array([index.get(c, np.nan) for c in cats] + [np.nan], dtype=np.float64)
|
|
1532
1558
|
codes = np.where(np.isfinite(pos), pos, len(cats)).astype(np.int64)
|
|
1533
1559
|
return lookup[codes]
|
|
1560
|
+
values = np.asarray(series)
|
|
1561
|
+
# float32 prints differently as a NumPy scalar than as a float.
|
|
1562
|
+
if values.dtype.kind in "biu" or values.dtype == np.float64:
|
|
1563
|
+
# One text lookup per distinct value (NaN included), not per row.
|
|
1564
|
+
codes, uniques = pd.factorize(values, use_na_sentinel=False)
|
|
1565
|
+
lookup = np.array([index.get(str(u), np.nan) for u in uniques.tolist()], dtype=np.float64)
|
|
1566
|
+
return lookup[codes] if len(codes) else np.array([], dtype=np.float64)
|
|
1534
1567
|
return np.array([index.get(str(v), np.nan) for v in series], dtype=np.float64)
|
|
1535
1568
|
if kind == "cat":
|
|
1536
1569
|
return np.full(len(pos), np.nan)
|
|
@@ -1726,7 +1759,8 @@ def _formula_legend(legend, formula_geoms, has_title: bool):
|
|
|
1726
1759
|
"""Tidy the legend rows that function curves add.
|
|
1727
1760
|
|
|
1728
1761
|
* One curve under a title of your own: the title already names it, so
|
|
1729
|
-
the curve gets no row (ggplot2 draws no legend for an unmapped layer)
|
|
1762
|
+
the curve gets no row (ggplot2 draws no legend for an unmapped layer),
|
|
1763
|
+
unless a tangent, derivative or other row sits beside it.
|
|
1730
1764
|
* Several curves of one formula (a loop over ``a``): the formula moves
|
|
1731
1765
|
to the legend title and each row lists only its values.
|
|
1732
1766
|
|
|
@@ -1744,7 +1778,7 @@ def _formula_legend(legend, formula_geoms, has_title: bool):
|
|
|
1744
1778
|
return legend, None
|
|
1745
1779
|
if has_title and len(formula_geoms) == 1:
|
|
1746
1780
|
kept = [entry for entry in legend if not any(entry is p for p in primary)]
|
|
1747
|
-
return kept
|
|
1781
|
+
return (legend if kept else None), None
|
|
1748
1782
|
if len(primary) < 2 or len(primary) != len(formula_geoms):
|
|
1749
1783
|
return legend, None
|
|
1750
1784
|
geoms = [entry["_geom"] for entry in primary]
|
|
@@ -2210,10 +2244,8 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
|
|
|
2210
2244
|
}
|
|
2211
2245
|
out = np.full(np.size(v), np.nan, dtype=np.float64)
|
|
2212
2246
|
flat = np.asarray(v, dtype=np.float64).ravel()
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
continue
|
|
2216
|
-
out[i] = remap.get(int(c), -1)
|
|
2247
|
+
finite = np.isfinite(flat)
|
|
2248
|
+
out[finite] = _remap_codes(flat[finite], remap, -1)
|
|
2217
2249
|
v = out.reshape(np.shape(v))
|
|
2218
2250
|
sc.cats = merged
|
|
2219
2251
|
else:
|
|
@@ -2871,6 +2903,28 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
|
|
|
2871
2903
|
source = getattr(geom, "_inherit_from", None)
|
|
2872
2904
|
if source and source in labeled:
|
|
2873
2905
|
geom.const_color = labeled[source]
|
|
2906
|
+
# Clicking a legend row hides every layer it names: a tangent's line and
|
|
2907
|
+
# its point, a curve and the area shaded under it.
|
|
2908
|
+
legend_keys: dict[int, str] = {}
|
|
2909
|
+
key_of: dict[str, str] = {}
|
|
2910
|
+
for geom, _mapped in resolved:
|
|
2911
|
+
label = getattr(geom, "_legend_label", None)
|
|
2912
|
+
if not label or geom.const_color is None:
|
|
2913
|
+
continue
|
|
2914
|
+
color_key = getattr(geom, "_color_key", None)
|
|
2915
|
+
key = key_of.get(str(label)) or str(color_key or label)
|
|
2916
|
+
legend_keys[id(geom)] = key
|
|
2917
|
+
key_of.setdefault(str(label), key)
|
|
2918
|
+
if color_key:
|
|
2919
|
+
key_of.setdefault(str(color_key), key)
|
|
2920
|
+
for geom, _mapped in resolved:
|
|
2921
|
+
source = getattr(geom, "_inherit_from", None)
|
|
2922
|
+
if id(geom) not in legend_keys and source and str(source) in key_of:
|
|
2923
|
+
legend_keys[id(geom)] = key_of[str(source)]
|
|
2924
|
+
# Stable short keys, so the same figure always builds the same spec.
|
|
2925
|
+
stable: dict[str, str] = {}
|
|
2926
|
+
for gid, key in legend_keys.items():
|
|
2927
|
+
legend_keys[gid] = stable.setdefault(key, f"k{len(stable)}")
|
|
2874
2928
|
|
|
2875
2929
|
payloads: list[tuple[str, str]] = []
|
|
2876
2930
|
layer_specs = []
|
|
@@ -3066,9 +3120,7 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
|
|
|
3066
3120
|
i: color_scale[1].index(c)
|
|
3067
3121
|
for i, c in enumerate(local)
|
|
3068
3122
|
}
|
|
3069
|
-
codes =
|
|
3070
|
-
[remap.get(int(c), 0) for c in cv], dtype="<u2"
|
|
3071
|
-
)
|
|
3123
|
+
codes = _remap_codes(cv, remap, 0).astype("<u2")
|
|
3072
3124
|
payloads.append((pid, pack_u16(codes, g.compress)))
|
|
3073
3125
|
spec_l["ocolor"] = {
|
|
3074
3126
|
"id": pid, "dtype": "u16", "kind": "cat"
|
|
@@ -3098,8 +3150,7 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
|
|
|
3098
3150
|
# remap onto the global cat list
|
|
3099
3151
|
local = vals["color"][2]
|
|
3100
3152
|
remap = {i: color_scale[1].index(c) for i, c in enumerate(local)}
|
|
3101
|
-
codes =
|
|
3102
|
-
dtype="<u2")
|
|
3153
|
+
codes = _remap_codes(cv[order], remap, 0).astype("<u2")
|
|
3103
3154
|
payloads.append((pid, pack_u16(codes, g.compress)))
|
|
3104
3155
|
spec_l["color"] = {"id": pid, "dtype": "u16", "kind": "cat"}
|
|
3105
3156
|
else:
|
|
@@ -3117,9 +3168,7 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
|
|
|
3117
3168
|
if ckind == "cat":
|
|
3118
3169
|
local = vals["color"][2]
|
|
3119
3170
|
remap = {i: color_scale[1].index(c) for i, c in enumerate(local)}
|
|
3120
|
-
codes =
|
|
3121
|
-
[remap.get(int(c), 0) for c in cv[order]], dtype="<u2"
|
|
3122
|
-
)
|
|
3171
|
+
codes = _remap_codes(cv[order], remap, 0).astype("<u2")
|
|
3123
3172
|
payloads.append((pid, pack_u16(codes, g.compress)))
|
|
3124
3173
|
spec_l["color"] = {"id": pid, "dtype": "u16", "kind": "cat"}
|
|
3125
3174
|
else:
|
|
@@ -3299,10 +3348,7 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
|
|
|
3299
3348
|
flat = np.full(raw.shape, 65535, dtype=np.uint16)
|
|
3300
3349
|
ok = np.isfinite(raw) & (raw >= 0)
|
|
3301
3350
|
if ok.any():
|
|
3302
|
-
mapped = np.
|
|
3303
|
-
[remap.get(int(c), 0) for c in raw[ok]],
|
|
3304
|
-
dtype=np.uint16,
|
|
3305
|
-
)
|
|
3351
|
+
mapped = _remap_codes(raw[ok], remap, 0).astype(np.uint16)
|
|
3306
3352
|
flat[np.flatnonzero(ok)] = mapped
|
|
3307
3353
|
enc = encode_codes(flat, g.compress)
|
|
3308
3354
|
pid = f"p{li}fc"
|
|
@@ -3362,6 +3408,8 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
|
|
|
3362
3408
|
if tip_latex:
|
|
3363
3409
|
tip["latex"] = str(tip_latex)
|
|
3364
3410
|
spec_l["tip"] = tip
|
|
3411
|
+
if id(geom) in legend_keys:
|
|
3412
|
+
spec_l["lk"] = legend_keys[id(geom)]
|
|
3365
3413
|
layer_specs.append(spec_l)
|
|
3366
3414
|
|
|
3367
3415
|
# color spec + legend
|
|
@@ -3402,6 +3450,8 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
|
|
|
3402
3450
|
if not label or not geom.const_color:
|
|
3403
3451
|
continue
|
|
3404
3452
|
entry = {"label": str(label), "color": geom.const_color}
|
|
3453
|
+
if id(geom) in legend_keys:
|
|
3454
|
+
entry["lk"] = legend_keys[id(geom)]
|
|
3405
3455
|
if getattr(geom, "_is_formula", False):
|
|
3406
3456
|
entry["formula"] = True
|
|
3407
3457
|
entry["_geom"] = geom
|
|
@@ -3439,7 +3489,9 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
|
|
|
3439
3489
|
raw_title = g.labs.get("title") or ""
|
|
3440
3490
|
# One function and no title of your own: the formula is the title.
|
|
3441
3491
|
# A shaded area, a tangent, or roots stay in the legend when they
|
|
3442
|
-
# have a label of their own. The curve
|
|
3492
|
+
# have a label of their own. The curve keeps its row beside them, so the
|
|
3493
|
+
# legend says which line is the curve; alone, its row would only repeat
|
|
3494
|
+
# the title.
|
|
3443
3495
|
if not str(raw_title).strip() and len(formula_geoms) == 1:
|
|
3444
3496
|
shown = formula_geoms[0]
|
|
3445
3497
|
# Symbolic formula, not the value list. That list stays in the legend
|
|
@@ -3456,11 +3508,11 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
|
|
|
3456
3508
|
elif title_latex:
|
|
3457
3509
|
labs_math["title"] = [{"text": title, "latex": str(title_latex)}]
|
|
3458
3510
|
if legend:
|
|
3459
|
-
|
|
3511
|
+
others = [
|
|
3460
3512
|
entry for entry in legend
|
|
3461
3513
|
if not (entry.get("formula") and entry.get("label") == title)
|
|
3462
3514
|
]
|
|
3463
|
-
if not
|
|
3515
|
+
if not others:
|
|
3464
3516
|
legend = None
|
|
3465
3517
|
else:
|
|
3466
3518
|
title = _take("title", raw_title)
|
|
@@ -7,6 +7,10 @@ import gzip as _gzip
|
|
|
7
7
|
|
|
8
8
|
import numpy as np
|
|
9
9
|
|
|
10
|
+
# gzip is lossless at every level. Level 1 builds large payloads several
|
|
11
|
+
# times faster than 6 for a few percent more bytes.
|
|
12
|
+
_LEVEL = 1
|
|
13
|
+
|
|
10
14
|
def delta_u16(a: np.ndarray) -> np.ndarray:
|
|
11
15
|
"""Column-wise delta mod 2^16 (lossless; tiny values on ordered data)."""
|
|
12
16
|
d = a.astype(np.int32)
|
|
@@ -21,7 +25,7 @@ def pack_u16(q: np.ndarray, compress: bool) -> str:
|
|
|
21
25
|
raw = _gzip.compress(
|
|
22
26
|
np.ascontiguousarray(d[:, 0]).tobytes()
|
|
23
27
|
+ np.ascontiguousarray(d[:, 1]).tobytes(),
|
|
24
|
-
|
|
28
|
+
compresslevel=_LEVEL, mtime=0,
|
|
25
29
|
)
|
|
26
30
|
else:
|
|
27
31
|
raw = np.ascontiguousarray(q, dtype="<u2").tobytes()
|
|
@@ -31,7 +35,7 @@ def pack_u16(q: np.ndarray, compress: bool) -> str:
|
|
|
31
35
|
def pack_f32(v: np.ndarray, compress: bool) -> str:
|
|
32
36
|
raw = np.ascontiguousarray(v, dtype="<f4").tobytes()
|
|
33
37
|
if compress:
|
|
34
|
-
raw = _gzip.compress(raw,
|
|
38
|
+
raw = _gzip.compress(raw, _LEVEL, mtime=0)
|
|
35
39
|
return base64.b64encode(raw).decode("ascii")
|
|
36
40
|
|
|
37
41
|
|
|
@@ -55,7 +59,7 @@ def pack_u8(values: np.ndarray, compress: bool) -> str:
|
|
|
55
59
|
"""uint8 array -> base64 (optional gzip; no delta)."""
|
|
56
60
|
raw = np.ascontiguousarray(values, dtype=np.uint8).tobytes()
|
|
57
61
|
if compress:
|
|
58
|
-
raw = _gzip.compress(raw,
|
|
62
|
+
raw = _gzip.compress(raw, _LEVEL, mtime=0)
|
|
59
63
|
return base64.b64encode(raw).decode("ascii")
|
|
60
64
|
|
|
61
65
|
|
|
@@ -63,6 +67,6 @@ def pack_u32(values: np.ndarray, compress: bool) -> str:
|
|
|
63
67
|
"""uint32 array -> base64 (optional gzip; no delta)."""
|
|
64
68
|
raw = np.ascontiguousarray(values, dtype="<u4").tobytes()
|
|
65
69
|
if compress:
|
|
66
|
-
raw = _gzip.compress(raw,
|
|
70
|
+
raw = _gzip.compress(raw, _LEVEL, mtime=0)
|
|
67
71
|
return base64.b64encode(raw).decode("ascii")
|
|
68
72
|
|
plot3-0.7.0/plot3/kde.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Binned Gaussian kernel density sums (linear binning + FFT convolution).
|
|
2
|
+
|
|
3
|
+
R's ``density()`` bins too. Values are spread onto a fine grid (at least
|
|
4
|
+
four points per output interval, at most 1/20 of a bandwidth apart in
|
|
5
|
+
1D and 1/4 in 2D),
|
|
6
|
+
convolved with the kernel by FFT, and read back at the output points. The
|
|
7
|
+
relative error is far below what a plot shows. Values more than
|
|
8
|
+
``_REACH`` bandwidths past the output range add nothing in float64 and are
|
|
9
|
+
left out. The callers keep the exact sum for small inputs, and these
|
|
10
|
+
helpers return None when the fine grid would be too large.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
# Below this many (observations x output points) the exact sum is cheap.
|
|
18
|
+
EXACT_WORK = 2_000_000
|
|
19
|
+
# The fine spacing is at most this fraction of the bandwidth (1D, 2D), and
|
|
20
|
+
# the fine grid has at least four points per output interval. Linear
|
|
21
|
+
# binning's error grows with the square of that fraction.
|
|
22
|
+
_MAX_STEP_1D = 0.05
|
|
23
|
+
_MAX_STEP_2D = 0.25
|
|
24
|
+
_MIN_FINE = 4
|
|
25
|
+
# Past this many bandwidths a kernel underflows to zero in float64.
|
|
26
|
+
_REACH = 40.0
|
|
27
|
+
# Largest fine grid (points, or cells in 2D) worth binning onto.
|
|
28
|
+
_MAX_CELLS = 1 << 22
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _fine_axis(values: np.ndarray, lo: float, hi: float, n: int, h: float, max_step: float):
|
|
32
|
+
"""``(origin, step, size, first, fine)`` of a fine grid through the output points.
|
|
33
|
+
|
|
34
|
+
The output point ``k`` sits at fine index ``first + k * fine``. The grid
|
|
35
|
+
covers every value within ``_REACH`` bandwidths of the output range;
|
|
36
|
+
values farther out add nothing to the sums.
|
|
37
|
+
"""
|
|
38
|
+
if n < 2 or not hi > lo or not h > 0 or not values.size:
|
|
39
|
+
return None
|
|
40
|
+
fine = max(_MIN_FINE, int(np.ceil((hi - lo) / ((n - 1) * max_step * h))))
|
|
41
|
+
step = (hi - lo) / ((n - 1) * fine)
|
|
42
|
+
low = max(float(values.min()), lo - _REACH * h)
|
|
43
|
+
high = min(float(values.max()), hi + _REACH * h)
|
|
44
|
+
below = max(0, int(np.ceil((lo - low) / step)))
|
|
45
|
+
above = max(0, int(np.ceil((high - hi) / step)))
|
|
46
|
+
size = (n - 1) * fine + 1 + below + above
|
|
47
|
+
if size > _MAX_CELLS:
|
|
48
|
+
return None
|
|
49
|
+
return lo - below * step, step, size, below, fine
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _within(values: np.ndarray, origin: float, step: float, size: int) -> np.ndarray:
|
|
53
|
+
return (values >= origin) & (values <= origin + (size - 1) * step)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _linear_bins(values: np.ndarray, origin: float, step: float, size: int):
|
|
57
|
+
"""Indices and weights of the two fine points around each value."""
|
|
58
|
+
pos = (values - origin) / step
|
|
59
|
+
left = np.clip(np.floor(pos).astype(np.int64), 0, size - 2)
|
|
60
|
+
frac = np.clip(pos - left, 0.0, 1.0)
|
|
61
|
+
return left, frac
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _kernel_fft(step: float, size: int, h: float, length: int) -> np.ndarray:
|
|
65
|
+
offsets = np.arange(-(size - 1), size) * step
|
|
66
|
+
kernel = np.exp(-0.5 * (offsets / h) ** 2) / (h * np.sqrt(2.0 * np.pi))
|
|
67
|
+
return np.fft.rfft(kernel, length)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _fft_length(size: int) -> int:
|
|
71
|
+
return 1 << int(np.ceil(np.log2(3 * size - 2)))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _convolve(weights: np.ndarray, step: float, size: int, h: float, axis: int) -> np.ndarray:
|
|
75
|
+
"""``sum_i w[i] K((j - i) step)`` for every fine index ``j`` along *axis*."""
|
|
76
|
+
length = _fft_length(size)
|
|
77
|
+
spectrum = np.fft.rfft(weights, length, axis=axis)
|
|
78
|
+
shape = [1] * weights.ndim
|
|
79
|
+
shape[axis] = -1
|
|
80
|
+
full = np.fft.irfft(spectrum * _kernel_fft(step, size, h, length).reshape(shape), length, axis=axis)
|
|
81
|
+
return np.take(full, np.arange(size - 1, 2 * size - 1), axis=axis)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def gaussian_1d(x: np.ndarray, lo: float, hi: float, n: int, h: float) -> np.ndarray | None:
|
|
85
|
+
"""``sum_i phi((g - x_i) / h) / h`` at ``linspace(lo, hi, n)``, or None."""
|
|
86
|
+
axis = _fine_axis(x, lo, hi, n, h, _MAX_STEP_1D)
|
|
87
|
+
if axis is None:
|
|
88
|
+
return None
|
|
89
|
+
origin, step, size, first, fine = axis
|
|
90
|
+
x = x[_within(x, origin, step, size)]
|
|
91
|
+
left, frac = _linear_bins(x, origin, step, size)
|
|
92
|
+
weights = np.bincount(left, 1.0 - frac, size) + np.bincount(left + 1, frac, size)
|
|
93
|
+
sums = _convolve(weights, step, size, h, axis=0)
|
|
94
|
+
return np.maximum(sums[first:first + (n - 1) * fine + 1:fine], 0.0)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def gaussian_2d(
|
|
98
|
+
xs: np.ndarray, ys: np.ndarray, x_lims: tuple[float, float], y_lims: tuple[float, float],
|
|
99
|
+
n: int, hx: float, hy: float,
|
|
100
|
+
) -> np.ndarray | None:
|
|
101
|
+
"""``sum_i phi_x(gx - x_i) phi_y(gy - y_i)`` as an ``(n, n)`` (y, x) grid, or None."""
|
|
102
|
+
ax = _fine_axis(xs, x_lims[0], x_lims[1], n, hx, _MAX_STEP_2D)
|
|
103
|
+
ay = _fine_axis(ys, y_lims[0], y_lims[1], n, hy, _MAX_STEP_2D)
|
|
104
|
+
if ax is None or ay is None:
|
|
105
|
+
return None
|
|
106
|
+
(x_origin, x_step, x_size, x_first, x_fine), (y_origin, y_step, y_size, y_first, y_fine) = ax, ay
|
|
107
|
+
# Two FFT passes over the fine grid must beat n exponentials per value
|
|
108
|
+
# on each axis; a grid stretched over wide limits does not.
|
|
109
|
+
if x_size * y_size > _MAX_CELLS or x_size * y_size * 16 > xs.size * n:
|
|
110
|
+
return None
|
|
111
|
+
keep = _within(xs, x_origin, x_step, x_size) & _within(ys, y_origin, y_step, y_size)
|
|
112
|
+
xs, ys = xs[keep], ys[keep]
|
|
113
|
+
xl, xf = _linear_bins(xs, x_origin, x_step, x_size)
|
|
114
|
+
yl, yf = _linear_bins(ys, y_origin, y_step, y_size)
|
|
115
|
+
cells = y_size * x_size
|
|
116
|
+
weights = np.zeros(cells)
|
|
117
|
+
for dy, wy in ((0, 1.0 - yf), (1, yf)):
|
|
118
|
+
for dx, wx in ((0, 1.0 - xf), (1, xf)):
|
|
119
|
+
weights += np.bincount((yl + dy) * x_size + xl + dx, wy * wx, cells)
|
|
120
|
+
weights = weights.reshape(y_size, x_size)
|
|
121
|
+
sums = _convolve(_convolve(weights, x_step, x_size, hx, axis=1), y_step, y_size, hy, axis=0)
|
|
122
|
+
rows = slice(y_first, y_first + (n - 1) * y_fine + 1, y_fine)
|
|
123
|
+
cols = slice(x_first, x_first + (n - 1) * x_fine + 1, x_fine)
|
|
124
|
+
return np.maximum(sums[rows, cols], 0.0)
|
|
@@ -288,9 +288,19 @@ def ordered_levels(values, *, keep_order: bool = False) -> list:
|
|
|
288
288
|
"10" before "4"); anything else sorts as text.
|
|
289
289
|
"""
|
|
290
290
|
values = list(values)
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
291
|
+
try:
|
|
292
|
+
# Drop repeats first, then test the distinct values for missing:
|
|
293
|
+
# one isna() call per level instead of one per row.
|
|
294
|
+
keys = list(dict.fromkeys(values))
|
|
295
|
+
except TypeError: # an NA whose comparison has no truth value
|
|
296
|
+
keys = None
|
|
297
|
+
if keys is None:
|
|
298
|
+
present = [v for v in values if not _is_na(v)]
|
|
299
|
+
missing = len(present) != len(values)
|
|
300
|
+
distinct = list(dict.fromkeys(present))
|
|
301
|
+
else:
|
|
302
|
+
distinct = [v for v in keys if not _is_na(v)]
|
|
303
|
+
missing = len(distinct) != len(keys)
|
|
294
304
|
if not keep_order:
|
|
295
305
|
if all(isinstance(v, (bool, np.bool_)) for v in distinct):
|
|
296
306
|
distinct.sort(key=bool)
|
|
@@ -19,6 +19,7 @@ import numpy as np
|
|
|
19
19
|
import pandas as pd
|
|
20
20
|
|
|
21
21
|
from plot3.geoms import _Geom, aes
|
|
22
|
+
from plot3.kde import EXACT_WORK, gaussian_2d
|
|
22
23
|
from plot3.scales import col_values, ordered_levels, resolution
|
|
23
24
|
from plot3.special import qt
|
|
24
25
|
from plot3.table import has_column, materialize_columns
|
|
@@ -1334,6 +1335,11 @@ def _kde2d(xs, ys, n, lims=None, h=None):
|
|
|
1334
1335
|
y0, y1 = (ys.min(), ys.max()) if lims is None else lims[2:]
|
|
1335
1336
|
gx, gy = np.linspace(x0, x1, n), np.linspace(y0, y1, n)
|
|
1336
1337
|
hx, hy = (_bandwidth(xs), _bandwidth(ys)) if h is None else (float(h[0]) / 4, float(h[1]) / 4)
|
|
1338
|
+
if xs.size * n > EXACT_WORK:
|
|
1339
|
+
# Large inputs: bilinear binning and FFT convolution.
|
|
1340
|
+
sums = gaussian_2d(xs, ys, (float(x0), float(x1)), (float(y0), float(y1)), n, hx, hy)
|
|
1341
|
+
if sums is not None:
|
|
1342
|
+
return gx, gy, sums / xs.size
|
|
1337
1343
|
kx = np.exp(-0.5 * ((gx[:, None] - xs[None, :]) / hx) ** 2) / (hx * math.sqrt(2 * math.pi))
|
|
1338
1344
|
ky = np.exp(-0.5 * ((gy[:, None] - ys[None, :]) / hy) ** 2) / (hy * math.sqrt(2 * math.pi))
|
|
1339
1345
|
z = (ky @ kx.T) / xs.size # (ny, nx), the shape the contour code takes
|
|
@@ -338,8 +338,14 @@ function regCat(ci, obj) {
|
|
|
338
338
|
if (!catObjs.has(ci)) catObjs.set(ci, []);
|
|
339
339
|
catObjs.get(ci).push(obj);
|
|
340
340
|
}
|
|
341
|
+
// legend click on a formula row (tangent, derivative, curve): row key ->
|
|
342
|
+
// every three.js object its layers drew, e.g. a tangent's line and its point
|
|
343
|
+
const hiddenKeys = new Set();
|
|
344
|
+
const keyObjs = new Map();
|
|
345
|
+
let currentLK = null; // the key of the layer being drawn, while it draws
|
|
346
|
+
function layerHidden(L) { return L.lk != null && hiddenKeys.has(L.lk); }
|
|
341
347
|
let redraw = () => {}; // 2D assigns its draw(); 3D renders continuously
|
|
342
|
-
window.__plot3 = { hiddenCats, catObjs };
|
|
348
|
+
window.__plot3 = { hiddenCats, catObjs, hiddenKeys, keyObjs };
|
|
343
349
|
function showLegendBox() {
|
|
344
350
|
// theme(legend_position="none"), and all but one panel of a facet_grid.
|
|
345
351
|
if (S.legendPosition === 'none') return;
|
|
@@ -414,15 +420,23 @@ if (S.legend) {
|
|
|
414
420
|
legEl.innerHTML = (S.labs.color ? '<b style="color:'+T.ink+'">' +
|
|
415
421
|
richLabel('color', S.labs.color) + '</b>' : '') +
|
|
416
422
|
S.legend.map((e, i) => '<div class="lg-e" data-ci="' + (e.ci != null ? e.ci : i) +
|
|
423
|
+
(e.lk != null ? '" data-lk="' + plot3Esc(e.lk) : '') +
|
|
417
424
|
'">' + keyHTML(e, e.color) +
|
|
418
425
|
legendLabel(e) + '</div>').join('') + barHTML() + szHTML;
|
|
419
426
|
legEl.addEventListener('click', ev => {
|
|
420
427
|
const row = ev.target.closest('.lg-e');
|
|
421
428
|
if (!row) return;
|
|
422
|
-
const
|
|
423
|
-
if (
|
|
424
|
-
|
|
425
|
-
|
|
429
|
+
const lk = row.dataset.lk;
|
|
430
|
+
if (lk != null) {
|
|
431
|
+
if (hiddenKeys.has(lk)) hiddenKeys.delete(lk); else hiddenKeys.add(lk);
|
|
432
|
+
row.style.opacity = hiddenKeys.has(lk) ? 0.35 : 1;
|
|
433
|
+
for (const o of (keyObjs.get(lk) || [])) o.visible = !hiddenKeys.has(lk);
|
|
434
|
+
} else {
|
|
435
|
+
const ci = +row.dataset.ci;
|
|
436
|
+
if (hiddenCats.has(ci)) hiddenCats.delete(ci); else hiddenCats.add(ci);
|
|
437
|
+
row.style.opacity = hiddenCats.has(ci) ? 0.35 : 1;
|
|
438
|
+
for (const o of (catObjs.get(ci) || [])) o.visible = !hiddenCats.has(ci);
|
|
439
|
+
}
|
|
426
440
|
if (window.__plot3.afterLegend) window.__plot3.afterLegend();
|
|
427
441
|
tip.style.display = 'none';
|
|
428
442
|
redraw();
|
|
@@ -607,6 +621,16 @@ renderer.setPixelRatio(window.devicePixelRatio);
|
|
|
607
621
|
renderer.setClearColor(0x000000, 0);
|
|
608
622
|
host.appendChild(renderer.domElement);
|
|
609
623
|
const scene = new THREE.Scene();
|
|
624
|
+
{
|
|
625
|
+
const add0 = scene.add.bind(scene);
|
|
626
|
+
scene.add = (...objs) => {
|
|
627
|
+
if (currentLK != null) {
|
|
628
|
+
if (!keyObjs.has(currentLK)) keyObjs.set(currentLK, []);
|
|
629
|
+
keyObjs.get(currentLK).push(...objs);
|
|
630
|
+
}
|
|
631
|
+
return add0(...objs);
|
|
632
|
+
};
|
|
633
|
+
}
|
|
610
634
|
const lineMats = [];
|
|
611
635
|
|
|
612
636
|
// Point symbols (aes(shape=) / geom_point(shape=)) as white sprites tinted
|
|
@@ -1614,6 +1638,7 @@ if (!S.is3d) {
|
|
|
1614
1638
|
return addToScene(...objs);
|
|
1615
1639
|
};
|
|
1616
1640
|
for (const L of S.layers) {
|
|
1641
|
+
currentLK = L.lk != null ? L.lk : null;
|
|
1617
1642
|
if (L.blank) continue; // geom_blank: on the scales only
|
|
1618
1643
|
paintOrder += 1;
|
|
1619
1644
|
const n = L.n;
|
|
@@ -1937,6 +1962,7 @@ if (!S.is3d) {
|
|
|
1937
1962
|
});
|
|
1938
1963
|
}
|
|
1939
1964
|
}
|
|
1965
|
+
currentLK = null;
|
|
1940
1966
|
scene.add = addToScene;
|
|
1941
1967
|
|
|
1942
1968
|
function drawAxes() {
|
|
@@ -2267,6 +2293,7 @@ if (!S.is3d) {
|
|
|
2267
2293
|
if (!best || score < best.score) best = { score, sx, sy, html, hold: !!hold };
|
|
2268
2294
|
}
|
|
2269
2295
|
for (const L of S.layers) {
|
|
2296
|
+
if (layerHidden(L)) continue;
|
|
2270
2297
|
if (L.kind === 'col') {
|
|
2271
2298
|
// Full bar rectangle (not just the top-center point).
|
|
2272
2299
|
const hw = (L.width || 0.08) * 0.5;
|
|
@@ -2445,6 +2472,7 @@ if (!S.is3d) {
|
|
|
2445
2472
|
}
|
|
2446
2473
|
|
|
2447
2474
|
for (const L of S.layers) {
|
|
2475
|
+
currentLK = L.lk != null ? L.lk : null;
|
|
2448
2476
|
if (L.blank) continue; // geom_blank: on the scales only
|
|
2449
2477
|
const n = L.n;
|
|
2450
2478
|
const cols = layerColors(L, hex2rgb(T.cat[0]));
|
|
@@ -2528,6 +2556,7 @@ if (!S.is3d) {
|
|
|
2528
2556
|
}
|
|
2529
2557
|
}
|
|
2530
2558
|
}
|
|
2559
|
+
currentLK = null;
|
|
2531
2560
|
|
|
2532
2561
|
// Axes box as matplotlib and plotly draw it: the three far walls with
|
|
2533
2562
|
// grid lines, refreshed as the camera orbits. The edges at the near
|
|
@@ -2706,6 +2735,7 @@ if (!S.is3d) {
|
|
|
2706
2735
|
let best = null, bestD = Infinity;
|
|
2707
2736
|
const maxScan = 80000;
|
|
2708
2737
|
for (const { L } of pickLayers) {
|
|
2738
|
+
if (layerHidden(L)) continue;
|
|
2709
2739
|
const n = L._stepN != null ? L._stepN : L.n;
|
|
2710
2740
|
const step = n > maxScan ? Math.ceil(n / maxScan) : 1;
|
|
2711
2741
|
const xs = L._x || L.x.data;
|
|
@@ -17,6 +17,7 @@ plot3/geoms.py
|
|
|
17
17
|
plot3/ggplot.py
|
|
18
18
|
plot3/io.py
|
|
19
19
|
plot3/jupyter.py
|
|
20
|
+
plot3/kde.py
|
|
20
21
|
plot3/latexin.py
|
|
21
22
|
plot3/masking.py
|
|
22
23
|
plot3/mathtext.py
|
|
@@ -48,6 +49,7 @@ tests/test_contour.py
|
|
|
48
49
|
tests/test_everyday_ggplot2.py
|
|
49
50
|
tests/test_expr.py
|
|
50
51
|
tests/test_facets_compose.py
|
|
52
|
+
tests/test_fast_paths.py
|
|
51
53
|
tests/test_geom_function.py
|
|
52
54
|
tests/test_geoms_ds.py
|
|
53
55
|
tests/test_ggplot2_extras2.py
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Vectorised level, code and density paths match the per-value rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
import plot3.build as build
|
|
12
|
+
import plot3.stat2d as stat2d
|
|
13
|
+
from plot3.build import _kde_1d, _remap_codes, _rug_positions
|
|
14
|
+
from plot3.scales import Scale, _is_na, ordered_levels
|
|
15
|
+
from plot3.stat2d import _kde2d
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _levels_per_value(values, keep_order=False):
|
|
19
|
+
"""ordered_levels as it was: test every row for missing, then dedupe."""
|
|
20
|
+
values = list(values)
|
|
21
|
+
present = [v for v in values if not _is_na(v)]
|
|
22
|
+
distinct = list(dict.fromkeys(present))
|
|
23
|
+
out = ordered_levels(distinct, keep_order=keep_order)
|
|
24
|
+
return out + ([None] if len(present) != len(values) else [])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.mark.parametrize("values", [
|
|
28
|
+
["Adelie", "Gentoo", None, "Adelie", "Chinstrap", float("nan"), "Gentoo"] * 500,
|
|
29
|
+
[4.0, 6.0, np.nan, 8.0, 10.0, 6.0] * 500,
|
|
30
|
+
[True, False, None, True] * 500,
|
|
31
|
+
[3, 1.0, 1, 2, np.int64(3)] * 500,
|
|
32
|
+
["Biscoe", pd.NA, "Dream", pd.NA, pd.NaT, "Biscoe"] * 500,
|
|
33
|
+
[float("nan")] * 50 + ["Torgersen"],
|
|
34
|
+
[],
|
|
35
|
+
])
|
|
36
|
+
@pytest.mark.parametrize("keep_order", [False, True])
|
|
37
|
+
def test_ordered_levels_matches_the_per_row_rule(values, keep_order):
|
|
38
|
+
assert ordered_levels(values, keep_order=keep_order) == _levels_per_value(values, keep_order)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_remap_codes_matches_the_dict_rule():
|
|
42
|
+
rng = np.random.default_rng(0)
|
|
43
|
+
remap = {0: 2, 1: 0, 3: 1}
|
|
44
|
+
for codes in (rng.integers(-1, 5, 1000).astype(float), rng.integers(0, 4, 1000), np.array([], dtype=float)):
|
|
45
|
+
expected = np.array([remap.get(int(c), 7) for c in codes])
|
|
46
|
+
assert np.array_equal(_remap_codes(codes, remap, 7), expected)
|
|
47
|
+
assert np.array_equal(_remap_codes(np.array([1.9, -0.5]), remap, 7), [0, 2])
|
|
48
|
+
assert np.array_equal(_remap_codes(np.array([2.0]), {}, 0), [0])
|
|
49
|
+
with pytest.raises(ValueError):
|
|
50
|
+
_remap_codes(np.array([0.0, np.nan]), remap, 0)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.parametrize("series", [
|
|
54
|
+
pd.Series([4.0, 6.0, np.nan, 8.0, 4.0]),
|
|
55
|
+
pd.Series([4, 6, 8, 6]),
|
|
56
|
+
pd.Series([True, False, True]),
|
|
57
|
+
pd.Series(np.array([4.0, 6.5], dtype=np.float32)),
|
|
58
|
+
])
|
|
59
|
+
def test_rug_positions_on_a_discrete_scale_match_text_lookup(series):
|
|
60
|
+
scale = Scale("cat")
|
|
61
|
+
scale.cats = ["4", "6", "8", "True", "4.0", "6.0", "6.5", "nan"]
|
|
62
|
+
index = {c: i for i, c in enumerate(scale.cats)}
|
|
63
|
+
expected = np.array([index.get(str(v), np.nan) for v in series], dtype=np.float64)
|
|
64
|
+
np.testing.assert_array_equal(_rug_positions(scale, series), expected)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _exact(module, fn, *args, **kwargs):
|
|
68
|
+
saved = module.EXACT_WORK
|
|
69
|
+
module.EXACT_WORK = math.inf
|
|
70
|
+
try:
|
|
71
|
+
return fn(*args, **kwargs)
|
|
72
|
+
finally:
|
|
73
|
+
module.EXACT_WORK = saved
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.mark.parametrize("values, adjust", [
|
|
77
|
+
(np.random.default_rng(1).normal(4200, 800, 20_000), 1.0),
|
|
78
|
+
(np.random.default_rng(2).lognormal(0, 1.5, 20_000), 1.0),
|
|
79
|
+
(np.random.default_rng(3).normal(17, 2, 20_000), 0.2),
|
|
80
|
+
(np.r_[np.random.default_rng(4).normal(0, 1, 20_000), [1e6]], 1.0),
|
|
81
|
+
(np.random.default_rng(5).integers(2007, 2010, 20_000).astype(float), 1.0),
|
|
82
|
+
])
|
|
83
|
+
def test_binned_density_matches_the_exact_sum(values, adjust):
|
|
84
|
+
grid, dens = _kde_1d(values, adjust=adjust)
|
|
85
|
+
exact_grid, exact = _exact(build, _kde_1d, values, adjust=adjust)
|
|
86
|
+
np.testing.assert_array_equal(grid, exact_grid)
|
|
87
|
+
assert np.max(np.abs(dens - exact)) <= 2e-4 * exact.max()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_small_densities_keep_the_exact_sum():
|
|
91
|
+
values = np.random.default_rng(6).normal(44, 5, 300)
|
|
92
|
+
np.testing.assert_array_equal(_kde_1d(values)[1], _exact(build, _kde_1d, values)[1])
|
|
93
|
+
xs, ys = np.random.default_rng(7).normal(0, 1, (2, 300))
|
|
94
|
+
np.testing.assert_array_equal(_kde2d(xs, ys, 50)[2], _exact(stat2d, _kde2d, xs, ys, 50)[2])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pytest.mark.parametrize("lims, h", [(None, None), (None, (2.0, 1.0)), ((-1, 1, -0.5, 0.5), None)])
|
|
98
|
+
def test_binned_2d_density_matches_the_exact_sum(lims, h):
|
|
99
|
+
rng = np.random.default_rng(8)
|
|
100
|
+
xs, ys = rng.normal(44, 5, 60_000), rng.exponential(2, 60_000)
|
|
101
|
+
gx, gy, z = _kde2d(xs, ys, 100, lims, h)
|
|
102
|
+
ex, ey, exact = _exact(stat2d, _kde2d, xs, ys, 100, lims, h)
|
|
103
|
+
np.testing.assert_array_equal(gx, ex)
|
|
104
|
+
np.testing.assert_array_equal(gy, ey)
|
|
105
|
+
# A quarter-bandwidth grid: the error stays under 0.2% of the peak.
|
|
106
|
+
assert np.max(np.abs(z - exact)) <= 2e-3 * exact.max()
|
|
@@ -13,6 +13,7 @@ from plot3 import (
|
|
|
13
13
|
geom_vector_field,
|
|
14
14
|
ggplot,
|
|
15
15
|
ggsave,
|
|
16
|
+
labs,
|
|
16
17
|
slider,
|
|
17
18
|
tangent,
|
|
18
19
|
)
|
|
@@ -134,6 +135,39 @@ def test_tangent_at_one_has_slope_two():
|
|
|
134
135
|
assert any(entry["label"] == "tangent" for entry in spec["legend"])
|
|
135
136
|
|
|
136
137
|
|
|
138
|
+
def test_legend_rows_name_the_layers_a_click_hides():
|
|
139
|
+
# The viewer hides every layer whose key matches the clicked row: the
|
|
140
|
+
# tangent row covers its line and the point at x = 1.
|
|
141
|
+
spec, _payloads = build_spec(
|
|
142
|
+
ggplot() + geom_function("y = x^3 - 3x") + tangent(at=1) + derivative()
|
|
143
|
+
)
|
|
144
|
+
keys = {entry["label"]: entry["lk"] for entry in spec["legend"]}
|
|
145
|
+
tangent_layers = [layer for layer in spec["layers"] if layer.get("lk") == keys["tangent"]]
|
|
146
|
+
assert sorted(layer["kind"] for layer in tangent_layers) == ["line", "point"]
|
|
147
|
+
assert len(set(keys.values())) == 3
|
|
148
|
+
# Stable keys: the same figure builds the same spec.
|
|
149
|
+
again, _payloads = build_spec(
|
|
150
|
+
ggplot() + geom_function("y = x^3 - 3x") + tangent(at=1) + derivative()
|
|
151
|
+
)
|
|
152
|
+
assert [entry["lk"] for entry in again["legend"]] == list(keys.values())
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_curve_keeps_its_row_beside_a_tangent_and_derivative():
|
|
156
|
+
spec, _payloads = build_spec(
|
|
157
|
+
ggplot() + geom_function("y = x^3 - 3x") + tangent(at=1) + derivative()
|
|
158
|
+
)
|
|
159
|
+
labels = [entry["label"] for entry in spec["legend"]]
|
|
160
|
+
assert labels[0] == spec["labs"]["title"]
|
|
161
|
+
assert "tangent" in labels and len(labels) == 3
|
|
162
|
+
titled, _payloads = build_spec(
|
|
163
|
+
ggplot() + geom_function("y = x^2") + tangent(at=1) + labs(title="Slope")
|
|
164
|
+
)
|
|
165
|
+
assert len(titled["legend"]) == 2
|
|
166
|
+
# Alone, the curve's row would only repeat the title.
|
|
167
|
+
alone, _payloads = build_spec(ggplot() + geom_function("y = x^2"))
|
|
168
|
+
assert alone["legend"] is None
|
|
169
|
+
|
|
170
|
+
|
|
137
171
|
def test_derivative_is_the_slope_curve():
|
|
138
172
|
layers = _layers(ggplot() + geom_function("y = x^2", xlim=(-2, 2), n=11) + derivative())
|
|
139
173
|
curve = next(
|
plot3-0.6.7/plot3/__version__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.6.7"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|