"""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 def limits(self, left: float, right: float, bottom: float, top: float) -> "Bounds": """Validate absolute axis limits in the shared core.""" return Bounds(*self.call(8, left, right, bottom, top)) @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 validated(self, core: PlotMath) -> "Bounds": return core.limits(self.left, self.right, self.bottom, self.top) 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