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
+110
View File
@@ -409,3 +409,113 @@ def create_progress_dialog(total: int, title: str = "Fortschritt", label: str =
"""von Test abgesehen, weil vernachlässigbar; HD 18.09.2026"""
return ProgressDialog(total, title, label)
def ask_mehrfachauswahl(
titel: str,
hinweis: str,
eintraege: list[dict],
vorauswahl: set[str] | list[str],
parent: Any = None,
) -> Optional[list[str]]:
"""Zeigt eine Liste mit Häkchen, in der Einträge an- und abgewählt werden.
Parameters
----------
eintraege :
Dicts mit ``schluessel`` und ``text``, optional ``gruppe`` (Zwischenüberschrift; Einträge
einer Gruppe müssen aufeinander folgen) und ``icon`` (z. B. Symbolvorschau).
vorauswahl :
Schlüssel der anfangs angehakten Einträge.
Returns
-------
list[str] | None
Schlüssel der angehakten Einträge in Listenreihenfolge, ``None`` bei Abbruch.
Im Mock-Modus die Vorauswahl.
"""
vorauswahl = set(vorauswahl)
if QT_VERSION == 0: # Mock-Modus
return [e["schluessel"] for e in eintraege if e["schluessel"] in vorauswahl]
from sn_basis.functions.qt_wrapper import (
QDialog, QDialogButtonBox, QFont, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton,
QVBoxLayout, DialogAccepted, ItemIsEnabled, ItemIsUserCheckable, CheckStateChecked,
CheckStateUnchecked, UserRole,
)
dialog = QDialog(parent)
dialog.setWindowTitle(titel)
layout = QVBoxLayout(dialog)
beschriftung = QLabel(hinweis)
beschriftung.setWordWrap(True)
layout.addWidget(beschriftung)
suche = QLineEdit()
suche.setPlaceholderText("Suche …")
layout.addWidget(suche)
liste = QListWidget()
kopf_schrift = QFont()
kopf_schrift.setBold(True)
zeilen: list[tuple[Any, dict]] = []
koepfe: dict[str, Any] = {}
letzte_gruppe = None
for eintrag in eintraege:
gruppe = eintrag.get("gruppe")
if gruppe and gruppe != letzte_gruppe:
kopf = QListWidgetItem(gruppe)
kopf.setFlags(ItemIsEnabled)
kopf.setFont(kopf_schrift)
liste.addItem(kopf)
koepfe[gruppe] = kopf
letzte_gruppe = gruppe
zeile = QListWidgetItem(eintrag["text"])
if eintrag.get("icon") is not None:
zeile.setIcon(eintrag["icon"])
zeile.setFlags(ItemIsUserCheckable | ItemIsEnabled)
zeile.setCheckState(CheckStateChecked if eintrag["schluessel"] in vorauswahl else CheckStateUnchecked)
zeile.setData(UserRole, eintrag["schluessel"])
liste.addItem(zeile)
zeilen.append((zeile, eintrag))
layout.addWidget(liste, 1)
def _filtern(text: str) -> None:
suchtext = text.strip().lower()
sichtbare_gruppen = set()
for zeile, eintrag in zeilen:
treffer = not suchtext or suchtext in eintrag["text"].lower() \
or suchtext in (eintrag.get("gruppe") or "").lower()
zeile.setHidden(not treffer)
if treffer:
sichtbare_gruppen.add(eintrag.get("gruppe"))
for gruppe, kopf in koepfe.items():
kopf.setHidden(gruppe not in sichtbare_gruppen)
def _alle_setzen(zustand: Any) -> None:
for zeile, _ in zeilen:
if not zeile.isHidden():
zeile.setCheckState(zustand)
suche.textChanged.connect(_filtern)
knopfzeile = QHBoxLayout()
alle = QPushButton("Alle auswählen")
alle.clicked.connect(lambda *_: _alle_setzen(CheckStateChecked))
keine = QPushButton("Keine auswählen")
keine.clicked.connect(lambda *_: _alle_setzen(CheckStateUnchecked))
knopfzeile.addWidget(alle)
knopfzeile.addWidget(keine)
knopfzeile.addStretch(1)
layout.addLayout(knopfzeile)
knoepfe = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
knoepfe.accepted.connect(dialog.accept)
knoepfe.rejected.connect(dialog.reject)
layout.addWidget(knoepfe)
dialog.resize(460, 560)
if exec_dialog(dialog) != DialogAccepted:
return None
return [eintrag["schluessel"] for zeile, eintrag in zeilen if zeile.checkState() == CheckStateChecked]
+255
View File
@@ -872,6 +872,261 @@ except Exception:
QgsJsonExporter = _MockQgsJsonExporter
# ---------------------------------------------------------
# Vektorkacheln, Ausdrücke, Symbole und Legende (Basemap-Legende, Basis #88)
# Eigener Block, damit ein fehlendes Symbol den übrigen Wrapper nicht in den Mock-Modus zwingt.
# ---------------------------------------------------------
QgsVectorTileLayer: Type[Any]
QgsSelectionContext: Type[Any]
QgsTileXYZ: Type[Any]
QgsExpression: Type[Any]
QgsExpressionContext: Type[Any]
QgsExpressionContextScope: Type[Any]
QgsProperty: Type[Any]
QgsCategorizedSymbolRenderer: Type[Any]
QgsRendererCategory: Type[Any]
QgsLegendRenderer: Type[Any]
QgsLegendStyle: Type[Any]
QgsMapLayer: Type[Any]
QgsApplication: Type[Any]
QgsSymbolLayerUtils: Type[Any]
QgsLayoutMeasurement: Type[Any]
QgsLayoutUtils: Type[Any]
SELECT_BEHAVIOR_SET: Any = None
SELECT_RELATION_INTERSECT: Any = None
PROPERTY_TYPE_EXPRESSION: Any = None
LEGEND_STYLE_HIDDEN: Any = None
LEGEND_STYLE_SUBGROUP: Any = None
STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG: Any = None
VECTOR_TILES_AVAILABLE = False
try:
from qgis.core import (
QgsVectorTileLayer as _QgsVectorTileLayer,
QgsSelectionContext as _QgsSelectionContext,
QgsTileXYZ as _QgsTileXYZ,
QgsExpression as _QgsExpression,
QgsExpressionContext as _QgsExpressionContext,
QgsExpressionContextScope as _QgsExpressionContextScope,
QgsProperty as _QgsProperty,
QgsCategorizedSymbolRenderer as _QgsCategorizedSymbolRenderer,
QgsRendererCategory as _QgsRendererCategory,
QgsLegendRenderer as _QgsLegendRenderer,
QgsLegendStyle as _QgsLegendStyle,
QgsMapLayer as _QgsMapLayer,
QgsApplication as _QgsApplication,
QgsSymbolLayerUtils as _QgsSymbolLayerUtils,
QgsLayoutMeasurement as _QgsLayoutMeasurement,
QgsLayoutUtils as _QgsLayoutUtils,
Qgis as _QgisVT,
)
QgsVectorTileLayer = _QgsVectorTileLayer
QgsSelectionContext = _QgsSelectionContext
QgsTileXYZ = _QgsTileXYZ
QgsExpression = _QgsExpression
QgsExpressionContext = _QgsExpressionContext
QgsExpressionContextScope = _QgsExpressionContextScope
QgsProperty = _QgsProperty
QgsCategorizedSymbolRenderer = _QgsCategorizedSymbolRenderer
QgsRendererCategory = _QgsRendererCategory
QgsLegendRenderer = _QgsLegendRenderer
QgsLegendStyle = _QgsLegendStyle
QgsMapLayer = _QgsMapLayer
QgsApplication = _QgsApplication
QgsSymbolLayerUtils = _QgsSymbolLayerUtils
QgsLayoutMeasurement = _QgsLayoutMeasurement
QgsLayoutUtils = _QgsLayoutUtils
SELECT_BEHAVIOR_SET = _QgisVT.SelectBehavior.SetSelection
SELECT_RELATION_INTERSECT = _QgisVT.SelectGeometryRelationship.Intersect
PROPERTY_TYPE_EXPRESSION = getattr(getattr(_QgsProperty, "Type", None), "ExpressionBasedProperty", None)
if PROPERTY_TYPE_EXPRESSION is None:
PROPERTY_TYPE_EXPRESSION = getattr(_QgsProperty, "ExpressionBasedProperty", None)
LEGEND_STYLE_HIDDEN = getattr(getattr(_QgsLegendStyle, "Style", _QgsLegendStyle), "Hidden", None)
LEGEND_STYLE_SUBGROUP = getattr(getattr(_QgsLegendStyle, "Style", _QgsLegendStyle), "Subgroup", None)
_kategorie = getattr(_QgsMapLayer, "StyleCategory", _QgsMapLayer)
STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG = _kategorie.Symbology | _kategorie.Labeling
VECTOR_TILES_AVAILABLE = True
except Exception:
VECTOR_TILES_AVAILABLE = False
class _MockQgsVectorTileLayer:
"""Platzhalter: Vektorkachel-Layer gibt es im Mock-Modus nicht."""
class _MockQgsSelectionContext:
def __init__(self):
self._scale = 0.0
def setScale(self, scale: float) -> None:
self._scale = scale
def scale(self) -> float:
return self._scale
class _MockQgsTileXYZ:
def __init__(self, column: int = 0, row: int = 0, zoom: int = 0):
self._column, self._row, self._zoom = column, row, zoom
def column(self) -> int:
return self._column
def row(self) -> int:
return self._row
def zoomLevel(self) -> int:
return self._zoom
class _MockQgsExpression:
def __init__(self, text: str = ""):
self._text = text
def prepare(self, context: Any) -> bool:
return True
def evaluate(self, context: Any = None) -> Any:
return True
class _MockQgsExpressionContextScope:
def __init__(self, name: str = ""):
self._variables: dict[str, Any] = {}
def setVariable(self, name: str, value: Any, *args: Any) -> None:
self._variables[name] = value
def variable(self, name: str) -> Any:
return self._variables.get(name)
class _MockQgsExpressionContext:
def __init__(self):
self._scopes: list[Any] = []
self._feature: Any = None
def appendScope(self, scope: Any) -> None:
self._scopes.append(scope)
def setFeature(self, feature: Any) -> None:
self._feature = feature
class _MockQgsProperty:
def __init__(self, value: Any = None):
self._value = value
@staticmethod
def fromValue(value: Any) -> "_MockQgsProperty":
return _MockQgsProperty(value)
class _MockQgsRendererCategory:
def __init__(self, value: Any = None, symbol: Any = None, label: str = ""):
self._value, self._symbol, self._label = value, symbol, label
def value(self) -> Any:
return self._value
def symbol(self) -> Any:
return self._symbol
def label(self) -> str:
return self._label
class _MockQgsCategorizedSymbolRenderer:
def __init__(self, attribute: str = "", categories: list | None = None):
self._attribute = attribute
self._categories = list(categories or [])
def categories(self) -> list:
return list(self._categories)
class _MockQgsLegendStyle:
Hidden = 0
Group = 1
Subgroup = 2
class _MockQgsLegendRenderer:
@staticmethod
def setNodeLegendStyle(node: Any, style: Any) -> None:
setattr(node, "_legend_style", style)
class _MockQgsMapLayer:
class StyleCategory:
Symbology = 1
Labeling = 2
class _MockQgsApplication:
@staticmethod
def qgisSettingsDirPath() -> str:
return ""
class _MockQgsSymbolLayerUtils:
@staticmethod
def symbolPreviewIcon(symbol: Any, size: Any, *args: Any) -> Any:
return None
QgsVectorTileLayer = _MockQgsVectorTileLayer
QgsSelectionContext = _MockQgsSelectionContext
QgsTileXYZ = _MockQgsTileXYZ
QgsExpression = _MockQgsExpression
QgsExpressionContext = _MockQgsExpressionContext
QgsExpressionContextScope = _MockQgsExpressionContextScope
QgsProperty = _MockQgsProperty
QgsCategorizedSymbolRenderer = _MockQgsCategorizedSymbolRenderer
QgsRendererCategory = _MockQgsRendererCategory
QgsLegendRenderer = _MockQgsLegendRenderer
QgsLegendStyle = _MockQgsLegendStyle
QgsMapLayer = _MockQgsMapLayer
QgsApplication = _MockQgsApplication
QgsSymbolLayerUtils = _MockQgsSymbolLayerUtils
class _MockQgsLayoutMeasurement:
def __init__(self, length: float = 0.0, units: Any = None):
self._length = length
def length(self) -> float:
return self._length
QgsLayoutMeasurement = _MockQgsLayoutMeasurement
class _MockQgsLayoutUtils:
@staticmethod
def createRenderContextForLayout(layout: Any, painter: Any, dpi: float = -1) -> Any:
return None
QgsLayoutUtils = _MockQgsLayoutUtils
# QgsRectangle hatte im allgemeinen Mock-Block keinen Ersatz (nur Typannotation).
if "QgsRectangle" not in globals():
class _MockQgsRectangle:
def __init__(self, x_min: float = 0.0, y_min: float = 0.0, x_max: float = 0.0, y_max: float = 0.0):
self._werte = (x_min, y_min, x_max, y_max)
def xMinimum(self) -> float:
return self._werte[0]
def yMinimum(self) -> float:
return self._werte[1]
def xMaximum(self) -> float:
return self._werte[2]
def yMaximum(self) -> float:
return self._werte[3]
def width(self) -> float:
return self._werte[2] - self._werte[0]
def height(self) -> float:
return self._werte[3] - self._werte[1]
QgsRectangle = _MockQgsRectangle
SELECT_BEHAVIOR_SET = 0
SELECT_RELATION_INTERSECT = 0
PROPERTY_TYPE_EXPRESSION = 2
LEGEND_STYLE_HIDDEN = _MockQgsLegendStyle.Hidden
LEGEND_STYLE_SUBGROUP = _MockQgsLegendStyle.Subgroup
STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG = 3
# ---------------------------------------------------------
# Netzwerk
# ---------------------------------------------------------
+28
View File
@@ -55,6 +55,7 @@ QTableWidget: Type[Any] = object
QTableWidgetItem: Type[Any] = object
QTableWidgetSelectionRange: Type[Any] = object
QIcon: Type[Any] = object
QSize: Type[Any] = object
def exec_dialog(dialog: Any) -> Any:
@@ -125,6 +126,7 @@ try:
Qt as _Qt,
QVariant as _QVariant,
QSettings as _QSettings,
QSize as _QSize,
)
from qgis.PyQt.QtNetwork import (
QNetworkRequest as _QNetworkRequest,
@@ -175,6 +177,7 @@ try:
QTableWidgetItem = _QTableWidgetItem
QTableWidgetSelectionRange = _QTableWidgetSelectionRange
QIcon = _QIcon
QSize = _QSize
# ✅ QT6 ENUMS
YES = QMessageBox.StandardButton.Yes
NO = QMessageBox.StandardButton.No
@@ -200,6 +203,11 @@ try:
SelectRows = QAbstractItemView.SelectionBehavior.SelectRows
NoEditTriggers = QAbstractItemView.EditTrigger.NoEditTriggers
AscendingOrder = Qt.SortOrder.AscendingOrder
ItemIsUserCheckable = Qt.ItemFlag.ItemIsUserCheckable
ItemIsEnabled = Qt.ItemFlag.ItemIsEnabled
CheckStateChecked = Qt.CheckState.Checked
CheckStateUnchecked = Qt.CheckState.Unchecked
UserRole = Qt.ItemDataRole.UserRole
def exec_dialog(dialog: Any) -> Any:
return dialog.exec()
@@ -249,6 +257,7 @@ except (ImportError, AttributeError):
Qt as _Qt,
QVariant as _QVariant,
QSettings as _QSettings,
QSize as _QSize,
)
from PyQt5.QtNetwork import (
QNetworkRequest as _QNetworkRequest,
@@ -297,6 +306,7 @@ except (ImportError, AttributeError):
QTableWidgetItem = _QTableWidgetItem
QTableWidgetSelectionRange = _QTableWidgetSelectionRange
QIcon = _QIcon
QSize = _QSize
# ✅ PYQT5 ENUMS
YES = QMessageBox.Yes
@@ -324,6 +334,11 @@ except (ImportError, AttributeError):
SelectRows = QAbstractItemView.SelectRows
NoEditTriggers = QAbstractItemView.NoEditTriggers
AscendingOrder = Qt.AscendingOrder
ItemIsUserCheckable = Qt.ItemIsUserCheckable
ItemIsEnabled = Qt.ItemIsEnabled
CheckStateChecked = Qt.Checked
CheckStateUnchecked = Qt.Unchecked
UserRole = Qt.UserRole
def exec_dialog(dialog: Any) -> Any:
return dialog.exec_()
@@ -687,6 +702,19 @@ except (ImportError, AttributeError):
SelectRows = 1
NoEditTriggers = 0
AscendingOrder = 0
ItemIsUserCheckable = 16
ItemIsEnabled = 32
CheckStateChecked = 2
CheckStateUnchecked = 0
UserRole = 256
class _MockQSize:
def __init__(self, width: int = 0, height: int = 0):
self._width, self._height = width, height
def width(self) -> int: return self._width
def height(self) -> int: return self._height
QSize = _MockQSize
class _MockQDialog(_MockWidget):
DialogCode = type("DialogCode", (), {"Accepted": DialogAccepted, "Rejected": 0})