Files
templates/python/protocan/plot.py

181 lines
6.7 KiB
Python

"""Plot interaction port. All numerical operations use templates' set_plot.c.
This module has no Qt dependency. The application supplies its SETProtocol CDLL.
Units, clocks, acquisition, colours and rendering belong to the application.
"""
from __future__ import annotations
import ctypes
from dataclasses import dataclass, replace
from enum import IntEnum
from typing import Optional
class Axis(IntEnum):
X = 1
Y = 2
class Marker(IntEnum):
A = 0
B = 1
C = 2
D = 3
E = 4
F = 5
G = 6
H = 7
@property
def horizontal(self) -> bool:
return self in (Marker.E, Marker.F, Marker.G, Marker.H)
class PlotMath:
"""Typed operations over the versioned allocation-free C ABI."""
def __init__(self, library: ctypes.CDLL) -> None:
self.library = library
library.set_plot_abi_version.restype = ctypes.c_uint32
library.set_plot_abi_version.argtypes = []
if library.set_plot_abi_version() != 1:
raise RuntimeError("Unsupported plot ABI")
library.set_plot_eval.argtypes = [ctypes.c_uint32, ctypes.POINTER(ctypes.c_double),
ctypes.c_size_t, ctypes.POINTER(ctypes.c_double), ctypes.c_size_t]
library.set_plot_eval.restype = ctypes.c_size_t
def call(self, operation: int, *values: float) -> tuple:
inputs = (ctypes.c_double * len(values))(*values)
output = (ctypes.c_double * 4)()
count = self.library.set_plot_eval(operation, inputs, len(values), output, 4)
if not count:
raise ValueError("Invalid plot operation %s" % operation)
return tuple(output[:count])
def pinch_axis(self, dx: float, dy: float, slop: float) -> Optional[Axis]:
value = int(self.call(1, dx, dy, slop)[0])
return Axis(value) if value else None
def tick_step(self, span: float, pixels: float) -> float:
return self.call(5, span, pixels)[0]
def delta(self, a: float, b: float, multiplier: float = 1) -> float:
return self.call(6, a, b, multiplier)[0]
def db_delta(self, a: float, b: float) -> Optional[float]:
try:
return self.call(7, a, b)[0]
except ValueError:
return None
@dataclass(frozen=True)
class Viewport:
x: float = 0.0
y: float = 0.0
width: float = 1.0
height: float = 1.0
locked: bool = False
def transform(self, core: PlotMath, zoom_x: float = 1, zoom_y: float = 1,
pan_x: float = 0, pan_y: float = 0,
focus_x: float = 0.5, focus_y: float = 0.5) -> "Viewport":
if self.locked:
return self
return Viewport(*core.call(0, self.x, self.y, self.width, self.height,
zoom_x, zoom_y, pan_x, pan_y, focus_x, focus_y), locked=self.locked)
@dataclass(frozen=True)
class Bounds:
left: float
right: float
bottom: float
top: float
def fraction(self, core: PlotMath, value: float, horizontal: bool) -> float:
return core.call(2, value, self.bottom if horizontal else self.left,
self.top if horizontal else self.right, int(horizontal))[0]
def value(self, core: PlotMath, fraction: float, horizontal: bool) -> float:
return core.call(3, fraction, self.bottom if horizontal else self.left,
self.top if horizontal else self.right, int(horizontal))[0]
def visible(self, core: PlotMath, viewport: Viewport) -> "Bounds":
return Bounds(self.value(core, viewport.x, False),
self.value(core, viewport.x + viewport.width, False),
self.value(core, viewport.y + viewport.height, True),
self.value(core, viewport.y, True))
@dataclass(frozen=True)
class Markers:
x_enabled: bool = True
y_enabled: bool = False
x_pairs: int = 1
y_pairs: int = 1
selected: Marker = Marker.A
a: Optional[float] = None
b: Optional[float] = None
c: Optional[float] = None
d: Optional[float] = None
e: Optional[float] = None
f: Optional[float] = None
g: Optional[float] = None
h: Optional[float] = None
def __post_init__(self) -> None:
if self.x_pairs not in (1, 2) or self.y_pairs not in (1, 2):
raise ValueError("Marker pair count must be 1 or 2")
def value(self, marker: Marker) -> Optional[float]:
return getattr(self, marker.name.lower())
def enabled(self, marker: Marker) -> bool:
if marker in (Marker.A, Marker.B):
return self.x_enabled
if marker in (Marker.C, Marker.D):
return self.x_enabled and self.x_pairs >= 2
if marker in (Marker.E, Marker.F):
return self.y_enabled
return self.y_enabled and self.y_pairs >= 2
def move(self, marker: Marker, value: float) -> "Markers":
return replace(self, **{marker.name.lower(): value})
def positioned(self, core: PlotMath, bounds: Bounds) -> "Markers":
result = self
for marker, fraction in ((Marker.A, .2), (Marker.B, .4), (Marker.C, .6), (Marker.D, .8),
(Marker.E, .2), (Marker.F, .4), (Marker.G, .6), (Marker.H, .8)):
if result.value(marker) is None:
result = result.move(marker, bounds.value(core, fraction, marker.horizontal))
return result
def reset(self, core: PlotMath, bounds: Bounds) -> "Markers":
return replace(self, a=None, b=None, c=None, d=None, e=None, f=None, g=None, h=None).positioned(core, bounds)
def fraction(self, core: PlotMath, marker: Marker, bounds: Bounds) -> float:
value = self.value(marker)
if value is None:
raise ValueError("Marker has no position")
return bounds.fraction(core, value, marker.horizontal)
def drag(self, core: PlotMath, marker: Marker, delta: float, length: float, bounds: Bounds) -> "Markers":
value = core.call(4, self.value(marker), delta, length,
bounds.bottom if marker.horizontal else bounds.left,
bounds.top if marker.horizontal else bounds.right, int(marker.horizontal))[0]
return replace(self.move(marker, value), selected=marker)
def hit(self, core: PlotMath, x: float, y: float, width: float, height: float,
radius: float, bounds: Bounds) -> Optional[Marker]:
if not (0 <= x <= width and 0 <= y <= height):
return None
candidates = []
for marker in Marker:
if not self.enabled(marker) or self.value(marker) is None:
continue
fraction = self.fraction(core, marker, bounds)
distance = abs(y - fraction * height if marker.horizontal else x - fraction * width)
if 0 <= fraction <= 1 and distance <= radius:
candidates.append((distance, marker != self.selected, marker))
return min(candidates)[2] if candidates else None