Basemap-Legende: Auswertung und Legendenelement im Drucklayout (#88)

Beim Erstellen einer §38-/§41-Vorlage wertet BasemapLegende die Kacheln des sichtbaren
basemap.de-Layers im Verfahrensgebiet (wahlweise Druckbereich) kachelweise über
selectByGeometry aus, prüft die Renderer-Styles mit derselben Zoomlogik wie QGIS und
bildet daraus Legendeneinträge. Ein Mehrfachauswahl-Dialog (neu: ask_mehrfachauswahl)
lässt Kategorien abwählen; die Auswahl wird im Projekt gemerkt. Die gewählten Einträge
stehen mit der Signatur des Projektlayers (zoomabhängige Werte festgeschrieben) in
einem eigenen Legendenelement über der Quellenangabe. Ein eigenes Element ist nötig,
weil die nach Karteninhalt filternde Hauptlegende die Hilfslayer ausblenden würde.

Wrapper um die nötigen QGIS-/Qt-Symbole mit Mock ergänzt (eigener Block in
qgiscore_wrapper; QgsRectangle hatte bisher keinen Mock).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 11:57:09 +02:00
co-authored by Claude Opus 5.5
parent 7408740dfe
commit b549cb8a5a
11 changed files with 2223 additions and 0 deletions
+192
View File
@@ -0,0 +1,192 @@
"""
sn_basis/modules/basemap_kacheln.py – Kacheln des Basemap-Layers auswerten
Liest die Renderer-Styles eines Vektorkachel-Layers, rechnet Maßstab in Zoomstufen um wie der
QGIS-Renderer und sammelt die Objekte eines Auswertebereichs kachelweise über
``QgsVectorTileLayer.selectByGeometry``. Teil der Basemap-Legende (``basemap_legende``).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable
from sn_basis.functions.qgiscore_wrapper import (
QgsExpression,
QgsExpressionContext,
QgsExpressionContextScope,
QgsGeometry,
QgsRectangle,
QgsSelectionContext,
QgsTileXYZ,
SELECT_BEHAVIOR_SET,
SELECT_RELATION_INTERSECT,
)
_KACHELFELDER = ("tile_zoom", "tile_layer")
def _aufruf(objekt: Any, methode: str, standard: Any = None) -> Any:
funktion = getattr(objekt, methode, None)
if not callable(funktion):
return standard
try:
return funktion()
except Exception:
return standard
@dataclass(frozen=True)
class StilInfo:
"""Renderer-Style des Basemap-Layers in QGIS-unabhängiger Form."""
name: str
layer_name: str
geometrie: int # 0 Punkt, 1 Linie, 2 Fläche
min_zoom: int = -1
max_zoom: int = -1
aktiv: bool = True
filter: str = ""
@dataclass
class ObjektInfo:
"""Ein (entdoppeltes) Objekt aus den Kacheln des Auswertebereichs."""
layer_name: str
geometrie: int
attribute: dict
merkmal: Any = None # QgsFeature für die Auswertung der Filterausdrücke in QGIS
def _enum_int(wert: Any) -> int:
try:
return int(wert)
except (TypeError, ValueError):
return int(getattr(wert, "value", -1))
def stile_aus_layer(layer: Any) -> list[StilInfo]:
"""Liest die Renderer-Styles eines Vektorkachel-Layers."""
renderer = _aufruf(layer, "renderer")
stile = _aufruf(renderer, "styles", []) or []
return [
StilInfo(
name=s.styleName(),
layer_name=s.layerName(),
geometrie=_enum_int(s.geometryType()),
min_zoom=s.minZoomLevel(),
max_zoom=s.maxZoomLevel(),
aktiv=s.isEnabled(),
filter=s.filterExpression(),
)
for s in stile
]
def zoomstufen(layer: Any, massstab: float) -> tuple[int, int, float]:
"""Kachel-, Darstellungs- und gebrochene Zoomstufe wie im QGIS-Vektorkachel-Renderer.
``zoom_level`` = ``scaleToZoomLevel(maßstab, False)`` (nicht auf die Kachelstufen begrenzt),
``vector_tile_zoom`` = ``scaleToZoom(maßstab)``. Für Karten in EPSG:25833 entspricht der
Kachelmaßstab dem Kartenmaßstab (geprüft mit ``calculateTileScaleForMap``, QGIS 3.44).
"""
matrix = layer.tileMatrixSet()
return (
matrix.scaleToZoomLevel(massstab),
matrix.scaleToZoomLevel(massstab, False),
matrix.scaleToZoom(massstab),
)
def qgis_filterpruefer(vector_tile_zoom: float, zoom_level: int) -> Callable[[str, ObjektInfo], bool]:
"""Liefert eine Filterprüfung, die QGIS-Ausdrücke wie der Renderer auswertet."""
kontext = QgsExpressionContext()
scope = QgsExpressionContextScope()
scope.setVariable("vector_tile_zoom", vector_tile_zoom)
scope.setVariable("zoom_level", zoom_level)
kontext.appendScope(scope)
ausdruecke: dict[str, Any] = {}
def pruefe(filter_text: str, objekt: ObjektInfo) -> bool:
ausdruck = ausdruecke.get(filter_text)
if ausdruck is None:
ausdruck = QgsExpression(filter_text)
ausdruck.prepare(kontext)
ausdruecke[filter_text] = ausdruck
kontext.setFeature(objekt.merkmal)
return bool(ausdruck.evaluate(kontext))
return pruefe
def _objekt_schluessel(merkmal: Any) -> tuple:
werte = []
for feld in merkmal.fields():
name = feld.name()
if name not in _KACHELFELDER:
werte.append((name, str(merkmal[name])))
return merkmal["tile_layer"], _enum_int(merkmal.geometry().type()), tuple(sorted(werte))
def sammle_objekte(
layer: Any,
geometrie: Any,
massstab: float,
*,
fortschritt: Callable[[int, int], bool] | None = None,
) -> list[ObjektInfo] | None:
"""Wertet die Kacheln des Basemap-Layers im Auswertebereich aus.
``geometrie`` liegt im KBS des Layers (EPSG:3857). Ausgewertet wird kachelweise über
``selectByGeometry``, damit Fortschritt und Abbruch möglich sind. Gleiche Objekte (gleicher
Quell-Layer, Geometrietyp und Attribute) werden entdoppelt. Eine vorhandene Auswahl auf dem
Basemap-Layer wird dabei aufgehoben.
Returns
-------
list[ObjektInfo] | None
``None``, wenn der Nutzer abgebrochen hat.
"""
matrix_set = layer.tileMatrixSet()
zoom = matrix_set.scaleToZoomLevel(massstab)
matrix = matrix_set.tileMatrix(zoom)
bereich = matrix.tileRangeFromExtent(geometrie.boundingBox())
kacheln = [
(spalte, zeile)
for spalte in range(bereich.startColumn(), bereich.endColumn() + 1)
for zeile in range(bereich.startRow(), bereich.endRow() + 1)
]
objekte: dict[tuple, ObjektInfo] = {}
try:
for nummer, (spalte, zeile) in enumerate(kacheln, start=1):
if fortschritt is not None and not fortschritt(nummer, len(kacheln)):
return None
rechteck = matrix.tileExtent(QgsTileXYZ(spalte, zeile, zoom))
rand = rechteck.width() * 1e-6
innen = QgsRectangle(rechteck.xMinimum() + rand, rechteck.yMinimum() + rand,
rechteck.xMaximum() - rand, rechteck.yMaximum() - rand)
teil = geometrie.intersection(QgsGeometry.fromRect(innen))
if teil is None or teil.isEmpty():
continue
kontext = QgsSelectionContext()
kontext.setScale(massstab)
layer.selectByGeometry(teil, kontext, SELECT_BEHAVIOR_SET, SELECT_RELATION_INTERSECT)
for merkmal in layer.selectedFeatures():
schluessel = _objekt_schluessel(merkmal)
if schluessel not in objekte:
objekte[schluessel] = ObjektInfo(
layer_name=schluessel[0],
geometrie=schluessel[1],
attribute=dict(schluessel[2]),
merkmal=merkmal,
)
finally:
_aufruf(layer, "removeSelection")
return list(objekte.values())
def anzahl_kacheln(layer: Any, geometrie: Any, massstab: float) -> int:
matrix_set = layer.tileMatrixSet()
zoom = matrix_set.scaleToZoomLevel(massstab)
bereich = matrix_set.tileMatrix(zoom).tileRangeFromExtent(geometrie.boundingBox())
return max(0, bereich.endColumn() - bereich.startColumn() + 1) * max(0, bereich.endRow() - bereich.startRow() + 1)