Source code for radioviz.geometry.line_selector

#  Copyright 2025–2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""
Interactive line selector for matplotlib plots.

This module provides functionality to create and manipulate interactive
line selectors on matplotlib axes. It allows users to draw lines, edit
their endpoints, and snap to specific angles during interaction.
"""

from __future__ import annotations

import math
from dataclasses import dataclass
from typing import Any, Literal, Optional, cast

import numpy as np
from matplotlib.axes import Axes
from matplotlib.backend_bases import KeyEvent, MouseEvent, PickEvent
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
from matplotlib.lines import Line2D
from matplotlib.patches import FancyArrowPatch
from numpy._typing import NDArray
from PySide6.QtCore import QObject, Qt, Signal
from PySide6.QtWidgets import QApplication


[docs] @dataclass(frozen=True) class LineGeometry: """ Represents the geometric properties of an oriented line. This dataclass stores the start and end coordinates of a line segment and provides utility methods for vector operations and geometric calculations. """ start: tuple[float, float] """Start point of the line as (x, y) coordinates.""" end: tuple[float, float] """End point of the line as (x, y) coordinates."""
[docs] def as_vector(self) -> NDArray[np.float64]: """ Convert the line to a vector representation. :return: NumPy array representing the vector from start to end point :rtype: numpy.ndarray """ end_arr = np.array(self.end, dtype=float) start_arr = np.array(self.start, dtype=float) return cast(NDArray[np.float64], end_arr - start_arr)
[docs] def direction(self) -> NDArray[Any]: """ Calculate the normalized direction vector of the line. :return: Normalized direction vector :rtype: numpy.ndarray """ v = self.as_vector() n = np.linalg.norm(v) return v / n if n > 0 else v
@property def mid(self) -> tuple[float, float]: """ Calculate the midpoint of the line. :return: Midpoint coordinates as (x, y) :rtype: tuple[float, float] """ mx = (self.start[0] + self.end[0]) / 2 my = (self.start[1] + self.end[1]) / 2 return mx, my
[docs] def snap_to_angle( origin: tuple[float, float], target: tuple[float, float], step_deg: float = 45.0, ) -> tuple[float, float]: """ Snap a target point to the nearest angle multiple. Snaps the target point to the nearest angle multiple based on the specified step size, keeping the same distance from the origin. :param origin: Origin point (x, y) coordinates :type origin: tuple[float, float] :param target: Target point (x, y) coordinates :type target: tuple[float, float] :param step_deg: Angle step in degrees (default: 45.0) :type step_deg: float :return: Snapped point coordinates :rtype: tuple[float, float] """ ox, oy = origin tx, ty = target dx = tx - ox dy = ty - oy r = math.hypot(dx, dy) if r == 0: return target angle = math.atan2(dy, dx) step = math.radians(step_deg) snapped = round(angle / step) * step return ( ox + r * math.cos(snapped), oy + r * math.sin(snapped), )
[docs] class LineSelector(QObject): """ Interactive selector for an oriented line (arrow). Provides an interactive interface for creating and editing lines on matplotlib axes. Supports snapping to specific angles, visual handles for manipulation, and keyboard shortcuts. Lifecycle: idle -> creating -> editing -> cleared """ selector_created = Signal() """Signal emitted when a new selector is created.""" selector_accepted = Signal() """Signal emitted when the selector is accepted.""" selector_canceled = Signal() """Signal emitted when the selector is canceled.""" def __init__( self, ax: Axes, /, *, color: str = 'orange', linewidth: float = 2.0, handle_size: float = 8.0, picker_tol: float = 10.0, snap_deg: float = 30, arrowstyle: str = '-|>, head_width=4, head_length=8', ) -> None: """ Initialize the LineSelector. :param ax: Matplotlib axes object where the selector will be displayed :type ax: matplotlib.axes.Axes :param color: Color of the line and handles (default: 'orange') :type color: str :param linewidth: Width of the line (default: 2.0) :type linewidth: float :param handle_size: Size of the handle markers (default: 8.0) :type handle_size: float :param picker_tol: Tolerance for picking handles (default: 10.0) :type picker_tol: float :param snap_deg: Angle snapping step in degrees (default: 30) :type snap_deg: float :param arrowstyle: Matplotlib arrow style string (default: ``'-|>, head_width=4, head_length=8'``) :type arrowstyle: str """ super().__init__() self.ax = ax self.canvas = cast(FigureCanvasQTAgg, ax.figure.canvas) self._previously_focused_widget = QApplication.focusWidget() self.canvas.setFocus() self.canvas.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.color = color self.linewidth = linewidth self.handle_size = handle_size self.picker_tol = picker_tol self.snap_deg = snap_deg self.arrowstyle = arrowstyle # State self._state: Literal['idle', 'creating', 'editing'] = 'idle' self._geometry: Optional[LineGeometry] = None self._press_xy: Optional[tuple[float, float]] = None self._active_role: Optional[Literal['start', 'end', 'mid']] = None self._last_xy: Optional[tuple[float, float]] = None # Artists self._temp_arrow: Optional[FancyArrowPatch] = None self._arrow: Optional[FancyArrowPatch] = None self._h_start: Optional[Line2D] = None self._h_end: Optional[Line2D] = None self._h_mid: Optional[Line2D] = None # key modifiers self._ctrl_pressed: bool = False # Event connections self._cid_press = self.canvas.mpl_connect('button_press_event', self._on_press) self._cid_motion = self.canvas.mpl_connect('motion_notify_event', self._on_motion) self._cid_release = self.canvas.mpl_connect('button_release_event', self._on_release) self._cid_pick = self.canvas.mpl_connect('pick_event', self._on_pick) self._cid_key_press = self.canvas.mpl_connect('key_press_event', self._on_key_press)
[docs] def _restore_focus(self) -> None: """ Restore focus to the previously focused widget. """ if self._previously_focused_widget is not None: self._previously_focused_widget.setFocus() self._previously_focused_widget = None
# ------------------------------------------------------------------ # Event handlers # ------------------------------------------------------------------
[docs] def _on_press(self, event: object) -> None: """ Handle mouse press events. :param event: Mouse event data :type event: matplotlib.backend_bases.MouseEvent """ if not isinstance(event, MouseEvent): return if event.inaxes != self.ax or event.xdata is None or event.ydata is None: return if self._state == 'idle': # Start creation self._state = 'creating' self._press_xy = (event.xdata, event.ydata) self._temp_arrow = FancyArrowPatch( self._press_xy, self._press_xy, arrowstyle=self.arrowstyle, linewidth=self.linewidth, color=self.color, alpha=0.8, zorder=10, ) self.ax.add_patch(self._temp_arrow) self.canvas.draw_idle() # type: ignore[no-untyped-call] # type: ignore[no-untyped-call] elif self._state == 'editing': # pick_event will decide what we grabbed self._last_xy = (event.xdata, event.ydata)
[docs] def _on_motion(self, event: object) -> None: """ Handle mouse motion events. :param event: Mouse motion event data :type event: matplotlib.backend_bases.MouseEvent """ if not isinstance(event, MouseEvent): return if event.inaxes != self.ax or event.xdata is None or event.ydata is None: return if self._state == 'creating' and self._temp_arrow: if self._press_xy is None: return x, y = event.xdata, event.ydata if self._ctrl_pressed: x, y = snap_to_angle(self._press_xy, (x, y), self.snap_deg) self._temp_arrow.set_positions(self._press_xy, (x, y)) self.canvas.draw_idle() # type: ignore[no-untyped-call] # type: ignore[no-untyped-call] elif self._state == 'editing' and self._active_role: self._drag_edit(event)
[docs] def _on_release(self, event: object) -> None: """ Handle mouse release events. :param event: Mouse release event data :type event: matplotlib.backend_bases.MouseEvent """ if not isinstance(event, MouseEvent): return if event.inaxes != self.ax or event.xdata is None or event.ydata is None: return if self._state == 'creating' and self._temp_arrow: if self._press_xy is None: return start = self._press_xy end = (event.xdata, event.ydata) self._temp_arrow.remove() self._temp_arrow = None self._geometry = LineGeometry(start, end) self._create_artists(self._geometry) self._state = 'editing' self.selector_created.emit() self.canvas.draw_idle() # type: ignore[no-untyped-call] # type: ignore[no-untyped-call] self._active_role = None self._last_xy = None self._ctrl_pressed = False
[docs] def _on_pick(self, event: object) -> None: """ Handle pick events (clicking on handles). :param event: Pick event data :type event: matplotlib.backend_bases.PickEvent """ if not isinstance(event, PickEvent): return if self._state != 'editing': return artist = event.artist role = getattr(artist, '_ls_role', None) if role: self._active_role = role
[docs] def _on_key_press(self, event: object) -> None: """ Handle key press events. :param event: Key event data :type event: matplotlib.backend_bases.KeyEvent """ if not isinstance(event, KeyEvent): return if event.key is None: return key = event.key.lower() if key in ('control', 'ctrl'): self._ctrl_pressed = True return if key == 'escape': self.clear() return if key in ('enter', 'return'): self._on_accept() return
[docs] def _on_accept(self) -> None: """ Accept the current selection and emit the corresponding signal. """ self.selector_accepted.emit() self._restore_focus()
# ------------------------------------------------------------------ # Editing logic # ------------------------------------------------------------------
[docs] def _drag_edit(self, event: MouseEvent) -> None: """ Handle dragging during editing mode. :param event: Mouse motion event data :type event: matplotlib.backend_bases.MouseEvent """ if self._geometry is None or self._last_xy is None: return if event.xdata is None or event.ydata is None: return x, y = event.xdata, event.ydata if self._ctrl_pressed: if self._active_role == 'start': x, y = snap_to_angle(self._geometry.end, (x, y), self.snap_deg) elif self._active_role == 'end': x, y = snap_to_angle(self._geometry.start, (x, y), self.snap_deg) sx, sy = self._geometry.start ex, ey = self._geometry.end if self._active_role == 'start': new_start = (x, y) new_end = (ex, ey) elif self._active_role == 'end': new_start = (sx, sy) new_end = (x, y) elif self._active_role == 'mid': dx = x - self._last_xy[0] dy = y - self._last_xy[1] if self._ctrl_pressed: dx, dy = snap_to_angle((0, 0), (dx, dy), self.snap_deg) new_start = (sx + dx, sy + dy) new_end = (ex + dx, ey + dy) else: return self._geometry = LineGeometry(new_start, new_end) self._update_artists(self._geometry) self._last_xy = (x, y) self.canvas.draw_idle() # type: ignore[no-untyped-call] # type: ignore[no-untyped-call]
# ------------------------------------------------------------------ # Artist management # ------------------------------------------------------------------
[docs] def _create_artists(self, geom: LineGeometry) -> None: """ Create visual artists for the line and handles. :param geom: Line geometry to create artists for :type geom: LineGeometry """ self._arrow = FancyArrowPatch( geom.start, geom.end, arrowstyle=self.arrowstyle, linewidth=self.linewidth, color=self.color, zorder=10, ) self.ax.add_patch(self._arrow) self._h_start = self._make_handle(geom.start, 'start') self._h_end = self._make_handle(geom.end, 'end') self._h_mid = self._make_handle(geom.mid, 'mid')
[docs] def _update_artists(self, geom: LineGeometry) -> None: """ Update visual artists with new geometry. :param geom: Updated line geometry :type geom: LineGeometry """ if self._arrow is None or self._h_start is None or self._h_end is None or self._h_mid is None: return self._arrow.set_positions(geom.start, geom.end) self._h_start.set_data([geom.start[0]], [geom.start[1]]) self._h_end.set_data([geom.end[0]], [geom.end[1]]) self._h_mid.set_data([geom.mid[0]], [geom.mid[1]])
[docs] def _make_handle(self, xy: tuple[float, float], role: str) -> Line2D: """ Create a handle marker for the line. :param xy: Position coordinates (x, y) :type xy: tuple[float, float] :param role: Role of the handle ('start', 'end', or 'mid') :type role: str :return: Line2D handle object :rtype: matplotlib.lines.Line2D """ h = Line2D( [xy[0]], [xy[1]], marker='o', markersize=self.handle_size, # <-- points (screen space) markerfacecolor='white', markeredgecolor='black', markeredgewidth=1.5, linestyle='None', picker=self.picker_tol, zorder=10, ) setattr(h, '_ls_role', role) self.ax.add_line(h) return h
# ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ @property def geometry(self) -> Optional[LineGeometry]: """ Get the current line geometry. :return: Current line geometry or None if not set :rtype: Optional[LineGeometry] """ return self._geometry
[docs] def clear(self) -> None: """ Clear the selector and reset to idle state. Removes all visual elements and resets internal state. """ for art in (self._arrow, self._h_start, self._h_end, self._h_mid, self._temp_arrow): if art: try: art.remove() except ValueError: # it may happen that the artists are already removed pass self._geometry = None self._state = 'idle' self.canvas.draw_idle() # type: ignore[no-untyped-call] # type: ignore[no-untyped-call] self.selector_canceled.emit()
[docs] def disconnect_signals(self) -> None: """ Disconnect all event signals and restore focus. Cleans up event connections and restores previous focus state. """ self.canvas.mpl_disconnect(self._cid_press) self.canvas.mpl_disconnect(self._cid_motion) self.canvas.mpl_disconnect(self._cid_release) self.canvas.mpl_disconnect(self._cid_pick) self._restore_focus()