About Layer tree embedded widgets and have your WM(T)S always crispy sharp

Around 2014/2015 Martin updated the whole Legend / Layermanager code in QGIS. He wrote some nice blogs about this new “Layer Tree API”: Part 1, Part 2 and Part 3 so people would better understand how “to talk PyQGIS to the Legend”.
In 2016 Martin merged some code on top of this, which would make it possible to create so called ‘Layer Tree embedded widgets’.
In the image below you see an example of this: a little opacity slider which can be used there to change the opacity of the Layer to which it is connected visible in the Layer Manager.
Such embedded widgets are inserted when you move then in the Layer Properties/Legend tab from ‘Available widgets’ to ‘Used widgets’, ONLY for the layer you are viewing the properties.
embeddedwidget_gui

I wondered why the number of usable widgets did not explode. My idea was that this opened a LOT of possibilities: refreshing a layer with a Button widget, create a layer based TimeSlider or just show extra layer information in a Label there.
Martin provided some code to add a Layer Style Combobox widget to your layers:

# code from https://github.com/qgis/QGIS/pull/3170
# fixed to run in QGIS3
from qgis.PyQt.QtWidgets import QComboBox
from qgis.core import QgsMapLayer
from qgis.gui import QgsLayerTreeEmbeddedWidgetProvider, QgsLayerTreeEmbeddedWidgetRegistry
class LayerStyleComboBox(QComboBox):
    def __init__(self, layer):
        QComboBox.__init__(self)
        self.layer = layer
        for style_name in layer.styleManager().styles():
            self.addItem(style_name)
        idx = self.findText(layer.styleManager().currentStyle())
        if idx != -1:
          self.setCurrentIndex(idx)
        self.currentIndexChanged.connect(self.on_current_changed)
    def on_current_changed(self, index):
        self.layer.styleManager().setCurrentStyle(self.itemText(index))
class LayerStyleWidgetProvider(QgsLayerTreeEmbeddedWidgetProvider):
    def __init__(self):
        QgsLayerTreeEmbeddedWidgetProvider.__init__(self)
    def id(self):
        return "style"
    def name(self):
        return "Layer style chooser"
    def createWidget(self, layer, widgetIndex):
        return LayerStyleComboBox(layer)
    def supportsLayer(self, layer):
        return True   # any layer is fine
provider = LayerStyleWidgetProvider()
QgsGui.layerTreeEmbeddedWidgetRegistry().addProvider(provider)

As soon as you run those code examples as files in the Python Console. You will have the widget now in the gui to be used for your layers.
But if you want to use the widgets from within e.g. a plugin, you can add them like this:

# the code in the 'Tile Scale Layer'-widget has more information
l = iface.mapCanvas().currentLayer()
c = int(l.customProperty("embeddedWidgets/count", 0))
l.setCustomProperty("embeddedWidgets/count", c+1)
l.setCustomProperty("embeddedWidgets/{}/id".format(c), "style")
view = iface.layerTreeView()
view.model().refreshLayerLegend(view.currentNode())
view.currentNode().setExpanded(True)

This looks like the image below. You will have a dropdown in the legend which shows you the layer-styles available for the layer. Just like you can see in the Layer Styling panel to the right:
styletreewidget
So this brought up the idea to create a ‘Tile Scale Layer’-widget for those layer.
It took me some time (and QGIS an issue, Thanks Alessandro for fixing) to get started. But when Raymond mentioned the tree widgets last week I thought to create one.
Sometimes people which use WMTS, TMS or XYZ layers as backgrounds for their maps, ask me: “Why are those images not always crispy sharp?”. I tend to answer something in line with “Those tiles have a fixed scale AND crs in which the image is crisp, but you can make QGIS go to all kind of scales and crs’s, thereby forcing QGIS to rerender/process the original image tiles, making them unsharp”. Also I point them to Juergens ‘Scale Tile Panel’ which you can find in the Panel menu.
With this slider panel you can (IF you have a current WMTS/XYZ layer active) click/snap to the exact sharp resolutions that those services provide you. Some drawbacks I found though is 1) it’s only working on the active layer and 2) it was always so huge (in my setups).
So I decided to give the “Tile Scale” a go, and after some fiddling I ended up with code below (based on Juergen’s work, with a lot comments to explain how things work):

