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/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)