Building a Custom QgsMapCanvasItem Overlay
Write a QgsMapCanvasItem that follows the map: storing map coordinates and converting in paint, a bounding rectangle that prevents stale pixels,…
TL;DR: store geometry in map coordinates, convert to screen coordinates inside paint(), and return a boundingRect() in canvas pixels that generously covers everything you draw — the overlay then follows pan and zoom correctly and leaves no stale pixels behind. This page is part of the custom map canvas overlays and rendering guide.
Complete Runnable Code
"""A canvas overlay that labels a set of map positions."""
from qgis.core import QgsPointXY
from qgis.gui import QgsMapCanvas, QgsMapCanvasItem
from qgis.PyQt.QtCore import QPointF, QRectF, Qt
from qgis.PyQt.QtGui import QColor, QFont, QFontMetricsF, QPainter, QPen
class LabelledPointsItem(QgsMapCanvasItem):
"""Draws a dot and a caption at each map position.
Positions are held in map coordinates and converted at paint time, so the
overlay stays put when the user pans or zooms.
"""
PADDING = 12 # pixels of slack added to the bounding rectangle
def __init__(self, canvas: QgsMapCanvas):
super().__init__(canvas)
self._canvas = canvas
self._points: list[tuple[QgsPointXY, str]] = []
self._font = QFont()
self._font.setPointSizeF(9.0)
def set_points(self, points: list[tuple[QgsPointXY, str]]) -> None:
"""Replace the drawn set. Map coordinates in, repaint scheduled."""
self._points = list(points)
self.updatePosition()
self.update()
def paint(self, painter: QPainter, option=None, widget=None) -> None:
"""Called by the scene. Convert here, never store screen coordinates."""
if not self._points:
return
painter.setRenderHint(QPainter.Antialiasing, True)
painter.setFont(self._font)
painter.setPen(QPen(QColor(31, 97, 63), 1.5))
for map_point, label in self._points:
pos = self.toCanvasCoordinates(map_point)
painter.drawEllipse(QPointF(pos), 4.0, 4.0)
painter.drawText(QPointF(pos.x() + 8.0, pos.y() - 6.0), label)
def boundingRect(self) -> QRectF:
"""Canvas-pixel bounds of everything paint() will touch, plus slack."""
if not self._points:
return QRectF()
metrics = QFontMetricsF(self._font)
rect = QRectF()
for map_point, label in self._points:
pos = self.toCanvasCoordinates(map_point)
width = metrics.horizontalAdvance(label) + 16.0
rect = rect.united(QRectF(pos.x() - self.PADDING, pos.y() - self.PADDING,
width + self.PADDING, metrics.height() + self.PADDING))
return rect
def updatePosition(self) -> None:
"""Called when the canvas extent changes — recompute cached geometry."""
self.prepareGeometryChange()
Architecture Breakdown
paint() receives a QPainter, not a render context
Unlike a layer renderer, a canvas item paints with a plain Qt QPainter in canvas pixel coordinates. There is no QgsRenderContext, no map units, and no automatic scaling — which makes the method simple and makes the coordinate conversion entirely your responsibility.
toCanvasCoordinates() is the conversion, inherited from QgsMapCanvasItem, and it delegates to the canvas’s current QgsMapToPixel. Calling it inside paint() means every repaint uses the current view, which is exactly why the overlay follows the map.
boundingRect() is consulted far more often than paint()
Qt uses the bounding rectangle to decide whether the item intersects the region being redrawn. Returning one that is too small means Qt concludes your item cannot be affected and leaves the previous pixels on screen — the classic “smear” bug, where an old label stays visible after a pan.
Too large costs only performance, so the rectangle should include pen width, text metrics, and any glow or shadow. The PADDING constant above is deliberate slack: cheap insurance against an off-by-a-few-pixels calculation.
prepareGeometryChange() before the bounds change
Qt caches the bounding rectangle. Changing what boundingRect() would return without calling prepareGeometryChange() first leaves the scene with a stale cached region, and the symptom is again stale pixels. Calling it in updatePosition() and whenever the drawn set changes covers both cases.
Choosing Between an Item, a Rubber Band and a Layer
The canvas offers three different ways to put marks on the map, and writing a custom item is only right for one of them.
QgsRubberBand already does transient geometry — highlights, previews, measurement lines — with no code beyond feeding it points, and it handles conversion and repaint for you. Reach for a custom item when you need drawing a rubber band cannot express: text anchored to positions, custom symbology, per-vertex handles.
The third option is worth considering more often than it is. If what you are drawing is really data — results a user might want to style, query or export — a memory layer gives you all of that for free, and it participates in the map properly rather than floating above it.
Attaching and Removing It
class MyPlugin:
def initGui(self) -> None:
self.overlay = LabelledPointsItem(self.iface.mapCanvas())
def unload(self) -> None:
canvas = self.iface.mapCanvas()
if self.overlay is not None:
canvas.scene().removeItem(self.overlay) # detach before dropping
self.overlay = None
canvas.refresh()
Constructing the item with the canvas attaches it to the scene automatically; removing it is not automatic and must be done explicitly. An item left in the scene after unload keeps painting, and because the plugin that owns it is gone, there is no way for the user to remove it short of restarting QGIS.
Keeping the Overlay Cheap
An overlay is repainted far more often than most people expect — on every pan step, every zoom frame, every window resize, and whenever another item in the scene changes. That makes paint() one of the few places in a plugin where a few milliseconds are genuinely worth arguing about.
Two habits keep it manageable. Do no work in paint() that could have been done when the data changed: formatting strings, computing colours from attributes and sorting can all happen once in the setter. And bound what you draw by what is visible — an overlay holding ten thousand positions should skip the ones outside the current extent rather than converting every one of them and letting Qt clip.
If an overlay still costs more than a few milliseconds after both, the honest answer is usually that it should be a layer instead. Layers have a rendering pipeline built for scale, including caching and simplification, which a canvas item has to reimplement badly.
Production Best Practices
- Store map coordinates, convert in
paint(). Storing screen coordinates breaks on the first pan. - Over-estimate
boundingRect(). Too small leaves artefacts; too large costs a little time. - Call
prepareGeometryChange()before anything that alters the bounds. - Keep
paint()cheap. It runs on every canvas repaint, including continuously during a drag. - Remove the item from the scene in
unload(), then drop the reference. - Set an explicit
setZValue()when a plugin draws more than one overlay, so the stacking order is intentional.
Frequently Asked Questions
Why does my overlay smear when I pan?
The bounding rectangle is too small, so Qt is not invalidating everywhere you actually drew. Increase the slack, include text metrics and pen width, and make sure prepareGeometryChange() is called when the geometry changes.
A second cause is drawing outside the rectangle you reported — a label offset that pushes text past the bounds you calculated. The rectangle must cover everything paint() touches, not just the anchor points.
Why does the overlay disappear after a while?
Almost always because nothing in Python holds a reference to it. Adding an item to the scene does not create a Python reference, so an item constructed into a local variable is collected at the next garbage collection and vanishes. Keep it on the plugin for as long as it should be visible.
How do I make the overlay respond to clicks?
Canvas items can override mousePressEvent, but in practice a QgsMapTool is the better place for interaction: the tool receives canvas events with map coordinates already resolved, and it can drive the item. Keeping drawing and interaction in separate objects also makes both easier to test.
Does the overlay appear in a map export?
No. Canvas items live in the canvas scene, not in the map rendering pipeline, so a layout export or a QgsMapRendererJob does not include them. If the marks must appear in output, they need to be a layer or drawn into the layout separately.
How do I draw in map units rather than pixels?
Convert two map positions and use the distance between them, rather than assuming a scale. A circle of “50 metres” is drawn by converting the centre and a point 50 metres away and using the pixel distance between them as the radius, which then stays correct through every zoom.
Should I cache the converted coordinates?
For a handful of points, no — the conversion is cheap and caching adds a staleness problem. For thousands of vertices it is worth it, with the cache rebuilt in updatePosition() when the extent changes rather than on every paint. That is the difference between a smooth drag and a stuttering one.
Related
- Custom Map Canvas Overlays and Rendering — the parent guide covering the canvas rendering contract
- Drawing Temporary Geometries with QgsRubberBand — the simpler option for transient shapes
- Plugin Lifecycle and Resource Management — removing canvas items during teardown