from qgis.core import QgsMapLayer
from qgis.gui import QgsLayerTreeEmbeddedWidgetProvider, QgsLayerTreeEmbeddedWidgetRegistry
class LayerTileScaleWidget(QWidget):
    def __init__(self, layer):
        # layout the widget itself: a label, slider and checkbox
        QWidget.__init__(self)
        label = QLabel("Tile Scale")
        self.slider = QSlider(Qt.Horizontal)
        self.ckbox = QCheckBox()
        self.ckbox.setToolTip('ONLY Zoom To Fixed Scales')
        layout = QHBoxLayout()
        layout.addWidget(label)
        layout.addWidget(self.slider)
        layout.addWidget(self.ckbox)
        self.setLayout(layout)
        self.layer = layer
        self.resolutions = layer.dataProvider().property('resolutions')
        self.mapCanvas = iface.mapCanvas()
        self.slider.setRange(0, len(self.resolutions)-1)
        self.slider.setTickInterval(1)
        self.slider.setTickPosition(QSlider.TicksBothSides)
        # invert because 'highest' value == most zoomed out == 0
        self.slider.setInvertedAppearance(True)
        self.slider.setPageStep(1)
        self.slider.setTracking(False)
        self.slider.setEnabled(True)
        # set the right value of the slider now
        self.on_scale_changed(self.mapCanvas.scale())
        # connect signals
        self.mapCanvas.scaleChanged.connect(self.on_scale_changed)
        self.slider.valueChanged.connect(self.on_value_changed)
        self.ckbox.stateChanged.connect(self.on_chkbox_change)
    def on_chkbox_change(self, state):
        # force a rescaling OR not
        self.on_scale_changed(self.mapCanvas.scale())
    def on_scale_changed(self, scale):
        if len(self.resolutions)==0:
            return
        # enable or disabe the 'fixed zooms' checkbox
        self.ckbox.setVisible(self.layer.crs().srsid() == iface.mapCanvas().mapSettings().destinationCrs().srsid())
        mupp = self.mapCanvas.mapUnitsPerPixel()
        #print('Scale "{}" changed: {}, mupp = {}'.format(self.layer.name(), scale, mupp))
        r = 0
        for i in range(0, len(self.resolutions)):
            r = i
            if self.resolutions[i] > mupp:
                if i > 0 and (self.resolutions[i]-mupp > mupp-self.resolutions[i-1]):
                    r = i-1
                break
        # only do fixed zooms if the chkbox is checked AND visible
        if self.ckbox.isVisible() and self.ckbox.isChecked() and not math.isclose(self.resolutions[r], mupp, rel_tol=1e-5):
            self.mapCanvas.zoomByFactor(self.resolutions[r] / self.mapCanvas.mapUnitsPerPixel())
#            print('*** REscaling "{}" mupp = {}, self.resolutions[r] = {}'.format(self.layer.name(), mupp, self.resolutions[r]))
            return
#        else:
#            print('!!! NOT rescaling "{}" mupp = {}, self.resolutions[r] = {}'.format(self.layer.name(), mupp, self.resolutions[r]))
        self.slider.blockSignals(True)
        self.slider.setValue(r)
        self.slider.setToolTip('Z: {:.3f}\nResolution: {:.3f}\nMupp: {:.3f}'.format(self.slider.maximum() - r, self.resolutions[r], mupp))
        self.slider.blockSignals(False)
    def on_value_changed(self, value):
        if len(self.resolutions)==0:
            return
        #print('Slider Value "{}" changed: {} zoomByFactor {}'.format(self.layer.name(), value, self.resolutions[value] / self.mapCanvas.mapUnitsPerPixel()))
        self.mapCanvas.zoomByFactor(self.resolutions[value] / self.mapCanvas.mapUnitsPerPixel())
class LayerTileScaleWidgetProvider(QgsLayerTreeEmbeddedWidgetProvider):
    def __init__(self):
        QgsLayerTreeEmbeddedWidgetProvider.__init__(self)
    def id(self):
        # will be the ID of our widget
        return "tilescale"
    def name(self):
        # readable name (used in QGIS gui)
        return "Layer Tile Scale"
    def createWidget(self, layer, widgetIndex):
        return LayerTileScaleWidget(layer)
    def supportsLayer(self, layer):
        # only XYZ, WMTS and TMS layers. Hopefully catching these with the following:
        return layer.type() == QgsMapLayer.LayerType.RasterLayer and layer.dataProvider().supportsLegendGraphic()
# create the provider which will create the widget
provider = LayerTileScaleWidgetProvider()
# register it in QGIS so it pops up in the layer properties/Legend gui
QgsGui.layerTreeEmbeddedWidgetRegistry().addProvider(provider)
# try to add it to CURRENT layer
l = iface.mapCanvas().currentLayer()
# only create the widget if there IS a layer AND it is XYZ, WMTS and TMS
if l is not None and provider.supportsLayer(l):
    c = int(l.customProperty("embeddedWidgets/count", 0))
    # check IF there is already such widget for current layer
    not_yet = True
    for i in range(0, c):
        if l.customProperty("embeddedWidgets/{}/id".format(i)) == "tilescale":
            not_yet = False
    if not_yet:
        l.setCustomProperty("embeddedWidgets/count", c+1)
        l.setCustomProperty("embeddedWidgets/{}/id".format(c), "tilescale")
    # refresh the layertree / legend
    view = iface.layerTreeView()
    view.model().refreshLayerLegend(view.currentNode())
    # expand current node so the widget is visible
    view.currentNode().setExpanded(True)

