plot3 0.4.2__py3-none-any.whl → 0.5.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/__version__.py +1 -1
- plot3/build.py +3 -2
- plot3/calculus.py +102 -24
- plot3/contour.py +138 -26
- plot3/expr.py +8 -0
- plot3/geoms.py +8 -2
- plot3/ggplot.py +7 -6
- plot3/static.py +88 -9
- plot3/stats3d.py +104 -137
- plot3-0.5.0.dist-info/METADATA +308 -0
- {plot3-0.4.2.dist-info → plot3-0.5.0.dist-info}/RECORD +14 -14
- plot3-0.4.2.dist-info/METADATA +0 -521
- {plot3-0.4.2.dist-info → plot3-0.5.0.dist-info}/WHEEL +0 -0
- {plot3-0.4.2.dist-info → plot3-0.5.0.dist-info}/licenses/LICENSE +0 -0
- {plot3-0.4.2.dist-info → plot3-0.5.0.dist-info}/top_level.txt +0 -0
plot3/__version__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.
|
|
1
|
+
__version__ = "0.5.0"
|
plot3/build.py
CHANGED
|
@@ -976,8 +976,8 @@ def expand_stat_geom(
|
|
|
976
976
|
indices = np.zeros((0, 3), dtype=np.int32)
|
|
977
977
|
colour_by = getattr(geom, "colour_by", "level")
|
|
978
978
|
map_kwargs: dict = {"x": "x", "y": "y", "z": "z"}
|
|
979
|
-
if colour_by == "level" and "
|
|
980
|
-
map_kwargs["colour"] = "
|
|
979
|
+
if colour_by == "level" and "level" in vertices.columns:
|
|
980
|
+
map_kwargs["colour"] = "level"
|
|
981
981
|
out = _Geom(
|
|
982
982
|
aes(**map_kwargs),
|
|
983
983
|
color=geom.const_color,
|
|
@@ -990,6 +990,7 @@ def expand_stat_geom(
|
|
|
990
990
|
out.wireframe = bool(getattr(geom, "wireframe", False))
|
|
991
991
|
out._indices = indices
|
|
992
992
|
out._iso_levels = used
|
|
993
|
+
out._colour_title = "level"
|
|
993
994
|
return out
|
|
994
995
|
return geom
|
|
995
996
|
|
plot3/calculus.py
CHANGED
|
@@ -884,37 +884,115 @@ def _arrow(x, y, dx, dy, length: float):
|
|
|
884
884
|
|
|
885
885
|
|
|
886
886
|
def _streamlines(formula, xs, ys, cell, xlim, ylim):
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
887
|
+
"""Evenly spaced streamlines (Jobard and Lefer), one arrowhead each.
|
|
888
|
+
|
|
889
|
+
Seeds sit on the grid, centre first. Each line is traced both ways with
|
|
890
|
+
RK4 at unit speed and stops at the edge, at a zero of the field, within
|
|
891
|
+
``0.5 * cell`` of another line, or where it meets itself (a closed
|
|
892
|
+
orbit), so lines never pile up on one another.
|
|
893
|
+
"""
|
|
894
|
+
sep = cell
|
|
895
|
+
near = 0.5 * sep
|
|
896
|
+
step = 0.1 * sep
|
|
897
|
+
lag = 3.0 * sep # arc length before a line may end on itself
|
|
898
|
+
max_steps = int(4 * ((xlim[1] - xlim[0]) + (ylim[1] - ylim[0])) / step)
|
|
899
|
+
taken: dict[tuple[int, int], list[tuple[float, float]]] = {}
|
|
900
|
+
|
|
901
|
+
def key(x, y):
|
|
902
|
+
return int(np.floor(x / near)), int(np.floor(y / near))
|
|
903
|
+
|
|
904
|
+
def crowded(grid, x, y, radius, s=None):
|
|
905
|
+
kx, ky = key(x, y)
|
|
906
|
+
for ix in (kx - 1, kx, kx + 1):
|
|
907
|
+
for iy in (ky - 1, ky, ky + 1):
|
|
908
|
+
for item in grid.get((ix, iy), ()):
|
|
909
|
+
if (item[0] - x) ** 2 + (item[1] - y) ** 2 >= radius * radius:
|
|
910
|
+
continue
|
|
911
|
+
if s is None or abs(item[2] - s) > lag:
|
|
912
|
+
return True
|
|
913
|
+
return False
|
|
914
|
+
|
|
915
|
+
def unit(x, y):
|
|
916
|
+
vx, vy = _field_at(formula, x, y)
|
|
917
|
+
mag = float(np.hypot(vx, vy))
|
|
918
|
+
if not np.isfinite(mag) or mag < 1e-12:
|
|
919
|
+
return None
|
|
920
|
+
return vx / mag, vy / mag
|
|
921
|
+
|
|
922
|
+
def trace(sx, sy, direction, own):
|
|
923
|
+
points = []
|
|
924
|
+
x, y, s = sx, sy, 0.0
|
|
925
|
+
for _ in range(max_steps):
|
|
926
|
+
k1 = unit(x, y)
|
|
927
|
+
if k1 is None:
|
|
928
|
+
break
|
|
929
|
+
k2 = unit(x + 0.5 * step * direction * k1[0], y + 0.5 * step * direction * k1[1])
|
|
930
|
+
k3 = None if k2 is None else unit(
|
|
931
|
+
x + 0.5 * step * direction * k2[0], y + 0.5 * step * direction * k2[1]
|
|
932
|
+
)
|
|
933
|
+
k4 = None if k3 is None else unit(
|
|
934
|
+
x + step * direction * k3[0], y + step * direction * k3[1]
|
|
935
|
+
)
|
|
936
|
+
if k4 is None:
|
|
937
|
+
break
|
|
938
|
+
x += step * direction * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]) / 6
|
|
939
|
+
y += step * direction * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]) / 6
|
|
940
|
+
s += direction * step
|
|
941
|
+
if not (xlim[0] <= x <= xlim[1] and ylim[0] <= y <= ylim[1]):
|
|
942
|
+
break
|
|
943
|
+
if crowded(taken, x, y, near):
|
|
944
|
+
break
|
|
945
|
+
closed = crowded(own, x, y, 0.5 * step + 1e-12, s)
|
|
946
|
+
points.append((x, y, s))
|
|
947
|
+
own.setdefault(key(x, y), []).append((x, y, s))
|
|
948
|
+
if closed:
|
|
949
|
+
break
|
|
950
|
+
return points
|
|
951
|
+
|
|
952
|
+
cx, cy = 0.5 * (xlim[0] + xlim[1]), 0.5 * (ylim[0] + ylim[1])
|
|
953
|
+
seeds = sorted(
|
|
954
|
+
((float(x), float(y)) for y in ys for x in xs),
|
|
955
|
+
key=lambda p: (p[0] - cx) ** 2 + (p[1] - cy) ** 2,
|
|
956
|
+
)
|
|
894
957
|
rows: list[tuple[float, float]] = []
|
|
895
958
|
groups: list[list[int]] = []
|
|
896
959
|
for sx, sy in seeds:
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
960
|
+
if crowded(taken, sx, sy, sep) or unit(sx, sy) is None:
|
|
961
|
+
continue
|
|
962
|
+
own: dict = {key(sx, sy): [(sx, sy, 0.0)]}
|
|
963
|
+
forward = trace(sx, sy, 1.0, own)
|
|
964
|
+
backward = trace(sx, sy, -1.0, own)
|
|
965
|
+
line = [p for p in reversed(backward)] + [(sx, sy, 0.0)] + forward
|
|
966
|
+
if len(line) * step < sep:
|
|
967
|
+
continue
|
|
968
|
+
for p in line:
|
|
969
|
+
taken.setdefault(key(p[0], p[1]), []).append(p)
|
|
970
|
+
start = len(rows)
|
|
971
|
+
rows.extend((p[0], p[1]) for p in line)
|
|
972
|
+
groups.append([start, len(line)])
|
|
973
|
+
head = _line_arrowhead(line, 0.7 * sep)
|
|
974
|
+
if head is not None:
|
|
912
975
|
start = len(rows)
|
|
913
|
-
rows.extend(
|
|
914
|
-
groups.append([start, len(
|
|
976
|
+
rows.extend(head)
|
|
977
|
+
groups.append([start, len(head)])
|
|
915
978
|
return rows, groups
|
|
916
979
|
|
|
917
980
|
|
|
981
|
+
def _line_arrowhead(line, size: float):
|
|
982
|
+
"""A barb pair at the middle of ``line``, pointing along it."""
|
|
983
|
+
mid = len(line) // 2
|
|
984
|
+
a, b = line[max(0, mid - 1)], line[min(len(line) - 1, mid + 1)]
|
|
985
|
+
dx, dy = b[0] - a[0], b[1] - a[1]
|
|
986
|
+
mag = float(np.hypot(dx, dy))
|
|
987
|
+
if not np.isfinite(mag) or mag == 0.0:
|
|
988
|
+
return None
|
|
989
|
+
ux, uy = dx / mag, dy / mag
|
|
990
|
+
hx, hy = line[mid][0], line[mid][1]
|
|
991
|
+
bx, by = hx - 0.4 * size * ux, hy - 0.4 * size * uy
|
|
992
|
+
wing = 0.25 * size
|
|
993
|
+
return [(bx - wing * uy, by + wing * ux), (hx, hy), (bx + wing * uy, by - wing * ux)]
|
|
994
|
+
|
|
995
|
+
|
|
918
996
|
def _field_at(formula, x: float, y: float) -> tuple[float, float]:
|
|
919
997
|
env = {"x": np.array([x]), "y": np.array([y])}
|
|
920
998
|
dx = float(np.asarray(_component(formula, "dx", env)).reshape(-1)[0])
|
plot3/contour.py
CHANGED
|
@@ -42,6 +42,7 @@ _EDGE_LUT = np.array(
|
|
|
42
42
|
# it cannot refine a small loop that shares the window with a long curve.
|
|
43
43
|
_REFINE_SUB = 8
|
|
44
44
|
_REFINE_NODE_BUDGET = 400_000
|
|
45
|
+
_REFINE_GROW = 4 # rounds of refining around a curve's stray ends
|
|
45
46
|
|
|
46
47
|
_contourpy_mod = None
|
|
47
48
|
_contourpy_loaded = False
|
|
@@ -130,6 +131,51 @@ def _interp_t(a: np.ndarray, b: np.ndarray, level: float) -> np.ndarray:
|
|
|
130
131
|
return np.clip(t, 0.0, 1.0)
|
|
131
132
|
|
|
132
133
|
|
|
134
|
+
_ROOT_STEPS = 14 # bisection steps per crossing: 1/16384 of a fine cell
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _edge_root(sample, fa, fb, xa, ya, xb, yb, level: float) -> np.ndarray:
|
|
138
|
+
"""Where the curve crosses each edge, as a fraction from a to b.
|
|
139
|
+
|
|
140
|
+
Linear interpolation of the corner values is exact only where the
|
|
141
|
+
function crosses zero like a line. Near a double or triple root (the
|
|
142
|
+
heart ``(x^2 + y^2 - 1)^3 = x^2 y^3``) it can miss by half a cell, which
|
|
143
|
+
shows as kinks. With ``sample``, the crossing is bisected on the
|
|
144
|
+
function itself, then interpolated inside the final bracket.
|
|
145
|
+
"""
|
|
146
|
+
t = _interp_t(fa, fb, level)
|
|
147
|
+
if sample is None:
|
|
148
|
+
return t
|
|
149
|
+
fa = np.asarray(fa, dtype=np.float64)
|
|
150
|
+
fb = np.asarray(fb, dtype=np.float64)
|
|
151
|
+
cross = np.isfinite(fa) & np.isfinite(fb) & ((fa >= level) != (fb >= level))
|
|
152
|
+
if not np.any(cross):
|
|
153
|
+
return t
|
|
154
|
+
xa, ya, xb, yb = (np.broadcast_to(v, fa.shape)[cross] for v in (xa, ya, xb, yb))
|
|
155
|
+
lo = np.zeros(xa.shape)
|
|
156
|
+
hi = np.ones(xa.shape)
|
|
157
|
+
flo = fa[cross] - level
|
|
158
|
+
fhi = fb[cross] - level
|
|
159
|
+
for _ in range(_ROOT_STEPS):
|
|
160
|
+
mid = 0.5 * (lo + hi)
|
|
161
|
+
fm = np.asarray(sample(xa + mid * (xb - xa), ya + mid * (yb - ya)), dtype=np.float64)
|
|
162
|
+
fm = np.broadcast_to(fm, mid.shape) - level
|
|
163
|
+
ok = np.isfinite(fm)
|
|
164
|
+
low_side = ok & ((fm >= 0) == (flo >= 0))
|
|
165
|
+
high_side = ok & ~low_side
|
|
166
|
+
lo = np.where(low_side, mid, lo)
|
|
167
|
+
flo = np.where(low_side, fm, flo)
|
|
168
|
+
hi = np.where(high_side, mid, hi)
|
|
169
|
+
fhi = np.where(high_side, fm, fhi)
|
|
170
|
+
if not ok.all():
|
|
171
|
+
break
|
|
172
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
173
|
+
inner = np.where(fhi != flo, -flo / (fhi - flo), 0.5)
|
|
174
|
+
out = np.array(t, dtype=np.float64, copy=True)
|
|
175
|
+
out[cross] = lo + np.clip(inner, 0.0, 1.0) * (hi - lo)
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
133
179
|
def _pair_segments(
|
|
134
180
|
local_ids: np.ndarray,
|
|
135
181
|
cases: np.ndarray,
|
|
@@ -367,6 +413,83 @@ def _refine_active_cells(
|
|
|
367
413
|
_case, active, _center = _cell_cases(field, level)
|
|
368
414
|
if not np.any(active):
|
|
369
415
|
return []
|
|
416
|
+
lines = _refine_cells(xs, ys, active, level, sample, sub)
|
|
417
|
+
# A curve that stops inside the window left the refined cells through
|
|
418
|
+
# a cell whose corners all share a sign: a cusp, or a wedge thinner
|
|
419
|
+
# than one cell. Refine around each stray end until the curve goes on.
|
|
420
|
+
for _ in range(_REFINE_GROW):
|
|
421
|
+
if lines is None:
|
|
422
|
+
return None
|
|
423
|
+
ends = _stray_ends(lines, xs, ys)
|
|
424
|
+
if not ends:
|
|
425
|
+
break
|
|
426
|
+
grown = active.copy()
|
|
427
|
+
for x_val, y_val in ends:
|
|
428
|
+
i = int(np.clip(np.searchsorted(xs, x_val) - 1, 0, len(xs) - 2))
|
|
429
|
+
j = int(np.clip(np.searchsorted(ys, y_val) - 1, 0, len(ys) - 2))
|
|
430
|
+
grown[max(0, j - 1) : j + 2, max(0, i - 1) : i + 2] = True
|
|
431
|
+
if grown.sum() == active.sum():
|
|
432
|
+
break
|
|
433
|
+
active = grown
|
|
434
|
+
lines = _refine_cells(xs, ys, active, level, sample, sub)
|
|
435
|
+
if lines is None:
|
|
436
|
+
return None
|
|
437
|
+
gap = 2.0 * max(float(np.max(np.diff(xs))), float(np.max(np.diff(ys)))) / max(1, int(sub))
|
|
438
|
+
return _join_close_ends(lines, xs, ys, gap)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _stray_ends(lines, xs, ys) -> list[tuple[float, float]]:
|
|
442
|
+
"""End points of open polylines that are not on the window's edge."""
|
|
443
|
+
tol_x = 1e-9 * max(1.0, abs(float(xs[-1] - xs[0])))
|
|
444
|
+
tol_y = 1e-9 * max(1.0, abs(float(ys[-1] - ys[0])))
|
|
445
|
+
out = []
|
|
446
|
+
for line in lines:
|
|
447
|
+
if len(line) < 2 or line[0] == line[-1]:
|
|
448
|
+
continue
|
|
449
|
+
for x_val, y_val in (line[0], line[-1]):
|
|
450
|
+
on_edge = (
|
|
451
|
+
abs(x_val - xs[0]) <= tol_x or abs(x_val - xs[-1]) <= tol_x
|
|
452
|
+
or abs(y_val - ys[0]) <= tol_y or abs(y_val - ys[-1]) <= tol_y
|
|
453
|
+
)
|
|
454
|
+
if not on_edge:
|
|
455
|
+
out.append((x_val, y_val))
|
|
456
|
+
return out
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _join_close_ends(lines, xs, ys, gap: float):
|
|
460
|
+
"""Join open polylines whose stray ends are within ``gap`` (a cusp tip)."""
|
|
461
|
+
lines = [list(line) for line in lines if len(line) >= 2]
|
|
462
|
+
while True:
|
|
463
|
+
stray = set(_stray_ends(lines, xs, ys))
|
|
464
|
+
ends = [
|
|
465
|
+
(a, at_end, line[-1] if at_end else line[0])
|
|
466
|
+
for a, line in enumerate(lines)
|
|
467
|
+
for at_end in (False, True)
|
|
468
|
+
if line[0] != line[-1] and (line[-1] if at_end else line[0]) in stray
|
|
469
|
+
]
|
|
470
|
+
pair = next(
|
|
471
|
+
(
|
|
472
|
+
(p, q)
|
|
473
|
+
for k, p in enumerate(ends)
|
|
474
|
+
for q in ends[k + 1 :]
|
|
475
|
+
if (p[0], p[1]) != (q[0], q[1])
|
|
476
|
+
and np.hypot(p[2][0] - q[2][0], p[2][1] - q[2][1]) <= gap
|
|
477
|
+
),
|
|
478
|
+
None,
|
|
479
|
+
)
|
|
480
|
+
if pair is None:
|
|
481
|
+
return lines
|
|
482
|
+
(a, a_end, _pa), (b, b_end, _pb) = pair
|
|
483
|
+
if a == b:
|
|
484
|
+
lines[a].append(lines[a][0]) # both ends of one line: close it
|
|
485
|
+
continue
|
|
486
|
+
first = lines[a] if a_end else lines[a][::-1] # ends at the joint
|
|
487
|
+
second = lines[b] if not b_end else lines[b][::-1] # starts at it
|
|
488
|
+
lines[a] = first + second
|
|
489
|
+
del lines[b]
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _refine_cells(xs, ys, active, level, sample, sub):
|
|
370
493
|
jj, ii = np.nonzero(active)
|
|
371
494
|
sub = int(sub)
|
|
372
495
|
nodes = sub + 1
|
|
@@ -389,7 +512,7 @@ def _refine_active_cells(
|
|
|
389
512
|
fine = np.asarray(sample(xx, yy), dtype=np.float64)
|
|
390
513
|
if fine.shape != xx.shape:
|
|
391
514
|
fine = np.broadcast_to(fine, xx.shape).astype(np.float64, copy=False)
|
|
392
|
-
return _contour_refined(ii, jj, xs, ys, fine, xf, yf, level, sub)
|
|
515
|
+
return _contour_refined(ii, jj, xs, ys, fine, xf, yf, level, sub, sample)
|
|
393
516
|
|
|
394
517
|
|
|
395
518
|
def _contour_refined(
|
|
@@ -402,6 +525,7 @@ def _contour_refined(
|
|
|
402
525
|
yf: np.ndarray,
|
|
403
526
|
level: float,
|
|
404
527
|
sub: int,
|
|
528
|
+
sample=None,
|
|
405
529
|
) -> list[list[tuple[float, float]]]:
|
|
406
530
|
"""Marching squares on a batch of refined cells, chained by global edge id."""
|
|
407
531
|
f00 = fine[:, :-1, :-1]
|
|
@@ -429,7 +553,7 @@ def _contour_refined(
|
|
|
429
553
|
ny = int(ys.shape[0])
|
|
430
554
|
h_stride = (nx - 1) * sub
|
|
431
555
|
v_stride = (nx - 1) * sub + 1
|
|
432
|
-
n_horizontal = h_stride * ((ny - 1) * sub)
|
|
556
|
+
n_horizontal = h_stride * ((ny - 1) * sub + 1) # rows of cells + 1 rows of edges
|
|
433
557
|
bottom = gj * h_stride + gi
|
|
434
558
|
top = (gj + 1) * h_stride + gi
|
|
435
559
|
left = n_horizontal + gj * v_stride + gi
|
|
@@ -445,30 +569,18 @@ def _contour_refined(
|
|
|
445
569
|
for edge, x_val, y_val in zip(edge_ids.tolist(), x_vals.tolist(), y_vals.tolist()):
|
|
446
570
|
points[int(edge)] = (float(x_val), float(y_val))
|
|
447
571
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
)
|
|
454
|
-
t =
|
|
455
|
-
put(
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
)
|
|
460
|
-
t = _interp_t(fine[aj, lj + 1, li], fine[aj, lj + 1, li + 1], level)
|
|
461
|
-
put(
|
|
462
|
-
top,
|
|
463
|
-
xf[aj, li] + t * (xf[aj, li + 1] - xf[aj, li]),
|
|
464
|
-
yf[aj, lj + 1],
|
|
465
|
-
)
|
|
466
|
-
t = _interp_t(fine[aj, lj, li], fine[aj, lj + 1, li], level)
|
|
467
|
-
put(
|
|
468
|
-
left,
|
|
469
|
-
xf[aj, li],
|
|
470
|
-
yf[aj, lj] + t * (yf[aj, lj + 1] - yf[aj, lj]),
|
|
471
|
-
)
|
|
572
|
+
x0, x1 = xf[aj, li], xf[aj, li + 1]
|
|
573
|
+
y0, y1 = yf[aj, lj], yf[aj, lj + 1]
|
|
574
|
+
f00, f10 = fine[aj, lj, li], fine[aj, lj, li + 1]
|
|
575
|
+
f01, f11 = fine[aj, lj + 1, li], fine[aj, lj + 1, li + 1]
|
|
576
|
+
t = _edge_root(sample, f00, f10, x0, y0, x1, y0, level)
|
|
577
|
+
put(bottom, x0 + t * (x1 - x0), y0)
|
|
578
|
+
t = _edge_root(sample, f10, f11, x1, y0, x1, y1, level)
|
|
579
|
+
put(right, x1, y0 + t * (y1 - y0))
|
|
580
|
+
t = _edge_root(sample, f01, f11, x0, y1, x1, y1, level)
|
|
581
|
+
put(top, x0 + t * (x1 - x0), y1)
|
|
582
|
+
t = _edge_root(sample, f00, f01, x0, y0, x0, y1, level)
|
|
583
|
+
put(left, x0, y0 + t * (y1 - y0))
|
|
472
584
|
|
|
473
585
|
def point_of(edge: int) -> tuple[float, float]:
|
|
474
586
|
return points[edge]
|
plot3/expr.py
CHANGED
|
@@ -434,6 +434,14 @@ def _parse_math(
|
|
|
434
434
|
code_tree = ast.BinOp(left=lhs_tree, op=ast.Sub(), right=rhs_tree)
|
|
435
435
|
# Names only on the left still count (already in ``free``).
|
|
436
436
|
|
|
437
|
+
if len(free) > 2 and defer_missing and "x" in free and "y" in free:
|
|
438
|
+
# ``z = sin(r - t)`` or ``x^2 + y^2 = r^2``: x and y are the axes, so
|
|
439
|
+
# any other letter is a coefficient a slider or transition will
|
|
440
|
+
# sweep. Unswept ones fail at build time like any missing coefficient.
|
|
441
|
+
pending.extend(name for name in free if name not in ("x", "y"))
|
|
442
|
+
free = [name for name in free if name in ("x", "y")]
|
|
443
|
+
if mode == "explicit" and lhs_tree is None:
|
|
444
|
+
dependent = "z"
|
|
437
445
|
if len(free) > 2:
|
|
438
446
|
listed = ", ".join(free)
|
|
439
447
|
raise ExprError(
|
plot3/geoms.py
CHANGED
|
@@ -417,7 +417,8 @@ class geom_isosurface(_Geom):
|
|
|
417
417
|
Density grid bins per axis (8–64). Overridden by a preceding
|
|
418
418
|
:class:`stat_density_3d` if present on the figure.
|
|
419
419
|
colour_by:
|
|
420
|
-
``"level"`` colours
|
|
420
|
+
``"level"`` colours each surface by its level (default), on the
|
|
421
|
+
same 0–1 scale as ``levels``.
|
|
421
422
|
wireframe, alpha:
|
|
422
423
|
Same idea as :class:`geom_surface`.
|
|
423
424
|
"""
|
|
@@ -2066,12 +2067,17 @@ class scale_x_log10:
|
|
|
2066
2067
|
Positions are encoded in log10 space, so a decade is a constant distance
|
|
2067
2068
|
and a tween along the axis moves in log space. Non-positive values are
|
|
2068
2069
|
omitted. Tick labels stay in the original units (1, 10, 100, …).
|
|
2070
|
+
``name``, ``limits``, ``breaks``, and ``labels`` work as in
|
|
2071
|
+
``scale_x_continuous``.
|
|
2069
2072
|
"""
|
|
2070
2073
|
|
|
2071
2074
|
axis = "x"
|
|
2072
2075
|
|
|
2076
|
+
def __init__(self, name=None, *, limits=None, breaks=None, labels=None):
|
|
2077
|
+
self.name, self.limits, self.breaks, self.labels = name, limits, breaks, labels
|
|
2073
2078
|
|
|
2074
|
-
|
|
2079
|
+
|
|
2080
|
+
class scale_y_log10(scale_x_log10):
|
|
2075
2081
|
"""Base-10 logarithmic scale for y. See :class:`scale_x_log10`."""
|
|
2076
2082
|
|
|
2077
2083
|
axis = "y"
|
plot3/ggplot.py
CHANGED
|
@@ -355,22 +355,23 @@ class ggplot:
|
|
|
355
355
|
g.coord = other
|
|
356
356
|
elif isinstance(other, stat_density_3d):
|
|
357
357
|
g.stat_density_3d = other
|
|
358
|
-
elif isinstance(other, scale_x_log10):
|
|
359
|
-
g
|
|
360
|
-
|
|
361
|
-
|
|
358
|
+
elif isinstance(other, scale_x_log10): # also scale_y_log10
|
|
359
|
+
g = g + _scaling.PositionScale(
|
|
360
|
+
other.axis, "continuous", name=other.name, limits=other.limits,
|
|
361
|
+
breaks=other.breaks, labels=other.labels, trans="log10",
|
|
362
|
+
)
|
|
362
363
|
elif isinstance(other, _scaling._Lims):
|
|
363
364
|
for part in other:
|
|
364
365
|
g = g + part
|
|
365
366
|
elif isinstance(other, _scaling.PositionScale):
|
|
366
367
|
if other.axis == "x":
|
|
367
368
|
g.xscale = other
|
|
368
|
-
g.scale_x = scale_x_log10() if other.trans == "log10" else (
|
|
369
|
+
g.scale_x = scale_x_log10(other.name) if other.trans == "log10" else (
|
|
369
370
|
None if other.kind == "continuous" else g.scale_x
|
|
370
371
|
)
|
|
371
372
|
else:
|
|
372
373
|
g.yscale = other
|
|
373
|
-
g.scale_y = scale_y_log10() if other.trans == "log10" else (
|
|
374
|
+
g.scale_y = scale_y_log10(other.name) if other.trans == "log10" else (
|
|
374
375
|
None if other.kind == "continuous" else g.scale_y
|
|
375
376
|
)
|
|
376
377
|
elif isinstance(other, _scaling.ColourScale):
|
plot3/static.py
CHANGED
|
@@ -1065,6 +1065,10 @@ def _draw_2d(
|
|
|
1065
1065
|
box = _box_2d(spec, x, y, w, h, labs, fonts, extra_right, extra_bottom)
|
|
1066
1066
|
scales = spec.get("scales") or {}
|
|
1067
1067
|
window = _view_window(spec, box[2], box[3])
|
|
1068
|
+
scales = dict(scales)
|
|
1069
|
+
for axis, u_lo, u_hi in (("x", window[0], window[1]), ("y", window[2], window[3])):
|
|
1070
|
+
if axis in scales:
|
|
1071
|
+
scales[axis] = _window_ticks(scales[axis], u_lo, u_hi)
|
|
1068
1072
|
surface = theme.get("surface") or "#0b1020"
|
|
1069
1073
|
grid = theme.get("grid") or "#1c2742"
|
|
1070
1074
|
axis = theme.get("axis") or "#2e3a5c"
|
|
@@ -1429,6 +1433,15 @@ def _draw_3d(
|
|
|
1429
1433
|
# labels the same room as the others.
|
|
1430
1434
|
inner = _inset_box(box, layout["pad"])
|
|
1431
1435
|
project, centre = _fit_projector(base, ext, inner, zoom)
|
|
1436
|
+
if not void and centre is not None:
|
|
1437
|
+
# A long axis title ("elevation") beside a short edge can reach past
|
|
1438
|
+
# the canvas: narrow the cube on that side to make room for it.
|
|
1439
|
+
spots = _axis_title_spots(ext, project, centre, labs, fonts, layout)
|
|
1440
|
+
left, right = _title_overflow(spots, fonts, (x, y, w, h))
|
|
1441
|
+
if left > 0.5 or right > 0.5:
|
|
1442
|
+
ix, iy, iw, ih = inner
|
|
1443
|
+
inner = (ix + left, iy, max(4.0, iw - left - right), ih)
|
|
1444
|
+
project, centre = _fit_projector(base, ext, inner, zoom)
|
|
1432
1445
|
ink = theme.get("ink") or "#ffffff"
|
|
1433
1446
|
gz = bool(spec.get("gz"))
|
|
1434
1447
|
min_dim = min(box[2], box[3])
|
|
@@ -2449,6 +2462,16 @@ def _clamp_point(px: float, py: float, bounds) -> tuple[float, float]:
|
|
|
2449
2462
|
)
|
|
2450
2463
|
|
|
2451
2464
|
|
|
2465
|
+
def _clamp_label(px, py, text, size, anchor, baseline, bounds) -> tuple[float, float]:
|
|
2466
|
+
"""``(px, py)`` moved so the whole label, not only its anchor, stays in ``bounds``."""
|
|
2467
|
+
px, py = _clamp_point(px, py, bounds)
|
|
2468
|
+
left, top, width, height = _label_box(px, py, text, size, anchor, baseline)
|
|
2469
|
+
x, y, w, h = bounds
|
|
2470
|
+
dx = max(0.0, x + 3.0 - left) - max(0.0, left + width - (x + w - 3.0))
|
|
2471
|
+
dy = max(0.0, y + 3.0 - top) - max(0.0, top + height - (y + h - 3.0))
|
|
2472
|
+
return px + dx, py + dy
|
|
2473
|
+
|
|
2474
|
+
|
|
2452
2475
|
def _thin_marks(marks, air: float) -> list:
|
|
2453
2476
|
"""Every k-th tick, the smallest k whose labels do not touch, keeping
|
|
2454
2477
|
round values (0, 5, 10 rather than 1, 4, 7) when the ticks allow."""
|
|
@@ -2472,6 +2495,41 @@ def _thin_marks(marks, air: float) -> list:
|
|
|
2472
2495
|
return marks[:1]
|
|
2473
2496
|
|
|
2474
2497
|
|
|
2498
|
+
def _axis_title_spots(ext, project, centre, labs, fonts, layout) -> list:
|
|
2499
|
+
"""``(text, x, y, anchor, baseline)`` for each 3D axis title, unclamped."""
|
|
2500
|
+
if centre is None:
|
|
2501
|
+
return []
|
|
2502
|
+
corners = _cube_corners(project, ext)
|
|
2503
|
+
hull = _hull_edge_set(corners)
|
|
2504
|
+
spots = []
|
|
2505
|
+
for axis in ("x", "y", "z"):
|
|
2506
|
+
text = labs.get(axis) or ""
|
|
2507
|
+
if not text:
|
|
2508
|
+
continue
|
|
2509
|
+
pair = _visible_axis_edge(axis, hull, corners, centre)
|
|
2510
|
+
if corners[pair[0]] is None or corners[pair[1]] is None:
|
|
2511
|
+
continue
|
|
2512
|
+
nx, ny = _outward_normal(corners[pair[0]], corners[pair[1]], centre)
|
|
2513
|
+
anchor, baseline = _outside_anchor(nx, ny)
|
|
2514
|
+
mid = project(_corner_xyz(pair[0], ext) * 0.5 + _corner_xyz(pair[1], ext) * 0.5)
|
|
2515
|
+
if mid is None:
|
|
2516
|
+
continue
|
|
2517
|
+
gap = layout["title_gap"]
|
|
2518
|
+
spots.append((text, mid[0] + nx * gap, mid[1] + ny * gap, anchor, baseline))
|
|
2519
|
+
return spots
|
|
2520
|
+
|
|
2521
|
+
|
|
2522
|
+
def _title_overflow(spots, fonts, bounds) -> tuple[float, float]:
|
|
2523
|
+
"""How far the axis titles reach past the left and right of ``bounds``."""
|
|
2524
|
+
x, _y, w, _h = bounds
|
|
2525
|
+
left = right = 0.0
|
|
2526
|
+
for text, lx, ly, anchor, baseline in spots:
|
|
2527
|
+
bx, _by, bw, _bh = _label_box(lx, ly, text, fonts[0], anchor, baseline)
|
|
2528
|
+
left = max(left, x + 3.0 - bx)
|
|
2529
|
+
right = max(right, bx + bw - (x + w - 3.0))
|
|
2530
|
+
return left, right
|
|
2531
|
+
|
|
2532
|
+
|
|
2475
2533
|
def _draw_3d_axes(spec, ext, project, centre, commands, theme, fonts, labs, bounds, layout) -> None:
|
|
2476
2534
|
"""Tick numbers and axis names just outside the three visible edges."""
|
|
2477
2535
|
if centre is None:
|
|
@@ -2482,7 +2540,6 @@ def _draw_3d_axes(spec, ext, project, centre, commands, theme, fonts, labs, boun
|
|
|
2482
2540
|
hull = _hull_edge_set(corners)
|
|
2483
2541
|
scales = spec.get("scales") or {}
|
|
2484
2542
|
tick_gap = layout["tick_gap"]
|
|
2485
|
-
title_gap = layout["title_gap"]
|
|
2486
2543
|
placed: list[tuple[float, float, float, float]] = [] # label boxes, all axes
|
|
2487
2544
|
for axis in ("x", "y", "z"):
|
|
2488
2545
|
pair = _visible_axis_edge(axis, hull, corners, centre)
|
|
@@ -2504,7 +2561,10 @@ def _draw_3d_axes(spec, ext, project, centre, commands, theme, fonts, labs, boun
|
|
|
2504
2561
|
hit = project(_corner_xyz(pair[0], ext) * (1.0 - u) + _corner_xyz(pair[1], ext) * u)
|
|
2505
2562
|
if hit is None:
|
|
2506
2563
|
continue
|
|
2507
|
-
tx, ty =
|
|
2564
|
+
tx, ty = _clamp_label(
|
|
2565
|
+
hit[0] + nx * tick_gap, hit[1] + ny * tick_gap, str(lab), fonts[0],
|
|
2566
|
+
anchor, baseline, bounds,
|
|
2567
|
+
)
|
|
2508
2568
|
box = _label_box(tx, ty, str(lab), fonts[0], anchor, baseline)
|
|
2509
2569
|
marks.append((value, str(lab), hit, tx, ty, box))
|
|
2510
2570
|
reach = max(3.0, tick_gap - 2.0)
|
|
@@ -2522,13 +2582,8 @@ def _draw_3d_axes(spec, ext, project, centre, commands, theme, fonts, labs, boun
|
|
|
2522
2582
|
"text", tx, ty, lab, fonts[0], muted,
|
|
2523
2583
|
anchor, baseline, 0, 400,
|
|
2524
2584
|
))
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
continue
|
|
2528
|
-
mid = project(_corner_xyz(pair[0], ext) * 0.5 + _corner_xyz(pair[1], ext) * 0.5)
|
|
2529
|
-
if mid is None:
|
|
2530
|
-
continue
|
|
2531
|
-
lx, ly = _clamp_point(mid[0] + nx * title_gap, mid[1] + ny * title_gap, bounds)
|
|
2585
|
+
for text, lx, ly, anchor, baseline in _axis_title_spots(ext, project, centre, labs, fonts, layout):
|
|
2586
|
+
lx, ly = _clamp_label(lx, ly, text, fonts[0], anchor, baseline, bounds)
|
|
2532
2587
|
commands.append((
|
|
2533
2588
|
"text", lx, ly, text, fonts[0], ink2, anchor, baseline, 0, 400,
|
|
2534
2589
|
))
|
|
@@ -2609,6 +2664,30 @@ def _view_window(spec, plot_w, plot_h):
|
|
|
2609
2664
|
return (0.5 - nx / 2, 0.5 + nx / 2, 0.5 - ny / 2, 0.5 + ny / 2)
|
|
2610
2665
|
|
|
2611
2666
|
|
|
2667
|
+
def _window_ticks(scale: dict, u_lo: float, u_hi: float) -> dict:
|
|
2668
|
+
"""Ticks across the visible window when coord_equal widens an axis.
|
|
2669
|
+
|
|
2670
|
+
The scale's ticks span the data. Equal aspect can show far more than
|
|
2671
|
+
that (``y^2 = x^3`` is 4.6 wide and 20 tall), and the data's ticks then
|
|
2672
|
+
bunch in a strip. The viewer re-ticks whatever is visible; so does this.
|
|
2673
|
+
Breaks the user chose (``fixed``) are kept.
|
|
2674
|
+
"""
|
|
2675
|
+
if (
|
|
2676
|
+
scale.get("kind") != "num" or scale.get("fixed") or scale.get("trans") == "log10"
|
|
2677
|
+
or (u_hi - u_lo) <= 1.2 * (1 + 2 * _PAD)
|
|
2678
|
+
):
|
|
2679
|
+
return scale
|
|
2680
|
+
from plot3.scales import fmt_ticks, nice_ticks
|
|
2681
|
+
|
|
2682
|
+
lo = float(scale.get("lo", 0.0))
|
|
2683
|
+
span = float(scale.get("hi", 1.0)) - lo or 1.0
|
|
2684
|
+
v_lo, v_hi = lo + u_lo * span, lo + u_hi * span
|
|
2685
|
+
values = [v for v in nice_ticks(v_lo, v_hi, 5) if v_lo <= v <= v_hi]
|
|
2686
|
+
if not values:
|
|
2687
|
+
return scale
|
|
2688
|
+
return dict(scale, ticks=[[v, label] for v, label in zip(values, fmt_ticks(values))])
|
|
2689
|
+
|
|
2690
|
+
|
|
2612
2691
|
def _ticks(scale: dict) -> list:
|
|
2613
2692
|
if not scale:
|
|
2614
2693
|
return []
|