# Copyright 2025–2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""
Module for managing overlays in radioviz plots.
This module provides functionality for creating, managing, and rendering overlays
on matplotlib axes. Overlays can represent various graphical elements such as
annotations, shapes, or highlights that are added to plots dynamically.
The module includes:
- Overlay models for defining overlay properties
- Factory for registering overlay renderers
- Manager for handling overlay lifecycle
- Renderer for drawing overlays on axes
"""
from __future__ import annotations
import sys
if sys.version_info >= (3, 9):
from collections.abc import Callable
else:
from typing import Callable
from copy import deepcopy
from dataclasses import dataclass, field
if sys.version_info >= (3, 11):
from enum import StrEnum
else:
from radioviz.models.legacy import StrEnum
# Use list from typing for 3.8 as well
if sys.version_info < (3, 9):
from typing import List as list
from typing import Any, Optional
from uuid import UUID
from matplotlib.artist import Artist
from matplotlib.axes import Axes
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
from matplotlib.text import Text
from PySide6.QtCore import QObject, Signal
[docs]
class OverlayRole(StrEnum):
"""
Enumeration of overlay roles.
Defines different roles that overlays can have in the visualization system.
"""
Permanent = 'permanent'
"""Permanent overlay that remains visible by default."""
Temporary = 'temporary'
"""Temporary overlay that may be removed after some time."""
Highlight = 'highlight'
"""Highlight overlay used to emphasize specific elements."""
Label = 'label'
"""Label overlay used to display the name of a specific elements"""
[docs]
@dataclass(frozen=True)
class OverlayKey:
"""
Key for identifying overlays.
Used to uniquely identify overlays based on their owner ID and role.
"""
owner_id: UUID
"""UUID of the overlay owner."""
role: OverlayRole
"""Role of the overlay."""
[docs]
@dataclass
class OverlayModel:
"""
Model representing an overlay in the visualization.
This class defines the properties of an overlay including its type, role,
geometry, style, and target axis.
"""
id: UUID
"""Unique identifier for the overlay."""
type: Optional[str] = ''
"""Type of the overlay for renderer selection."""
role: OverlayRole = OverlayRole.Permanent
"""Role of the overlay."""
label: Optional[str] = None
"""Label for the overlay."""
geometry: dict[str, Any] = field(default_factory=dict)
"""Geometry properties of the overlay."""
style: dict[str, Any] = field(default_factory=dict)
"""Style properties of the overlay."""
extra_props: dict[str, Any] = field(default_factory=dict)
"""Additional properties for the overlay."""
target_axis: Optional[str] = None
"""Target axis name where the overlay should be rendered."""
group: Optional[str] = None
"""The group to which the overlay belongs"""
visible: bool = True
"""Whether the overlay is visible or not"""
@property
def key(self) -> OverlayKey:
"""
Get the overlay key.
:return: Overlay key composed of owner ID and role
:rtype: OverlayKey
"""
return OverlayKey(self.id, self.role)
[docs]
def change_role_to(self, new_role: OverlayRole) -> OverlayModel:
"""
Create a copy of this overlay with a different role.
:param new_role: The new role for the overlay
:type new_role: OverlayRole
:return: A new overlay model with the specified role
:rtype: OverlayModel
"""
new_overlay = deepcopy(self)
new_overlay.role = new_role
return new_overlay
RendererFn = Callable[[OverlayModel, Axes], list[Artist]]
"""Type alias for overlay renderer functions."""
[docs]
def draw_text_overlay(overlay: OverlayModel, axes: Axes) -> list[Artist]:
"""
Draw a text overlay on the given matplotlib axes.
This function creates a matplotlib Text object using the geometry and style
properties from the overlay model and adds it to the specified axes.
:param overlay: The overlay model containing text properties
:type overlay: OverlayModel
:param axes: The matplotlib axes to draw the text on
:type axes: plt.Axes
:return: List containing the created Text artist
:rtype: list[plt.Artist]
"""
new_text = Text(**overlay.geometry, **overlay.style)
axes.add_artist(new_text)
return [new_text]
[docs]
@dataclass
class OverlaySpec:
"""
Specification for overlay registration.
Contains the information needed to register an overlay type with its renderer.
"""
overlay_type: str
"""Type identifier for the overlay."""
overlay_role: OverlayRole
"""Role of the overlay."""
renderer: RendererFn
"""Renderer function for this overlay type and role."""
[docs]
class OverlayFactory:
"""
Factory for creating and managing overlay renderers.
This factory maintains a registry of overlay renderers and provides
mechanisms to retrieve them based on overlay type and role.
"""
def __init__(self) -> None:
"""Initialize the overlay factory."""
self._registry: dict[str, dict[OverlayRole, RendererFn]] = {}
self.register_default_overlays()
[docs]
def register(
self,
spec: OverlaySpec | str,
overlay_role: OverlayRole | str | None = None,
renderer: RendererFn | None = None,
) -> None:
"""
Register an overlay specification.
:param spec: Overlay specification to register
:type spec: OverlaySpec
"""
if isinstance(spec, OverlaySpec):
overlay_spec = spec
else:
if overlay_role is None or renderer is None:
raise TypeError('overlay_role and renderer must be provided when registering by name')
role = overlay_role if isinstance(overlay_role, OverlayRole) else OverlayRole(overlay_role)
overlay_spec = OverlaySpec(spec, role, renderer)
self._registry.setdefault(overlay_spec.overlay_type, {})[overlay_spec.overlay_role] = overlay_spec.renderer
[docs]
def get_renderer(self, overlay_type: str, overlay_role: OverlayRole) -> RendererFn:
"""
Get the renderer function for an overlay type and role.
:param overlay_type: Type of the overlay
:type overlay_type: str
:param overlay_role: Role of the overlay
:type overlay_role: OverlayRole
:return: The renderer function for this overlay type and role
:rtype: RendererFn
:raise RuntimeError: if no renderer is registered for the given type and role
"""
try:
return self._registry[overlay_type][overlay_role]
except KeyError:
raise RuntimeError(f'Overlay role {overlay_role} for {overlay_type} not registered')
[docs]
def register_default_overlays(self) -> None:
"""
Register default overlay specifications.
Registers default overlay specifications for text overlays with all roles.
This ensures that basic text overlay functionality is available out-of-the-box.
"""
overlay_spec_list = []
text_overlays = [OverlaySpec('text', overlay_role, draw_text_overlay) for overlay_role in OverlayRole]
overlay_spec_list.extend(text_overlays)
for overlay_spec in overlay_spec_list:
self.register(overlay_spec)
[docs]
class OverlayManager(QObject):
"""
Manager for handling overlay lifecycle events.
This class manages the addition, removal, and updating of overlays,
emitting signals when these operations occur.
"""
request_to_add_overlay = Signal(str, OverlayModel)
"""Signal emitted when an overlay is added. Parameters: axis name, overlay model."""
request_to_remove_overlay = Signal(OverlayModel)
"""Signal emitted when an overlay is removed. Parameter: overlay model."""
request_to_update_overlay = Signal(OverlayModel)
"""Signal emitted when an overlay is updated. Parameter: overlay model."""
request_to_change_overlay_visibility = Signal(OverlayModel, bool)
"""Signal emitted to change the visibility of an overlay."""
request_to_toggle_overlay_visibility = Signal(OverlayModel)
"""Signal emitted to toggle the visibility of an overlay"""
def __init__(self) -> None:
"""Initialize the overlay manager."""
super().__init__()
self._overlays: dict[OverlayKey, OverlayModel] = {}
[docs]
def add(self, target_axis_name: str, overlay: OverlayModel) -> None:
"""
Add an overlay to the manager.
:param target_axis_name: Name of the target axis
:type target_axis_name: str
:param overlay: Overlay model to add
:type overlay: OverlayModel
"""
overlay.target_axis = target_axis_name
self._overlays[overlay.key] = overlay
self.request_to_add_overlay.emit(target_axis_name, overlay)
[docs]
def get_overlay(self, overlay_key: OverlayKey) -> OverlayModel | None:
"""
Retrieve an overlay by its key.
:param overlay_key: The key identifying the overlay
:type overlay_key: OverlayKey
:return: The overlay model if found, None otherwise
:rtype: OverlayModel | None
"""
return self._overlays.get(overlay_key)
[docs]
def get_overlay_by_id(self, overlay_id: UUID, role: OverlayRole = OverlayRole.Permanent) -> OverlayModel | None:
"""
Retrieve an overlay by its ID and role.
:param overlay_id: Unique identifier of the overlay
:type overlay_id: UUID
:param role: Role of the overlay to retrieve (default: Permanent)
:type role: OverlayRole
:return: The overlay model if found, None otherwise
:rtype: OverlayModel | None
"""
key = OverlayKey(overlay_id, role)
return self.get_overlay(key)
[docs]
def remove_id(self, overlay_id: UUID, role: OverlayRole = OverlayRole.Permanent) -> None:
"""
Remove an overlay by its ID and role.
:param overlay_id: Unique identifier of the overlay
:type overlay_id: UUID
:param role: Role of the overlay to remove (default: Permanent)
:type role: OverlayRole
"""
key = OverlayKey(overlay_id, role)
if key in self._overlays:
self.remove(self._overlays[key])
[docs]
def remove(self, overlay: OverlayModel) -> None:
"""
Remove an overlay from the manager.
:param overlay: Overlay model to remove
:type overlay: OverlayModel
"""
if overlay.key in self._overlays:
self._overlays.pop(overlay.key)
self.request_to_remove_overlay.emit(overlay)
[docs]
def update(self, overlay: OverlayModel) -> None:
"""
Update an existing overlay.
:param overlay: Overlay model to update
:type overlay: OverlayModel
"""
self._overlays[overlay.key] = overlay
self.request_to_update_overlay.emit(overlay)
[docs]
def set_visibility(self, overlay_id: UUID, role: OverlayRole, visible: bool) -> None:
"""
Set the visibility state of an overlay.
:param overlay_id: Unique identifier of the overlay
:type overlay_id: UUID
:param role: Role of the overlay to modify
:type role: OverlayRole
:param visible: New visibility state (True for visible, False for hidden)
:type visible: bool
"""
key = OverlayKey(overlay_id, role)
if key in self._overlays:
overlay = self._overlays[key]
if overlay.visible != visible:
overlay.visible = visible
self.request_to_change_overlay_visibility.emit(overlay, visible)
[docs]
def add_highlight(self, overlay_id: UUID) -> None:
"""
Add a highlight overlay for an existing permanent overlay.
:param overlay_id: Unique identifier of the overlay to highlight
:type overlay_id: UUID
:raise RuntimeError: if the permanent overlay does not exist
:raise ValueError: if the overlay has no target axis assigned
"""
# check if the permanent version of this overlay exists
key = OverlayKey(overlay_id, OverlayRole.Permanent)
if key not in self._overlays:
raise RuntimeError('Attempt to highlight a missing overlay')
normal_overlay = self._overlays[key]
# check if the highlight version of this overlay already exists
key = OverlayKey(overlay_id, OverlayRole.Highlight)
if key in self._overlays:
# nothing to be done
pass
else:
target_axis_name = normal_overlay.target_axis
if target_axis_name is None:
raise ValueError('Cannot highlight overlay without a target axis.')
highlight_overlay = normal_overlay.change_role_to(OverlayRole.Highlight)
self.add(target_axis_name, highlight_overlay)
[docs]
def remove_highlight(self, overlay_id: UUID) -> None:
"""
Remove a highlight overlay.
:param overlay_id: Unique identifier of the overlay to remove highlight from
:type overlay_id: UUID
"""
# check if the highlight is registered
key = OverlayKey(overlay_id, OverlayRole.Highlight)
if key in self._overlays:
self.remove(self._overlays[key])
[docs]
def exists(self, overlay_key: OverlayKey) -> bool:
"""
Check if an overlay with the given key exists in the manager.
:param overlay_key: The key identifying the overlay to check
:type overlay_key: OverlayKey
:return: True if the overlay exists, False otherwise
:rtype: bool
"""
return overlay_key in self._overlays
[docs]
def toggle_visibility(self, overlay_key: OverlayKey) -> None:
"""
Toggle the visibility state of an overlay.
Emits a signal to request toggling the visibility of the overlay
identified by the given key.
:param overlay_key: The key identifying the overlay to toggle
:type overlay_key: OverlayKey
"""
overlay = self.get_overlay(overlay_key)
if overlay is None:
return
overlay.visible = not overlay.visible
self.request_to_toggle_overlay_visibility.emit(overlay)
[docs]
def get_overlay_by_group(self, group: str) -> list[OverlayModel]:
"""
Retrieve all overlays belonging to a specific group.
This method filters overlays based on their group attribute and returns
a list of overlay models that match the specified group.
:param group: The group name to filter overlays by
:type group: str
:return: List of overlay models belonging to the specified group
:rtype: list[OverlayModel]
"""
return [v for v in self._overlays.values() if v.group == group]
[docs]
class OverlayRenderer(QObject):
"""
Renderer for drawing overlays on matplotlib axes.
This class handles the actual rendering of overlays onto matplotlib axes
using registered renderer functions.
"""
def __init__(self, canvas: FigureCanvasQTAgg, axes: list[Axes], factory: OverlayFactory) -> None:
"""
Initialize the overlay renderer.
:param canvas: Matplotlib figure canvas
:type canvas: FigureCanvasQTAgg
:param axes: List of matplotlib axes
:type axes: list[plt.Axes]
:param factory: Overlay factory for retrieving renderers
:type factory: OverlayFactory
"""
super().__init__()
self.canvas = canvas
self._factory = factory
self.axes = {getattr(a, 'radioviz_name', f'ax_{i}'): a for i, a in enumerate(axes)}
self._artists: dict[OverlayKey, list[Artist]] = {}
[docs]
def on_overlay_add(
self, axis_name: str, overlay: OverlayModel, override_role: Optional[OverlayRole] = None
) -> None:
"""
Handle adding an overlay to the plot.
:param axis_name: Name of the target axis
:type axis_name: str
:param overlay: Overlay model to add
:type overlay: OverlayModel
:param override_role: Override role for the overlay (optional)
:type override_role: Optional[OverlayRole]
"""
role = override_role or overlay.role
type_ = overlay.type
if type_ is None:
return
renderer = self._factory.get_renderer(type_, role)
artist = renderer(overlay, self.axes[axis_name])
for item in artist:
item.set_visible(overlay.visible)
self._artists[OverlayKey(overlay.id, role)] = artist
self.canvas.draw_idle() # type: ignore[no-untyped-call]
[docs]
def on_overlay_remove(self, overlay: OverlayModel, override_role: Optional[OverlayRole] = None) -> None:
"""
Handle removing an overlay from the plot.
:param overlay: Overlay model to remove
:type overlay: OverlayModel
:param override_role: Override role for the overlay (optional)
:type override_role: Optional[OverlayRole]
"""
role = override_role or overlay.role
key = OverlayKey(overlay.id, role)
# During workspace restore or rapid toggles, we may receive remove
# requests before artists are created. Ignore silently in that case.
if key not in self._artists:
return
for artist in self._artists[key]:
artist.remove()
self._artists.pop(key, None)
self.canvas.draw_idle() # type: ignore[no-untyped-call]
[docs]
def on_overlay_update(self, overlay: OverlayModel) -> None:
"""
Handle updating an overlay in the plot.
:param overlay: Overlay model to update
:type overlay: OverlayModel
:raise RuntimeError: if the overlay is not found in existing overlays
"""
# Updates can arrive before the overlay is added (e.g., workspace restore).
# If missing, treat update as an add to keep the UI in sync.
if overlay.key not in self._artists:
target_axis = overlay.target_axis
if target_axis is None:
return
self.on_overlay_add(target_axis, overlay)
return
target_axis = overlay.target_axis
if target_axis is None:
return
self.on_overlay_remove(overlay)
self.on_overlay_add(target_axis, overlay)
[docs]
def on_overlay_visibility_change(self, overlay: OverlayModel, visibility: bool) -> None:
"""
Handle changing the visibility state of an overlay.
This method updates the visibility of all artists associated with the given
overlay. If the overlay is not found in the registered artists, the method
returns without making changes.
:param overlay: The overlay model whose visibility needs to be changed
:type overlay: OverlayModel
:param visibility: New visibility state (True for visible, False for hidden)
:type visibility: bool
"""
if overlay.key not in self._artists:
return
for artist in self._artists[overlay.key]:
artist.set_visible(visibility)
self.canvas.draw_idle() # type: ignore[no-untyped-call]
[docs]
def on_overlay_visibility_toggle(self, overlay: OverlayModel) -> None:
"""
Handle toggling the visibility state of an overlay.
This method toggles the visibility of all artists associated with the given
overlay. If the overlay is not found in the registered artists, the method
returns without making changes.
:param overlay: The overlay model whose visibility needs to be toggled
:type overlay: OverlayModel
"""
if overlay.key not in self._artists:
return
for artist in self._artists[overlay.key]:
artist.set_visible(not artist.get_visible())
self.canvas.draw_idle() # type: ignore[no-untyped-call]