tilesliderpanelandwidget
Above you can see the ‘Layer Tile Scale’ working for 2 service layers. The code is heavily based on the TileScale Panel that you see to the right in the image, which is a standard panel available for such layers.
Some notable added functions:
– the little checkbox on the right make is possible to ALWAYS snap to a available resolution. Normally you get that when you draw the slider, but now you can also drag a box in the map and the widget will then snap to the nearest resolution (which come from the capabilties of the service)
– with checkbox the checked you will have a ‘fixed zoom level’ QGIS, that is: when you (or is done by QGIS because you docked some panel) resize your window, the scale will stay the same (so your WMTS map sharp)
– the number of ticks in the slider is (off course) depending on the number of resolutions of the service. When you hover over the slider you will see the Z-value and the Resolution and the Mupp (Map Units Per Pixel). When you have the checkbox checked those will always be the same (as we snap to it). But if you unchecked it, you are free to choose the scale/resolution you want and those values will differ
– note that the widget in the top layer does not show the checkbox. That is because that layer is retrieved in another Coordinate Reference System (crs) then the project itself is. The issue is that you can load several layers, and these can have several crs’s. So having the ‘snap to’ checkbox checked you will have a never ending hurricane of ‘snap to’-zooms, then ‘snap away’-zooms from a resolution in another layer etc etc.
Now the question is what to do with this: should I add a small QGIS plugin with this code, which could registre itself when QGIS is started? Or should I just leave it as is, so people can just run the code in the Python Console. Please let me know if you have idea’s about this or about the code above or just idea;s 🙂 .
One Idea which I want to pitch here: Nyall spoke about making QGIS-layers ‘time-aware’. This would be nice option to create small ‘time-filter’ widgets for indivindual layers…
Happy QGIS’sing and PyQGIS yourself !!

Join the Conversation

3 Comments

  1. A plugin would be greatly appreciated and would help me from time to time! Additionally, for a Qgis Grandpa like me, I would go for the plugin anytime over the Python Console just out of usability.

  2. I’d love to see this as a plugin! Also thanks for the background on the embedded widgets, I hope this spurs on some development of more plugins using them

  3. Bij gebruik van bovenstaande code liep ik tegen wat problemen op. Uiteindelijk gebruik ik nu deze code die vooralsnog probleemloos loopt:

    def __init__(self, layer):
    # layout the widget itself: a label, slider and checkbox
    QWidget.__init__(self)
    label = QLabel("Zoomniveau")
    self.slider = QSlider(Qt.Horizontal)
    self.ckbox = QCheckBox()
    self.ckbox.setToolTip("Zoom alleen naar beschikbare niveau's")
    layout = QHBoxLayout()
    layout.addWidget(label)
    layout.addWidget(self.slider)
    layout.addWidget(self.ckbox)
    self.setLayout(layout)
    self.layer = layer
    self.resolutions = layer.dataProvider().property('resolutions')
    self.mapCanvas = iface.mapCanvas()
    self.slider.setRange(0, len(self.resolutions)-1)
    self.slider.setTickInterval(1)
    self.slider.setTickPosition(QSlider.TicksBothSides)
    # invert because 'highest' value == most zoomed out == 0
    self.slider.setInvertedAppearance(True)
    self.slider.setPageStep(1)
    self.slider.setTracking(False)
    self.slider.setEnabled(True)
    # set the right value of the slider now
    self.on_scale_changed(self.mapCanvas.scale())
    # connect signals
    self.mapCanvas.scaleChanged.connect(self.on_scale_changed)
    self.slider.valueChanged.connect(self.on_value_changed)
    self.ckbox.stateChanged.connect(self.on_chkbox_change)
    def on_chkbox_change(self, state):
    # force a rescaling OR not
    self.on_scale_changed(self.mapCanvas.scale())
    def on_scale_changed(self, scale):
    if len(self.resolutions)==0:
    return
    try:
    # enable or disabe the 'fixed zooms' checkbox
    self.ckbox.setVisible(self.layer.crs().srsid() == \
    iface.mapCanvas().mapSettings().destinationCrs().srsid())
    mupp = self.mapCanvas.mapUnitsPerPixel()
    r = 0
    for i in range(0, len(self.resolutions)):
    r = i
    if self.resolutions[i] > mupp:
    if i > 0 \
    and (self.resolutions[i]-mupp > mupp-self.resolutions[i-1]):
    r = i-1
    break
    # only do fixed zooms if the chkbox is checked AND visible
    if self.ckbox.isVisible() and self.ckbox.isChecked() \
    and not math.isclose(self.resolutions[r], mupp, rel_tol=1e-5):
    self.mapCanvas.zoomByFactor(self.resolutions[r] / \
    self.mapCanvas.mapUnitsPerPixel())
    return
    self.slider.blockSignals(True)
    self.slider.setValue(r)
    self.slider.setToolTip('Z: {:.3f}\nResolution: {:.3f}'.\
    format(self.slider.maximum() - r, self.resolutions[r]))
    self.slider.blockSignals(False)
    except:
    pass
    def on_value_changed(self, value):
    if len(self.resolutions)==0:
    return
    self.mapCanvas.zoomByFactor(self.resolutions[value] / \
    self.mapCanvas.mapUnitsPerPixel())

Leave a comment

Your email address will not be published. Required fields are marked *