# 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()