plotruler 0.1.3__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.
- plotruler/__init__.py +3 -0
- plotruler/__main__.py +98 -0
- plotruler/core.py +371 -0
- plotruler/format.py +262 -0
- plotruler/hotkey.py +240 -0
- plotruler/overlay.py +1135 -0
- plotruler/settings.py +130 -0
- plotruler/storage.py +144 -0
- plotruler/titlebar.py +226 -0
- plotruler/tray.py +179 -0
- plotruler/win_hittest.py +292 -0
- plotruler-0.1.3.dist-info/METADATA +206 -0
- plotruler-0.1.3.dist-info/RECORD +15 -0
- plotruler-0.1.3.dist-info/WHEEL +4 -0
- plotruler-0.1.3.dist-info/licenses/LICENSE +22 -0
plotruler/__init__.py
ADDED
plotruler/__main__.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Run PlotRuler with `python -m plotruler`."""
|
|
2
|
+
|
|
3
|
+
import faulthandler
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import traceback
|
|
7
|
+
|
|
8
|
+
from PySide6.QtWidgets import QApplication
|
|
9
|
+
|
|
10
|
+
from . import storage
|
|
11
|
+
from .hotkey import DEFAULT_COMBO, GlobalHotkey, combo_from_dict
|
|
12
|
+
from .overlay import OverlayWindow, _config_path
|
|
13
|
+
from .settings import HotkeyDialog
|
|
14
|
+
from .tray import TrayIcon
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load_hotkey():
|
|
18
|
+
"""Return the saved key combo, or the default if none is stored."""
|
|
19
|
+
saved = storage.hotkey(_config_path())
|
|
20
|
+
combo = combo_from_dict(saved) if saved else None
|
|
21
|
+
return combo or DEFAULT_COMBO
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _crash_log_path():
|
|
25
|
+
base = os.environ.get("LOCALAPPDATA") or os.environ.get("TEMP") or "."
|
|
26
|
+
return os.path.join(base, "PlotRuler", "crash.log")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _install_crash_logging():
|
|
30
|
+
"""Route fatal signal and Python-exception reports to a log file.
|
|
31
|
+
|
|
32
|
+
A native segfault in Qt produces no Python traceback, so faulthandler
|
|
33
|
+
writes its dump to a file we can read afterward; a Python exception
|
|
34
|
+
that escapes the event loop is caught by sys.excepthook.
|
|
35
|
+
"""
|
|
36
|
+
path = _crash_log_path()
|
|
37
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
38
|
+
with open(path, "a", encoding="utf-8") as handle:
|
|
39
|
+
handle.write("\n===== PlotRuler start =====\n")
|
|
40
|
+
try:
|
|
41
|
+
# faulthandler writes on fatal signals using the file descriptor it
|
|
42
|
+
# captured; the file is left open despite no Python reference, so a
|
|
43
|
+
# C++ segfault can still be recorded.
|
|
44
|
+
faulthandler.enable(file=open(path, "a", encoding="utf-8"))
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
def excepthook(exc_type, exc, tb):
|
|
49
|
+
text = "".join(traceback.format_exception(exc_type, exc, tb))
|
|
50
|
+
with open(path, "a", encoding="utf-8") as handle:
|
|
51
|
+
handle.write(text)
|
|
52
|
+
sys.__excepthook__(exc_type, exc, tb)
|
|
53
|
+
|
|
54
|
+
sys.excepthook = excepthook
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main():
|
|
58
|
+
_install_crash_logging()
|
|
59
|
+
app = QApplication(sys.argv)
|
|
60
|
+
app.setApplicationName("PlotRuler")
|
|
61
|
+
app.setApplicationDisplayName("PlotRuler")
|
|
62
|
+
|
|
63
|
+
window = OverlayWindow()
|
|
64
|
+
|
|
65
|
+
config_path = _config_path()
|
|
66
|
+
|
|
67
|
+
def change_hotkey():
|
|
68
|
+
current = _load_hotkey()
|
|
69
|
+
dialog = HotkeyDialog(current=current)
|
|
70
|
+
if dialog.exec():
|
|
71
|
+
new_combo = dialog.combo()
|
|
72
|
+
if new_combo is not None:
|
|
73
|
+
if hotkey.set_combo(new_combo):
|
|
74
|
+
storage.save(config_path, hotkey=new_combo.to_dict())
|
|
75
|
+
else:
|
|
76
|
+
# The new key is already in use (RegisterHotKey failed);
|
|
77
|
+
# tell the user and leave the old hotkey registered.
|
|
78
|
+
print(
|
|
79
|
+
"Could not register "
|
|
80
|
+
+ new_combo.text()
|
|
81
|
+
+ "; it may already be in use."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Parent the tray to the window so it lives as long as the app does.
|
|
85
|
+
TrayIcon(window, on_change_hotkey=change_hotkey, parent=window)
|
|
86
|
+
app.setQuitOnLastWindowClosed(False) # stay resident in the tray
|
|
87
|
+
|
|
88
|
+
hotkey = GlobalHotkey(_load_hotkey(), callback=window.toggle_visibility)
|
|
89
|
+
hotkey.register()
|
|
90
|
+
app.installNativeEventFilter(hotkey)
|
|
91
|
+
app.aboutToQuit.connect(hotkey.unregister)
|
|
92
|
+
|
|
93
|
+
window.show()
|
|
94
|
+
sys.exit(app.exec())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
main()
|
plotruler/core.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Pixel-to-value math for PlotRuler.
|
|
2
|
+
|
|
3
|
+
This module is deliberately free of any Qt imports so it stays
|
|
4
|
+
unit-testable and portable. The overlay layer passes coordinates in
|
|
5
|
+
one consistent space (physical screen pixels); this module turns them
|
|
6
|
+
into data values.
|
|
7
|
+
|
|
8
|
+
Each axis maps screen coordinate to value through two anchor points.
|
|
9
|
+
By default the map is linear (affine): two anchor points define a line
|
|
10
|
+
and any screen coordinate is interpolated (or extrapolated) along it.
|
|
11
|
+
A log axis instead fits the line in logarithmic space, so a fixed pixel
|
|
12
|
+
step yields a fixed multiplicative factor rather than a fixed additive
|
|
13
|
+
step. X and Y are independent, so a full calibration is just two maps.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from math import floor, isfinite, log10
|
|
17
|
+
|
|
18
|
+
from .format import AUTO, render
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AxisCalibration:
|
|
22
|
+
"""Maps one screen axis to values using two (coordinate, value) anchors.
|
|
23
|
+
|
|
24
|
+
The anchors may be supplied in either order; a line is fit through
|
|
25
|
+
both. Screen coordinates normally grow toward the lower right while
|
|
26
|
+
data values may grow in either direction, so an inverted axis
|
|
27
|
+
(screen down = value up, the usual graph layout) is handled
|
|
28
|
+
naturally by the line fit.
|
|
29
|
+
|
|
30
|
+
When log is True the line is fit on log10(value) instead of value:
|
|
31
|
+
value = 10**(a * coordinate + b). This requires both anchor values
|
|
32
|
+
to be positive (a log scale cannot represent zero or negatives).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, p1, v1, p2, v2, log=False):
|
|
36
|
+
if p1 == p2:
|
|
37
|
+
raise ValueError("calibration anchors must be distinct")
|
|
38
|
+
self.p1 = float(p1)
|
|
39
|
+
self.v1 = float(v1)
|
|
40
|
+
self.p2 = float(p2)
|
|
41
|
+
self.v2 = float(v2)
|
|
42
|
+
self.log = bool(log)
|
|
43
|
+
if self.log and not (self.v1 > 0 and self.v2 > 0):
|
|
44
|
+
raise ValueError("log axes require positive anchor values")
|
|
45
|
+
|
|
46
|
+
def _log_slope(self):
|
|
47
|
+
"""Decades per pixel, the log-axis analog of scale()."""
|
|
48
|
+
return (log10(self.v2) - log10(self.v1)) / (self.p2 - self.p1)
|
|
49
|
+
|
|
50
|
+
def value(self, p):
|
|
51
|
+
"""Return the data value at screen coordinate p.
|
|
52
|
+
|
|
53
|
+
Interpolates between the anchors, or extrapolates past them so
|
|
54
|
+
the readout stays sensible just outside the calibrated region.
|
|
55
|
+
"""
|
|
56
|
+
if self.log:
|
|
57
|
+
exponent = log10(self.v1) + (p - self.p1) * self._log_slope()
|
|
58
|
+
result = 10**exponent
|
|
59
|
+
if not isfinite(result):
|
|
60
|
+
raise ValueError("log value is out of range")
|
|
61
|
+
return result
|
|
62
|
+
return self.v1 + (p - self.p1) / (self.p2 - self.p1) * (
|
|
63
|
+
self.v2 - self.v1
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def scale(self):
|
|
67
|
+
"""Return units per pixel for this axis (always positive).
|
|
68
|
+
|
|
69
|
+
Only meaningful for a linear axis; on a log axis a fixed pixel
|
|
70
|
+
step gives a multiplicative factor instead, so precision is
|
|
71
|
+
tracked as significant figures (see format()).
|
|
72
|
+
"""
|
|
73
|
+
return abs((self.v2 - self.v1) / (self.p2 - self.p1))
|
|
74
|
+
|
|
75
|
+
def decimals(self, pixel_error=1.0):
|
|
76
|
+
"""Return how many decimals to display given click precision.
|
|
77
|
+
|
|
78
|
+
A pixel_error-pixel click error becomes scale() * pixel_error
|
|
79
|
+
units of uncertainty on the readout; we display just enough
|
|
80
|
+
decimals that the rounding unit is no finer than that
|
|
81
|
+
uncertainty, so the readout never over-claims precision.
|
|
82
|
+
"""
|
|
83
|
+
uncertainty = self.scale() * pixel_error
|
|
84
|
+
if uncertainty <= 0:
|
|
85
|
+
return 0
|
|
86
|
+
return max(0, -floor(log10(uncertainty)))
|
|
87
|
+
|
|
88
|
+
def _log_significant_figures(self, pixel_error=1.0):
|
|
89
|
+
"""Significant figures for a log axis given click precision.
|
|
90
|
+
|
|
91
|
+
A pixel_error-pixel error moves the value by a factor of
|
|
92
|
+
10**(slope * pixel_error), so the relative uncertainty is
|
|
93
|
+
constant along the axis; we show just enough significant figures
|
|
94
|
+
that the last one is not swamped by that uncertainty. This is
|
|
95
|
+
the log analog of decimals() for a linear axis.
|
|
96
|
+
"""
|
|
97
|
+
relative = abs(10 ** (self._log_slope() * pixel_error) - 1)
|
|
98
|
+
if not isfinite(relative) or relative <= 0:
|
|
99
|
+
return 1
|
|
100
|
+
return max(1, -floor(log10(relative)))
|
|
101
|
+
|
|
102
|
+
def format(self, value, pixel_error=1.0, fmt=AUTO):
|
|
103
|
+
"""Return value as a string using the requested number format.
|
|
104
|
+
|
|
105
|
+
A linear axis rounds to decimals; a log axis shows a fixed
|
|
106
|
+
number of significant figures because its precision is relative,
|
|
107
|
+
not additive. The number format (plain, scientific, etc.) is
|
|
108
|
+
applied by the formatter module; the axis only supplies the
|
|
109
|
+
precision (decimals for linear, sig-figs for log) it implies.
|
|
110
|
+
"""
|
|
111
|
+
decimals = None if self.log else self.decimals(pixel_error)
|
|
112
|
+
sig = self._log_significant_figures(pixel_error) if self.log else None
|
|
113
|
+
return render(value, fmt, decimals, sig)
|
|
114
|
+
|
|
115
|
+
def __repr__(self):
|
|
116
|
+
return (
|
|
117
|
+
f"AxisCalibration({self.p1}, {self.v1}, {self.p2}, {self.v2}, "
|
|
118
|
+
f"log={self.log})"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Calibration:
|
|
123
|
+
"""Two AxisCalibrations, one per screen axis, forming a full 2-D map."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, x, y):
|
|
126
|
+
self.x = x
|
|
127
|
+
self.y = y
|
|
128
|
+
|
|
129
|
+
def xy(self, px, py):
|
|
130
|
+
"""Return the (x, y) data values at the screen point (px, py)."""
|
|
131
|
+
return self.x.value(px), self.y.value(py)
|
|
132
|
+
|
|
133
|
+
def region(self):
|
|
134
|
+
"""Return the calibrated pixel bounds as (left, top, right, bottom).
|
|
135
|
+
|
|
136
|
+
The X anchors give the horizontal extent and the Y anchors the
|
|
137
|
+
vertical extent, so the calibrated region is the rectangle between
|
|
138
|
+
them regardless of the order the anchors were clicked. The overlay
|
|
139
|
+
draws this as a guide so a misaligned graph underneath is noticed.
|
|
140
|
+
"""
|
|
141
|
+
top, bottom = sorted((self.y.p1, self.y.p2))
|
|
142
|
+
left, right = sorted((self.x.p1, self.x.p2))
|
|
143
|
+
return left, top, right, bottom
|
|
144
|
+
|
|
145
|
+
def __repr__(self):
|
|
146
|
+
return f"Calibration(x={self.x}, y={self.y})"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class CalibrationSession:
|
|
150
|
+
"""Guides the four click-and-type steps that build a calibration.
|
|
151
|
+
|
|
152
|
+
The user calibrates the X axis first (two points), then the Y axis
|
|
153
|
+
(two points). Each point is a screen position; immediately after
|
|
154
|
+
clicking it the user types the value that the position represents.
|
|
155
|
+
The session is a small state machine over that fixed sequence so the
|
|
156
|
+
overlay can paint a prompt for the step it is waiting on and can
|
|
157
|
+
undo steps or cancel cleanly.
|
|
158
|
+
|
|
159
|
+
Positions are in the same coordinate space the rest of the math
|
|
160
|
+
uses (physical screen pixels); the session does not care what units
|
|
161
|
+
they are in.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
# The whole flow as a fixed sequence of micro-steps: for each axis a
|
|
165
|
+
# click, a value, a second click, a second value, then a linear/log
|
|
166
|
+
# choice. A plain pointer into this list is the entire state, which
|
|
167
|
+
# makes undo trivial (step back and drop the stored data for the step
|
|
168
|
+
# we return to).
|
|
169
|
+
_STEPS = (
|
|
170
|
+
("click", "x", 0),
|
|
171
|
+
("value", "x", 0),
|
|
172
|
+
("click", "x", 1),
|
|
173
|
+
("value", "x", 1),
|
|
174
|
+
("mode", "x", None),
|
|
175
|
+
("click", "y", 0),
|
|
176
|
+
("value", "y", 0),
|
|
177
|
+
("click", "y", 1),
|
|
178
|
+
("value", "y", 1),
|
|
179
|
+
("mode", "y", None),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def __init__(self):
|
|
183
|
+
self._step = 0
|
|
184
|
+
self._points = {}
|
|
185
|
+
self._values = {}
|
|
186
|
+
# Axis scale mode: "lin" or "log". Set when the mode step for an
|
|
187
|
+
# axis is reached. A log scale needs both values positive, so an
|
|
188
|
+
# axis with a zero (or negative) anchor is forced to linear and
|
|
189
|
+
# the mode step is skipped automatically.
|
|
190
|
+
self._scale_mode = {"x": "lin", "y": "lin"}
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def active(self):
|
|
194
|
+
"""True while a calibration is still being entered."""
|
|
195
|
+
return self._step < len(self._STEPS)
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def expecting_click(self):
|
|
199
|
+
"""True when the next step needs a point click."""
|
|
200
|
+
return self.active and self._STEPS[self._step][0] == "click"
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def expecting_value(self):
|
|
204
|
+
"""True when the next step needs a typed value."""
|
|
205
|
+
return self.active and self._STEPS[self._step][0] == "value"
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def expecting_mode(self):
|
|
209
|
+
"""True when the next step needs a linear/log choice.
|
|
210
|
+
|
|
211
|
+
This only advances if both anchor values for the axis are
|
|
212
|
+
positive; otherwise the axis cannot be logarithmic, so the mode
|
|
213
|
+
step is skipped and the axis stays linear.
|
|
214
|
+
"""
|
|
215
|
+
if not self.active:
|
|
216
|
+
return False
|
|
217
|
+
kind, axis, _index = self._STEPS[self._step]
|
|
218
|
+
if kind != "mode":
|
|
219
|
+
return False
|
|
220
|
+
return self._log_permitted(axis)
|
|
221
|
+
|
|
222
|
+
def _log_permitted(self, axis):
|
|
223
|
+
"""True if a log scale is possible for an axis (both values > 0)."""
|
|
224
|
+
v0 = self._values.get((axis, 0))
|
|
225
|
+
v1 = self._values.get((axis, 1))
|
|
226
|
+
return v0 is not None and v1 is not None and v0 > 0 and v1 > 0
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def current_axis(self):
|
|
230
|
+
"""The axis ('x' or 'y') being calibrated right now, or None.
|
|
231
|
+
|
|
232
|
+
Returns the axis of the current step whether it is awaiting a
|
|
233
|
+
click or a value, so the overlay can draw a live guide line in
|
|
234
|
+
the right orientation while the user aligns a point.
|
|
235
|
+
"""
|
|
236
|
+
if not self.active:
|
|
237
|
+
return None
|
|
238
|
+
return self._STEPS[self._step][1]
|
|
239
|
+
|
|
240
|
+
def prompt(self):
|
|
241
|
+
"""Return the instruction for the step currently being waited on."""
|
|
242
|
+
if not self.active:
|
|
243
|
+
return "Calibration complete"
|
|
244
|
+
kind, axis, index = self._STEPS[self._step]
|
|
245
|
+
name = "X" if axis == "x" else "Y"
|
|
246
|
+
if kind == "value":
|
|
247
|
+
return f"Type the value at this {name} point, then press Enter"
|
|
248
|
+
if kind == "mode":
|
|
249
|
+
return f"Is the {name} axis linear or log? (click a button)"
|
|
250
|
+
which = "first" if index == 0 else "second"
|
|
251
|
+
return f"Click the {which} {name} point"
|
|
252
|
+
|
|
253
|
+
def record_point(self, px, py):
|
|
254
|
+
"""Record a clicked screen position for the current step."""
|
|
255
|
+
if not self.active:
|
|
256
|
+
raise ValueError("calibration is already complete")
|
|
257
|
+
kind, axis, index = self._STEPS[self._step]
|
|
258
|
+
if kind != "click":
|
|
259
|
+
raise ValueError("a value is being requested, not a click")
|
|
260
|
+
self._points[(axis, index)] = (px, py)
|
|
261
|
+
self._step += 1
|
|
262
|
+
self._skip_auto_mode()
|
|
263
|
+
|
|
264
|
+
def record_value(self, value):
|
|
265
|
+
"""Attach a numeric value to the point that was just clicked."""
|
|
266
|
+
if not self.active:
|
|
267
|
+
raise ValueError("calibration is already complete")
|
|
268
|
+
kind, axis, index = self._STEPS[self._step]
|
|
269
|
+
if kind != "value":
|
|
270
|
+
raise ValueError("a click is being requested, not a value")
|
|
271
|
+
self._values[(axis, index)] = float(value)
|
|
272
|
+
self._step += 1
|
|
273
|
+
self._skip_auto_mode()
|
|
274
|
+
|
|
275
|
+
def record_mode(self, scale_mode):
|
|
276
|
+
"""Choose how the current axis is scaled: 'lin' or 'log'.
|
|
277
|
+
|
|
278
|
+
Called when the session expects a mode step (see expecting_mode).
|
|
279
|
+
Rejects a log scale when the axis has a non-positive anchor value.
|
|
280
|
+
"""
|
|
281
|
+
if not self.active:
|
|
282
|
+
raise ValueError("calibration is already complete")
|
|
283
|
+
kind, axis, _index = self._STEPS[self._step]
|
|
284
|
+
if kind != "mode":
|
|
285
|
+
raise ValueError("a mode choice is not being requested")
|
|
286
|
+
if scale_mode == "log" and not self._log_permitted(axis):
|
|
287
|
+
raise ValueError("log scale needs positive anchor values")
|
|
288
|
+
if scale_mode not in ("lin", "log"):
|
|
289
|
+
raise ValueError("scale mode must be 'lin' or 'log'")
|
|
290
|
+
self._scale_mode[axis] = scale_mode
|
|
291
|
+
self._step += 1
|
|
292
|
+
|
|
293
|
+
def _skip_auto_mode(self):
|
|
294
|
+
"""Advance past a mode step the user never has to see.
|
|
295
|
+
|
|
296
|
+
A mode step whose axis has a zero or negative anchor cannot be
|
|
297
|
+
logarithmic, so it is fixed to linear and skipped rather than
|
|
298
|
+
stalling the flow waiting for a choice the user cannot make.
|
|
299
|
+
"""
|
|
300
|
+
while self.active and self._STEPS[self._step][0] == "mode":
|
|
301
|
+
axis = self._STEPS[self._step][1]
|
|
302
|
+
if self._log_permitted(axis):
|
|
303
|
+
break
|
|
304
|
+
self._scale_mode[axis] = "lin"
|
|
305
|
+
self._step += 1
|
|
306
|
+
|
|
307
|
+
def undo(self):
|
|
308
|
+
"""Undo the most recent step, clearing its stored data.
|
|
309
|
+
|
|
310
|
+
After a value is typed, undo returns to the value prompt with the
|
|
311
|
+
point still marked. After a click, undo removes the point too.
|
|
312
|
+
"""
|
|
313
|
+
if self._step == 0:
|
|
314
|
+
return
|
|
315
|
+
self._step -= 1
|
|
316
|
+
# Skip back over a mode step the user never saw (auto-skipped
|
|
317
|
+
# because log was impossible), so undo lands on a real step.
|
|
318
|
+
while self._step > 0 and self._STEPS[self._step][0] == "mode":
|
|
319
|
+
axis = self._STEPS[self._step][1]
|
|
320
|
+
if self._log_permitted(axis):
|
|
321
|
+
break
|
|
322
|
+
self._step -= 1
|
|
323
|
+
if self._step == 0:
|
|
324
|
+
return
|
|
325
|
+
kind, axis, index = self._STEPS[self._step]
|
|
326
|
+
if kind == "click":
|
|
327
|
+
self._points.pop((axis, index), None)
|
|
328
|
+
elif kind == "value":
|
|
329
|
+
self._values.pop((axis, index), None)
|
|
330
|
+
else:
|
|
331
|
+
# Mode step: revert the axis to linear so it can be re-picked.
|
|
332
|
+
self._scale_mode[axis] = "lin"
|
|
333
|
+
|
|
334
|
+
def anchors(self):
|
|
335
|
+
"""Return the points entered so far, oldest first.
|
|
336
|
+
|
|
337
|
+
Each item is (axis, index, px, py, value); value is None until
|
|
338
|
+
the point's value has been typed. The overlay uses this to paint
|
|
339
|
+
the anchor markers.
|
|
340
|
+
"""
|
|
341
|
+
result = []
|
|
342
|
+
for key, (px, py) in sorted(self._points.items()):
|
|
343
|
+
axis, index = key
|
|
344
|
+
result.append((axis, index, px, py, self._values.get(key)))
|
|
345
|
+
return result
|
|
346
|
+
|
|
347
|
+
def calibration(self):
|
|
348
|
+
"""Return the Calibration for the entered anchors, or None.
|
|
349
|
+
|
|
350
|
+
A calibration only exists once every point has a value. A
|
|
351
|
+
degenerate axis (both clicks at the same coordinate) cannot form
|
|
352
|
+
a line, so it yields None too.
|
|
353
|
+
"""
|
|
354
|
+
if self.active:
|
|
355
|
+
return None
|
|
356
|
+
try:
|
|
357
|
+
x = self._axis_calibration("x")
|
|
358
|
+
y = self._axis_calibration("y")
|
|
359
|
+
except ValueError:
|
|
360
|
+
return None
|
|
361
|
+
return Calibration(x, y)
|
|
362
|
+
|
|
363
|
+
def _axis_calibration(self, axis):
|
|
364
|
+
def coord(key):
|
|
365
|
+
px, py = self._points[key]
|
|
366
|
+
return (px if axis == "x" else py), self._values[key]
|
|
367
|
+
|
|
368
|
+
p0, v0 = coord((axis, 0))
|
|
369
|
+
p1, v1 = coord((axis, 1))
|
|
370
|
+
log = self._scale_mode.get(axis, "lin") == "log"
|
|
371
|
+
return AxisCalibration(p0, v0, p1, v1, log=log)
|