As part of preparing my GSoC proposal for the overlay layer project, I have been studying the existing Cursor widget in widgets.py and the blitting infrastructure in backend_bases.py to understand the current limitations.
I built a small proof-of-concept that demonstrates the core architectural idea — separating the logical state of overlay elements from the rendering layer, so the overlay can redraw independently without triggering a full figure draw. It also demonstrates crosshair propagation across multiple shared axes, which the current Cursor widget does not support.
python
import matplotlib
matplotlib.use('TkAgg')
import matplotlib.pyplot as plt
import numpy as np
class OverlayCrosshair:
"""
Logical representation of a crosshair — backend-independent.
Stores position and visibility state only.
Rendering is handled by OverlayLayer.
"""
def __init__(self, color='red', linewidth=0.8, linestyle='--'):
self.x = None
self.y = None
self.visible = False
self.color = color
self.linewidth = linewidth
self.linestyle = linestyle
def update(self, x, y):
self.x = x
self.y = y
self.visible = True
def hide(self):
self.visible = False
class OverlayLayer:
"""
A layer that sits on top of the figure and redraws independently.
Responsibilities:
- Maintain awareness of all Axes on the figure
- Cache the background (figure without overlay elements)
- On each update: restore background + redraw only overlay elements
- Invalidate cache on resize or full redraw
"""
def __init__(self, fig):
self.fig = fig
self.canvas = fig.canvas
self._background = None
self._elements = []
self._artists = []
self.canvas.mpl_connect('draw_event', self._on_draw)
self.canvas.mpl_connect('resize_event', self._on_resize)
def add_element(self, element):
self._elements.append(element)
def _get_all_axes(self):
"""
Figure-level Axes awareness.
This is what allows the overlay to propagate
to all axes, not just the one under the cursor.
"""
return self.fig.get_axes()
def _build_artists(self):
"""
Create one pair of crosshair lines per Axes per element.
Artists are marked animated=True so they are excluded
from the normal draw cycle.
"""
for artist in self._artists:
artist.remove()
self._artists = []
for element in self._elements:
if isinstance(element, OverlayCrosshair):
for ax in self._get_all_axes():
hline = ax.axhline(
y=0,
color=element.color,
linewidth=element.linewidth,
linestyle=element.linestyle,
animated=True,
visible=False
)
vline = ax.axvline(
x=0,
color=element.color,
linewidth=element.linewidth,
linestyle=element.linestyle,
animated=True,
visible=False
)
self._artists.extend([hline, vline])
def _cache_background(self):
"""
Capture the figure WITHOUT overlay elements as a pixel buffer.
This is restored on every mouse move instead of redrawing
the full figure.
"""
for artist in self._artists:
artist.set_visible(False)
self._background = self.canvas.copy_from_bbox(self.fig.bbox)
def _on_draw(self, event):
self._build_artists()
self._cache_background()
def _on_resize(self, event):
self._background = None
def render(self):
"""
Core render loop — called on every mouse move.
Never triggers a full figure redraw.
"""
if self._background is None:
return
self.canvas.restore_region(self._background)
axes = self._get_all_axes()
artist_idx = 0
for element in self._elements:
if isinstance(element, OverlayCrosshair):
for ax in axes:
hline = self._artists[artist_idx]
vline = self._artists[artist_idx + 1]
artist_idx += 2
if element.visible and element.x is not None:
hline.set_ydata([element.y])
vline.set_xdata([element.x])
hline.set_visible(True)
vline.set_visible(True)
ax.draw_artist(hline)
ax.draw_artist(vline)
else:
hline.set_visible(False)
vline.set_visible(False)
self.canvas.blit(self.fig.bbox)
class OverlayCrosshairTool:
"""
Connects mouse events to the OverlayLayer.
In the full implementation this would be a NavigationToolbar2
Tool, toggleable from the toolbar like Pan and Zoom.
"""
def __init__(self, fig, overlay, crosshair):
self.fig = fig
self.overlay = overlay
self.crosshair = crosshair
fig.canvas.mpl_connect('motion_notify_event', self._on_move)
def _on_move(self, event):
if event.inaxes:
self.crosshair.update(event.xdata, event.ydata)
else:
self.crosshair.hide()
self.overlay.render()
# ── Demo: two shared axes, crosshair propagates to both ──────────────────────
fig, (ax1, ax2) = plt.subplots(2, 1, sharex=True)
fig.suptitle("Overlay PoC — crosshair propagates to both axes", fontsize=10)
for ax in (ax1, ax2):
for i in range(30):
ax.plot(np.random.rand(500), np.random.rand(500),
'.', markersize=2, alpha=0.3)
overlay = OverlayLayer(fig)
crosshair = OverlayCrosshair(color='red', linewidth=0.8, linestyle='--')
overlay.add_element(crosshair)
tool = OverlayCrosshairTool(fig, overlay, crosshair)
plt.tight_layout()
plt.show()
I am sharing this to get early feedback on whether this architectural direction aligns with what is envisioned for the project before I build it into my proposal.
A few specific questions:
-
Is the separation between logical element state (OverlayCrosshair) and the rendering layer (OverlayLayer) the right abstraction to build on?
-
Should the overlay layer ultimately live inside the backend itself, or is a Python-level implementation viable as a starting point?
-
For the full implementation, which backends should be prioritised first — Qt, Tk, or both?