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.
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:
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)
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 !!
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.
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
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())