plotpress 0.23.2__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.
- plotpress/__init__.py +108 -0
- plotpress/_interactive.py +2849 -0
- plotpress/_spectral.py +154 -0
- plotpress/_version.py +1 -0
- plotpress/artists.py +1382 -0
- plotpress/axes.py +3221 -0
- plotpress/colors.py +498 -0
- plotpress/figure.py +3084 -0
- plotpress/fonts/__init__.py +51 -0
- plotpress/fonts/families.py +192 -0
- plotpress/fonts/installed.py +82 -0
- plotpress/fonts/metrics.py +265 -0
- plotpress/png.py +93 -0
- plotpress/polar.py +240 -0
- plotpress/primitives.py +335 -0
- plotpress/qt.py +427 -0
- plotpress/raster.py +1316 -0
- plotpress/style.py +91 -0
- plotpress/svg.py +2589 -0
- plotpress/ticker.py +212 -0
- plotpress/transform.py +85 -0
- plotpress/vega.py +1324 -0
- plotpress/vega_lite.py +1199 -0
- plotpress-0.23.2.dist-info/METADATA +378 -0
- plotpress-0.23.2.dist-info/RECORD +28 -0
- plotpress-0.23.2.dist-info/WHEEL +5 -0
- plotpress-0.23.2.dist-info/licenses/LICENSE +21 -0
- plotpress-0.23.2.dist-info/top_level.txt +1 -0
plotpress/ticker.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Tick location and label formatting ("nice numbers" 1-2-5 algorithm)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def nice_ticks(vmin: float, vmax: float, n: int = 5) -> np.ndarray:
|
|
12
|
+
"""Return ~``n`` evenly spaced "nice" tick locations within [vmin, vmax].
|
|
13
|
+
|
|
14
|
+
Order-independent: ``set_xlim(hi, lo)`` reverses the axis (like matplotlib),
|
|
15
|
+
so the caller may pass ``vmin > vmax``. The tick *locations* are the same
|
|
16
|
+
either way -- the reversal is handled by the transform -- so normalize here.
|
|
17
|
+
"""
|
|
18
|
+
if vmin > vmax:
|
|
19
|
+
vmin, vmax = vmax, vmin
|
|
20
|
+
if vmin == vmax:
|
|
21
|
+
vmin, vmax = vmin - 0.5, vmax + 0.5
|
|
22
|
+
if not (math.isfinite(vmin) and math.isfinite(vmax)):
|
|
23
|
+
return np.array([vmin, vmax])
|
|
24
|
+
|
|
25
|
+
span = vmax - vmin
|
|
26
|
+
raw_step = span / max(n, 1)
|
|
27
|
+
mag = 10 ** math.floor(math.log10(raw_step))
|
|
28
|
+
norm = raw_step / mag
|
|
29
|
+
# Snap to a nice multiple of the magnitude.
|
|
30
|
+
if norm < 1.5:
|
|
31
|
+
step = 1 * mag
|
|
32
|
+
elif norm < 3:
|
|
33
|
+
step = 2 * mag
|
|
34
|
+
elif norm < 7:
|
|
35
|
+
step = 5 * mag
|
|
36
|
+
else:
|
|
37
|
+
step = 10 * mag
|
|
38
|
+
|
|
39
|
+
start = math.ceil(vmin / step) * step
|
|
40
|
+
ticks = np.arange(start, vmax + step * 0.5, step)
|
|
41
|
+
# Snap the near-zero tick to exactly zero. For an unlucky step (0.02, say)
|
|
42
|
+
# np.arange lands it at ~1e-17 instead of 0, which then formats as "1.4e-17".
|
|
43
|
+
# A real tick is a multiple of step, so only the zero tick can be this close.
|
|
44
|
+
ticks[np.abs(ticks) < step * 1e-6] = 0.0
|
|
45
|
+
# Guard against float dust producing points just outside the range.
|
|
46
|
+
ticks = ticks[(ticks >= vmin - step * 1e-6) & (ticks <= vmax + step * 1e-6)]
|
|
47
|
+
return ticks
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def log_ticks(vmin: float, vmax: float) -> np.ndarray:
|
|
51
|
+
"""Tick locations for a log axis, all lying **within** [vmin, vmax].
|
|
52
|
+
|
|
53
|
+
Decades where the range spans them. The containment matters: a tick outside
|
|
54
|
+
the limits transforms to a pixel outside the axes box, and nothing clips
|
|
55
|
+
tick labels -- so an out-of-range decade is drawn into whatever sits next to
|
|
56
|
+
the axes, typically the neighboring subplot. Autoscale margins alone are
|
|
57
|
+
enough to trigger it: data from 0.01 upward gets a limit just under 0.01,
|
|
58
|
+
which used to pull in a 0.001 tick a whole panel away.
|
|
59
|
+
|
|
60
|
+
Ranges narrower than a decade have no decade inside them, so they fall back
|
|
61
|
+
to 1-2-5 subdivisions and then to plain :func:`nice_ticks` -- an axis with
|
|
62
|
+
no labels at all is worse than one whose labels are not powers of ten.
|
|
63
|
+
|
|
64
|
+
Order-independent (see :func:`nice_ticks`): a reversed log limit still gets
|
|
65
|
+
its ticks rather than silently rendering none.
|
|
66
|
+
"""
|
|
67
|
+
if vmin > vmax:
|
|
68
|
+
vmin, vmax = vmax, vmin
|
|
69
|
+
if vmin <= 0:
|
|
70
|
+
# Data/limits reached here non-positive; pick a small positive floor
|
|
71
|
+
# (three decades below the top), never zero. Matches the interactive JS.
|
|
72
|
+
vmin = max(vmax / 1000.0, 1e-300) if vmax > 0 else 1e-3
|
|
73
|
+
lo = math.floor(math.log10(vmin))
|
|
74
|
+
hi = math.ceil(math.log10(vmax))
|
|
75
|
+
# Huge dynamic range (a bit-error-rate axis spans seventeen decades): thin
|
|
76
|
+
# the decades out rather than interpolating between them. Every k-th decade
|
|
77
|
+
# keeps every label a round power of ten; a linear interpolation over the
|
|
78
|
+
# exponents produced ticks at 4.3e-17 and 1.9e-15, which is unreadable and
|
|
79
|
+
# not what a log axis is for.
|
|
80
|
+
step = max(1, int(math.ceil((hi - lo) / 12.0)))
|
|
81
|
+
exps = np.arange(lo, hi + 1, step)
|
|
82
|
+
|
|
83
|
+
def _inside(values):
|
|
84
|
+
# Relative tolerance: a decade that the limit sits on should survive the
|
|
85
|
+
# float dust of 10 ** floor(log10(v)).
|
|
86
|
+
return values[(values >= vmin * (1 - 1e-9)) & (values <= vmax * (1 + 1e-9))]
|
|
87
|
+
|
|
88
|
+
# Three decades is the point where powers of ten alone label an axis well.
|
|
89
|
+
# Two -- a range like 2 um to 120 um -- leaves a wide axis carrying "10" and
|
|
90
|
+
# "100" and nothing else, so subdivide instead.
|
|
91
|
+
ticks = _inside(np.power(10.0, exps))
|
|
92
|
+
if ticks.size >= 3:
|
|
93
|
+
return ticks
|
|
94
|
+
|
|
95
|
+
# Sub-decade range: 1-2-5 within each decade the range touches.
|
|
96
|
+
fine = np.concatenate([np.array([1.0, 2.0, 5.0]) * 10.0 ** e
|
|
97
|
+
for e in np.arange(lo, hi + 1)])
|
|
98
|
+
fine = _inside(np.sort(fine))
|
|
99
|
+
if fine.size >= 2:
|
|
100
|
+
return fine
|
|
101
|
+
return nice_ticks(vmin, vmax)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def minor_ticks(major: np.ndarray, vmin: float, vmax: float,
|
|
105
|
+
scale: str = "linear") -> np.ndarray:
|
|
106
|
+
"""Unlabeled minor tick locations between/around ``major``, within [vmin, vmax].
|
|
107
|
+
|
|
108
|
+
Linear: subdivides the major step by a count keyed off its leading digit
|
|
109
|
+
(1->5, 2->4, 5->5), matching :func:`nice_ticks`'s 1-2-5 convention, so
|
|
110
|
+
minor ticks land on round subdivisions of whatever step ``nice_ticks``
|
|
111
|
+
chose. Log: the 2..9 sub-decade marks within each decade the range spans.
|
|
112
|
+
"""
|
|
113
|
+
if vmin > vmax:
|
|
114
|
+
vmin, vmax = vmax, vmin
|
|
115
|
+
if scale == "log":
|
|
116
|
+
if vmin <= 0:
|
|
117
|
+
vmin = max(vmax / 1000.0, 1e-300) if vmax > 0 else 1e-3
|
|
118
|
+
lo = math.floor(math.log10(vmin))
|
|
119
|
+
hi = math.ceil(math.log10(vmax))
|
|
120
|
+
fine = np.concatenate([np.arange(2, 10) * 10.0 ** e
|
|
121
|
+
for e in np.arange(lo, hi + 1)])
|
|
122
|
+
fine = fine[(fine >= vmin) & (fine <= vmax)]
|
|
123
|
+
return np.sort(fine)
|
|
124
|
+
|
|
125
|
+
major = np.asarray(major, dtype=float)
|
|
126
|
+
if major.size < 2:
|
|
127
|
+
return np.empty(0, dtype=float)
|
|
128
|
+
step = float(major[1] - major[0])
|
|
129
|
+
if step == 0:
|
|
130
|
+
return np.empty(0, dtype=float)
|
|
131
|
+
mag = 10 ** math.floor(math.log10(abs(step)))
|
|
132
|
+
lead = round(abs(step) / mag)
|
|
133
|
+
n = {1: 5, 2: 4, 5: 5}.get(lead, 5)
|
|
134
|
+
substep = step / n
|
|
135
|
+
# Walk outward from the first major tick, in both directions, so minor
|
|
136
|
+
# ticks land exactly on subdivisions of the major grid rather than an
|
|
137
|
+
# independent grid that may not line up with it.
|
|
138
|
+
ticks = []
|
|
139
|
+
k = math.floor((vmin - major[0]) / substep) - 1
|
|
140
|
+
kmax = math.ceil((vmax - major[0]) / substep) + 1
|
|
141
|
+
for i in range(int(k), int(kmax) + 1):
|
|
142
|
+
v = major[0] + i * substep
|
|
143
|
+
if vmin - substep * 1e-6 <= v <= vmax + substep * 1e-6:
|
|
144
|
+
# Skip points coincident with a major tick.
|
|
145
|
+
if not np.any(np.abs(major - v) < abs(substep) * 1e-6):
|
|
146
|
+
ticks.append(v)
|
|
147
|
+
return np.array(sorted(ticks), dtype=float)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def format_tick(v: float) -> str:
|
|
151
|
+
"""Format a tick value compactly (fixed or scientific as appropriate)."""
|
|
152
|
+
if v == 0:
|
|
153
|
+
return "0"
|
|
154
|
+
av = abs(v)
|
|
155
|
+
if av >= 1e5 or av < 1e-3:
|
|
156
|
+
s = f"{v:.1e}"
|
|
157
|
+
# Tidy "1.0e+03" -> "1e3".
|
|
158
|
+
mant, exp = s.split("e")
|
|
159
|
+
mant = mant.rstrip("0").rstrip(".")
|
|
160
|
+
exp = int(exp)
|
|
161
|
+
return f"{mant}e{exp}"
|
|
162
|
+
s = f"{v:.6f}".rstrip("0").rstrip(".")
|
|
163
|
+
return s
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _sci_tick(v: float, exp: int, decimals: int) -> str:
|
|
167
|
+
"""Format ``v`` against a *shared* exponent, e.g. ``1.002e5`` for exp=5."""
|
|
168
|
+
mant = f"{v / 10.0 ** exp:.{decimals}f}"
|
|
169
|
+
if "." in mant:
|
|
170
|
+
mant = mant.rstrip("0").rstrip(".")
|
|
171
|
+
return f"{mant}e{exp}"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def format_ticks(values) -> List[str]:
|
|
175
|
+
"""Format a *set* of ticks so no two labels collide.
|
|
176
|
+
|
|
177
|
+
``format_tick`` alone rounds each value to one mantissa digit, which turns a
|
|
178
|
+
narrow band at high magnitude into six identical labels (ticks across
|
|
179
|
+
[100000, 101000] all read "1e5"). For an evenly spaced set, pick a single
|
|
180
|
+
shared exponent and carry enough mantissa digits to resolve the tick *step*,
|
|
181
|
+
so the labels stay distinct and comparable.
|
|
182
|
+
|
|
183
|
+
Unevenly spaced sets -- log decades, mainly -- keep the per-value form,
|
|
184
|
+
where each label already carries its own exponent.
|
|
185
|
+
"""
|
|
186
|
+
vals = [float(v) for v in values]
|
|
187
|
+
labels = [format_tick(v) for v in vals]
|
|
188
|
+
if len(set(labels)) == len(labels):
|
|
189
|
+
return labels # already distinct -- nothing to repair
|
|
190
|
+
if len(vals) < 2 or not all(math.isfinite(v) for v in vals):
|
|
191
|
+
return labels
|
|
192
|
+
|
|
193
|
+
diffs = np.diff(vals)
|
|
194
|
+
step = abs(float(diffs[0]))
|
|
195
|
+
# Uneven spacing means no single exponent describes the set; a zero step
|
|
196
|
+
# means the ticks are float-identical, so no formatting can separate them.
|
|
197
|
+
if step == 0 or not np.allclose(diffs, diffs[0], rtol=1e-6):
|
|
198
|
+
return labels
|
|
199
|
+
|
|
200
|
+
peak = max(abs(v) for v in vals)
|
|
201
|
+
if peak == 0:
|
|
202
|
+
return labels
|
|
203
|
+
|
|
204
|
+
exp = math.floor(math.log10(peak))
|
|
205
|
+
# Enough decimals that one step is visible in the mantissa: the step spans
|
|
206
|
+
# 10**-d of the shared decade when d = exp - log10(step). The 1e-9 absorbs
|
|
207
|
+
# float dust so an exact power-of-ten step doesn't round a digit up.
|
|
208
|
+
decimals = max(0, min(12, math.ceil(exp - math.log10(step) - 1e-9)))
|
|
209
|
+
shared = [_sci_tick(v, exp, decimals) for v in vals]
|
|
210
|
+
# Only adopt the rewrite if it actually separated them (a range so narrow
|
|
211
|
+
# that 12 decimals cannot resolve it keeps the shorter per-value labels).
|
|
212
|
+
return shared if len(set(shared)) == len(shared) else labels
|
plotpress/transform.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Vectorized data-space -> SVG-pixel-space transforms.
|
|
2
|
+
|
|
3
|
+
SVG's origin is the top-left corner with y increasing downward, so the y axis
|
|
4
|
+
is flipped relative to data space. All transforms operate on whole NumPy arrays
|
|
5
|
+
in one shot -- there is no per-point Python work.
|
|
6
|
+
|
|
7
|
+
A per-axis *scale* ('linear' or 'log') is applied as a forward function before
|
|
8
|
+
the affine map, exactly like matplotlib (scale transform, then affine). Log maps
|
|
9
|
+
non-positive values to NaN, which the renderers already skip.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _forward(scale):
|
|
18
|
+
if scale == "log":
|
|
19
|
+
def f(v):
|
|
20
|
+
v = np.asarray(v, dtype=float)
|
|
21
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
22
|
+
return np.where(v > 0, np.log10(np.where(v > 0, v, np.nan)), np.nan)
|
|
23
|
+
return f
|
|
24
|
+
return lambda v: np.asarray(v, dtype=float)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LinearTransform:
|
|
28
|
+
"""Maps data coordinates to pixel coordinates for one axes.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
xlim, ylim : (float, float)
|
|
33
|
+
Data limits ``(min, max)``.
|
|
34
|
+
pixel_rect : (float, float, float, float)
|
|
35
|
+
Target rectangle in pixels as ``(left, top, width, height)`` using
|
|
36
|
+
SVG's top-left origin.
|
|
37
|
+
xscale, yscale : 'linear' | 'log'
|
|
38
|
+
Per-axis scale applied before the affine map.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, xlim, ylim, pixel_rect, xscale="linear", yscale="linear"):
|
|
42
|
+
self.xmin, self.xmax = float(xlim[0]), float(xlim[1])
|
|
43
|
+
self.ymin, self.ymax = float(ylim[0]), float(ylim[1])
|
|
44
|
+
self.px_left, self.px_top, self.px_w, self.px_h = pixel_rect
|
|
45
|
+
self.xscale, self.yscale = xscale, yscale
|
|
46
|
+
|
|
47
|
+
self._fx = _forward(xscale)
|
|
48
|
+
self._fy = _forward(yscale)
|
|
49
|
+
self._fxmin = float(self._fx(self.xmin))
|
|
50
|
+
self._fxmax = float(self._fx(self.xmax))
|
|
51
|
+
self._fymin = float(self._fy(self.ymin))
|
|
52
|
+
self._fymax = float(self._fy(self.ymax))
|
|
53
|
+
|
|
54
|
+
self._sx = self.px_w / ((self._fxmax - self._fxmin) or 1.0)
|
|
55
|
+
self._sy = self.px_h / ((self._fymax - self._fymin) or 1.0)
|
|
56
|
+
|
|
57
|
+
def x(self, x):
|
|
58
|
+
"""Data x -> pixel x (vectorized)."""
|
|
59
|
+
return self.px_left + (self._fx(x) - self._fxmin) * self._sx
|
|
60
|
+
|
|
61
|
+
def y(self, y):
|
|
62
|
+
"""Data y -> pixel y (vectorized, y-axis flipped)."""
|
|
63
|
+
return self.px_top + (self._fymax - self._fy(y)) * self._sy
|
|
64
|
+
|
|
65
|
+
def xy(self, x, y):
|
|
66
|
+
"""Return stacked ``(N, 2)`` pixel coordinates."""
|
|
67
|
+
return np.column_stack([self.x(x), self.y(y)])
|
|
68
|
+
|
|
69
|
+
def x_base(self, x):
|
|
70
|
+
"""Data x -> pixel x, clamped into the visible domain first.
|
|
71
|
+
|
|
72
|
+
For the *baseline* of a shape that is anchored to one, not for data.
|
|
73
|
+
A bar or a stem sits on zero by default, and zero has no place on a log
|
|
74
|
+
axis: mapping it gives NaN, the whole rectangle's geometry becomes NaN,
|
|
75
|
+
and the series silently disappears -- which is how a log-scaled
|
|
76
|
+
histogram renders as an empty panel. Clamping the anchor to the axis
|
|
77
|
+
edge draws the bar from the bottom of the frame, as matplotlib does.
|
|
78
|
+
"""
|
|
79
|
+
return self.x(np.clip(x, *sorted((self.xmin, self.xmax)))
|
|
80
|
+
if self.xscale == "log" else x)
|
|
81
|
+
|
|
82
|
+
def y_base(self, y):
|
|
83
|
+
"""Data y -> pixel y, clamped into the visible domain. See :meth:`x_base`."""
|
|
84
|
+
return self.y(np.clip(y, *sorted((self.ymin, self.ymax)))
|
|
85
|
+
if self.yscale == "log" else y)
|