plotlive 0.1.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.
plotlive/transform.py ADDED
@@ -0,0 +1,205 @@
1
+ from __future__ import annotations
2
+ import numpy as np
3
+
4
+ _EPS = 1e-300 # floor for log scale to avoid log(0)
5
+
6
+
7
+ def _log(v):
8
+ return np.log10(np.maximum(np.asarray(v, float), _EPS))
9
+
10
+
11
+ class Transform:
12
+ """
13
+ Bidirectional mapping between data coordinates and screen pixel coordinates.
14
+ Single source of truth for zoom/pan state per Axes.
15
+ Supports 'linear' and 'log' scales independently on each axis.
16
+ """
17
+
18
+ def __init__(self):
19
+ self.xlim: tuple[float, float] = (0.0, 1.0)
20
+ self.ylim: tuple[float, float] = (0.0, 1.0)
21
+ # (left, top, width, height) in screen pixels — set by renderer before each draw
22
+ self.axes_rect: tuple[int, int, int, int] = (0, 0, 100, 100)
23
+ self._home_xlim: tuple[float, float] = (0.0, 1.0)
24
+ self._home_ylim: tuple[float, float] = (0.0, 1.0)
25
+ self.xscale: str = 'linear'
26
+ self.yscale: str = 'linear'
27
+
28
+ # ------------------------------------------------------------------
29
+ # Internal: normalised position [0, 1] along each axis
30
+ # ------------------------------------------------------------------
31
+
32
+ def _x_norm(self, x) -> np.ndarray:
33
+ x = np.asarray(x, float)
34
+ xmin, xmax = self.xlim
35
+ if self.xscale == 'log':
36
+ lx = _log(x); lmin = _log(xmin); lmax = _log(xmax)
37
+ span = lmax - lmin if lmax != lmin else 1.0
38
+ return (lx - lmin) / span
39
+ span = xmax - xmin if xmax != xmin else 1.0
40
+ return (x - xmin) / span
41
+
42
+ def _y_norm(self, y) -> np.ndarray:
43
+ y = np.asarray(y, float)
44
+ ymin, ymax = self.ylim
45
+ if self.yscale == 'log':
46
+ ly = _log(y); lmin = _log(ymin); lmax = _log(ymax)
47
+ span = lmax - lmin if lmax != lmin else 1.0
48
+ return (ly - lmin) / span
49
+ span = ymax - ymin if ymax != ymin else 1.0
50
+ return (y - ymin) / span
51
+
52
+ # ------------------------------------------------------------------
53
+ # Forward transform: data → screen
54
+ # ------------------------------------------------------------------
55
+
56
+ def data_to_screen(
57
+ self, x: float | np.ndarray, y: float | np.ndarray
58
+ ) -> tuple[np.ndarray, np.ndarray]:
59
+ """Convert data (x, y) to screen (sx, sy). Y is flipped."""
60
+ left, top, w, h = self.axes_rect
61
+ sx = left + self._x_norm(x) * w
62
+ sy = top + h - self._y_norm(y) * h # Y flip
63
+ return sx, sy
64
+
65
+ def data_x_to_screen(self, x: float | np.ndarray) -> np.ndarray:
66
+ left, _, w, _ = self.axes_rect
67
+ return left + self._x_norm(x) * w
68
+
69
+ def data_y_to_screen(self, y: float | np.ndarray) -> np.ndarray:
70
+ _, top, _, h = self.axes_rect
71
+ return top + h - self._y_norm(y) * h
72
+
73
+ # ------------------------------------------------------------------
74
+ # Inverse transform: screen → data
75
+ # ------------------------------------------------------------------
76
+
77
+ def screen_to_data(self, sx: float, sy: float) -> tuple[float, float]:
78
+ """Convert screen pixel (sx, sy) to data coordinates."""
79
+ left, top, w, h = self.axes_rect
80
+ xmin, xmax = self.xlim
81
+ ymin, ymax = self.ylim
82
+
83
+ tx = (sx - left) / w if w else 0.0
84
+ ty = (top + h - sy) / h if h else 0.0 # Y flip
85
+
86
+ if self.xscale == 'log':
87
+ lmin = _log(xmin); lmax = _log(xmax)
88
+ x = float(10 ** (lmin + tx * (lmax - lmin)))
89
+ else:
90
+ x = float(xmin + tx * (xmax - xmin))
91
+
92
+ if self.yscale == 'log':
93
+ lmin = _log(ymin); lmax = _log(ymax)
94
+ y = float(10 ** (lmin + ty * (lmax - lmin)))
95
+ else:
96
+ y = float(ymin + ty * (ymax - ymin))
97
+
98
+ return x, y
99
+
100
+ # ------------------------------------------------------------------
101
+ # Mutators
102
+ # ------------------------------------------------------------------
103
+
104
+ def zoom(self, sx: float, sy: float, factor: float) -> None:
105
+ """Zoom by factor centered on screen point (sx, sy). factor<1 = zoom in."""
106
+ cx, cy = self.screen_to_data(sx, sy)
107
+ xmin, xmax = self.xlim
108
+ ymin, ymax = self.ylim
109
+
110
+ if self.xscale == 'log':
111
+ lcx = _log(cx)
112
+ lmin, lmax = float(_log(xmin)), float(_log(xmax))
113
+ self.xlim = (10 ** (lcx - (lcx - lmin) * factor),
114
+ 10 ** (lcx + (lmax - lcx) * factor))
115
+ else:
116
+ self.xlim = (cx - (cx - xmin) * factor, cx + (xmax - cx) * factor)
117
+
118
+ if self.yscale == 'log':
119
+ lcy = float(_log(cy))
120
+ lmin, lmax = float(_log(ymin)), float(_log(ymax))
121
+ self.ylim = (10 ** (lcy - (lcy - lmin) * factor),
122
+ 10 ** (lcy + (lmax - lcy) * factor))
123
+ else:
124
+ self.ylim = (cy - (cy - ymin) * factor, cy + (ymax - cy) * factor)
125
+
126
+ def pan(self, dpx: float, dpy: float) -> None:
127
+ """Pan by (dpx, dpy) screen pixels."""
128
+ _, _, w, h = self.axes_rect
129
+ xmin, xmax = self.xlim
130
+ ymin, ymax = self.ylim
131
+
132
+ if self.xscale == 'log':
133
+ lmin, lmax = float(_log(xmin)), float(_log(xmax))
134
+ dlx = dpx / w * (lmax - lmin) if w else 0.0
135
+ self.xlim = (10 ** (lmin + dlx), 10 ** (lmax + dlx))
136
+ else:
137
+ x_range = xmax - xmin if xmax != xmin else 1.0
138
+ dx = dpx / w * x_range if w else 0.0
139
+ self.xlim = (xmin + dx, xmax + dx)
140
+
141
+ if self.yscale == 'log':
142
+ lmin, lmax = float(_log(ymin)), float(_log(ymax))
143
+ dly = -dpy / h * (lmax - lmin) if h else 0.0 # screen Y down = data Y up
144
+ self.ylim = (10 ** (lmin + dly), 10 ** (lmax + dly))
145
+ else:
146
+ y_range = ymax - ymin if ymax != ymin else 1.0
147
+ dy = -dpy / h * y_range if h else 0.0
148
+ self.ylim = (ymin + dy, ymax + dy)
149
+
150
+ def set_home(self) -> None:
151
+ self._home_xlim = self.xlim
152
+ self._home_ylim = self.ylim
153
+
154
+ def reset_to_home(self) -> None:
155
+ self.xlim = self._home_xlim
156
+ self.ylim = self._home_ylim
157
+
158
+ def contains_screen_point(self, sx: float, sy: float) -> bool:
159
+ left, top, w, h = self.axes_rect
160
+ return left <= sx <= left + w and top <= sy <= top + h
161
+
162
+ def auto_scale(
163
+ self,
164
+ all_x: list,
165
+ all_y: list,
166
+ margin: float = 0.05,
167
+ xlim_auto: bool = True,
168
+ ylim_auto: bool = True,
169
+ ) -> None:
170
+ """Fit xlim/ylim to data with a margin fraction."""
171
+ if all_x and xlim_auto:
172
+ x_arr = np.concatenate([np.atleast_1d(np.asarray(a, float))
173
+ for a in all_x if np.asarray(a).size > 0])
174
+ x_arr = x_arr[np.isfinite(x_arr)]
175
+ if self.xscale == 'log':
176
+ x_arr = x_arr[x_arr > 0]
177
+ if x_arr.size > 0:
178
+ if self.xscale == 'log':
179
+ lmin, lmax = float(np.log10(x_arr.min())), float(np.log10(x_arr.max()))
180
+ span = lmax - lmin if lmax != lmin else 1.0
181
+ self.xlim = (10 ** (lmin - span * margin),
182
+ 10 ** (lmax + span * margin))
183
+ else:
184
+ xmin, xmax = float(x_arr.min()), float(x_arr.max())
185
+ span = xmax - xmin if xmax != xmin else 1.0
186
+ self.xlim = (xmin - span * margin, xmax + span * margin)
187
+
188
+ if all_y and ylim_auto:
189
+ y_arr = np.concatenate([np.atleast_1d(np.asarray(a, float))
190
+ for a in all_y if np.asarray(a).size > 0])
191
+ y_arr = y_arr[np.isfinite(y_arr)]
192
+ if self.yscale == 'log':
193
+ y_arr = y_arr[y_arr > 0]
194
+ if y_arr.size > 0:
195
+ if self.yscale == 'log':
196
+ lmin, lmax = float(np.log10(y_arr.min())), float(np.log10(y_arr.max()))
197
+ span = lmax - lmin if lmax != lmin else 1.0
198
+ self.ylim = (10 ** (lmin - span * margin),
199
+ 10 ** (lmax + span * margin))
200
+ else:
201
+ ymin, ymax = float(y_arr.min()), float(y_arr.max())
202
+ span = ymax - ymin if ymax != ymin else 1.0
203
+ self.ylim = (ymin - span * margin, ymax + span * margin)
204
+
205
+ self.set_home()