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})
+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)
+471
View File
@@ -0,0 +1,471 @@
"""
sn_basis/modules/basemap_legende.py – Legende für den basemap.de-Vektorkachel-Layer
QGIS erzeugt für Vektorkachel-Layer keine Legende. Dieses Modul wertet aus, welche
Objekte des Basemap-Layers im Auswertebereich (Verfahrensgebiet oder Druckbereich)
tatsächlich dargestellt werden, lässt den Nutzer Kategorien abwählen und schreibt die
gewählten Kategorien mit der Signatur des Projektlayers in ein eigenes Legendenelement
des Drucklayouts (``basemap_legende_layout``).
Ablauf und Festlegungen: Konzept ``docs/konzept/10_basemap_legende.md`` im Projektwissen,
Ticket Plugin_SN_Basis #88.
Aufbau:
- reine Logik ohne QGIS (Katalog, Zuordnung Style → Legendeneintrag, Vorauswahl) – im
Mock-Modus getestet,
- QGIS-Anbindung: Layer-Erkennung und Auswertebereich hier, Kachelauswertung über
``selectByGeometry`` in ``basemap_kacheln``,
- ``BasemapLegende.aktualisiere(layout)`` als Einstieg für Druck-Tab und Vorlagenerstellung.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from typing import Any, Callable, Iterable
from sn_basis.functions.qgiscore_wrapper import QgsCoordinateTransform, QgsGeometry, QgsProject
from sn_basis.functions.variable_wrapper import get_variable, set_variable
from sn_basis.modules.basemap_kacheln import ( # noqa: F401 – StilInfo/ObjektInfo gehören zur Schnittstelle
ObjektInfo,
StilInfo,
anzahl_kacheln,
qgis_filterpruefer,
sammle_objekte,
stile_aus_layer,
zoomstufen,
)
# ---------------------------------------------------------
# Konstanten und Schnittstellen
# ---------------------------------------------------------
BASEMAP_URL_KENNUNG = "gdz_basemapde_vektor"
BASEMAP_NAME_RUECKFALL = "basemap.de"
AUSWERTEBEREICH_VERFAHRENSGEBIET = "verfahrensgebiet"
AUSWERTEBEREICH_DRUCKBEREICH = "druckbereich"
AUSWERTEBEREICHE = {
AUSWERTEBEREICH_VERFAHRENSGEBIET: "Verfahrensgebiet",
AUSWERTEBEREICH_DRUCKBEREICH: "Druckbereich (Kartenausschnitt der Vorlage)",
}
# Projektvariablen (ohne Präfix sn_, siehe variable_wrapper)
AUSWERTEBEREICH_VAR = "basemap_auswertebereich"
LEGENDE_ERZEUGEN_VAR = "basemap_legende_erzeugen"
AUSWAHL_VAR = "basemap_legende_auswahl"
GRUPPE_SONSTIGES = "Sonstiges"
# Ab dieser Kachelzahl wird vor der Auswertung nachgefragt (je Kachel ein Netzabruf).
KACHELN_NACHFRAGEN_AB = 400
_STANDARD_KATALOG = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"data", "basemap_legende.json")
# ---------------------------------------------------------
# Datenklassen
# ---------------------------------------------------------
@dataclass
class LegendenEintrag:
"""Ein Eintrag der Basemap-Legende."""
schluessel: str
bezeichnung: str
gruppe: str
symbol_stile: list[str]
treffer: list[str] = field(default_factory=list)
bekannt: bool = True
# ---------------------------------------------------------
# Katalog und Zuordnung (ohne QGIS)
# ---------------------------------------------------------
class Katalog:
"""Legendenkatalog aus ``data/basemap_legende.json`` mit Index Style-ID → Eintrag."""
def __init__(self, daten: dict):
self.daten = daten
self.gruppen: list[str] = list(daten.get("gruppen", []))
if GRUPPE_SONSTIGES not in self.gruppen:
self.gruppen.append(GRUPPE_SONSTIGES)
self.eintraege: list[dict] = list(daten.get("eintraege", []))
self._nie = set(daten.get("nie_in_legende", []))
self._praefixe = tuple(daten.get("nie_in_legende_praefixe", []))
self._index: dict[str, dict] = {}
self._reihenfolge: dict[str, int] = {}
for position, eintrag in enumerate(self.eintraege):
self._reihenfolge[eintrag["schluessel"]] = position
for stil_id in eintrag.get("treffer_stile", []):
self._index[stil_id] = eintrag
@classmethod
def laden(cls, pfad: str | None = None) -> "Katalog":
with open(pfad or _STANDARD_KATALOG, encoding="utf-8") as datei:
return cls(json.load(datei))
def ist_ausgeschlossen(self, stil_name: str) -> bool:
return stil_name in self._nie or (bool(self._praefixe) and stil_name.startswith(self._praefixe))
def eintrag_fuer(self, stil_name: str) -> dict | None:
return self._index.get(stil_name)
def sortierschluessel(self, eintrag: LegendenEintrag) -> tuple[int, int, str]:
gruppe = self.gruppen.index(eintrag.gruppe) if eintrag.gruppe in self.gruppen else len(self.gruppen)
return gruppe, self._reihenfolge.get(eintrag.schluessel, len(self._reihenfolge)), eintrag.bezeichnung
def stil_ist_aktiv(stil: StilInfo, zoom: int) -> bool:
"""Wie ``QgsVectorTileBasicRendererStyle.isActive``: eingeschaltet und Zoomstufe im Bereich."""
return (
stil.aktiv
and (stil.min_zoom < 0 or zoom >= stil.min_zoom)
and (stil.max_zoom < 0 or zoom <= stil.max_zoom)
)
def ermittle_getroffene_stile(
objekte: Iterable[ObjektInfo],
stile: Iterable[StilInfo],
zoom: int,
filter_trifft: Callable[[str, ObjektInfo], bool],
) -> dict[str, int]:
"""Ermittelt, welche Renderer-Styles die Objekte bei der Zoomstufe tatsächlich zeichnen.
Ein Style zeichnet ein Objekt, wenn er aktiv ist, zum Quell-Layer passt (leerer
Layername = alle Layer), denselben Geometrietyp hat und sein Filterausdruck zutrifft.
Returns
-------
dict[str, int]
Style-Name → Anzahl der gezeichneten (entdoppelten) Objekte.
"""
je_layer: dict[str, list[StilInfo]] = {}
for stil in stile:
if stil_ist_aktiv(stil, zoom):
je_layer.setdefault(stil.layer_name, []).append(stil)
treffer: dict[str, int] = {}
for objekt in objekte:
for stil in je_layer.get(objekt.layer_name, []) + je_layer.get("", []):
if stil.geometrie != objekt.geometrie:
continue
if stil.filter and not filter_trifft(stil.filter, objekt):
continue
treffer[stil.name] = treffer.get(stil.name, 0) + 1
return treffer
def bilde_eintraege(getroffene_stile: Iterable[str], katalog: Katalog) -> list[LegendenEintrag]:
"""Fasst getroffene Styles zu Legendeneinträgen zusammen, sortiert nach Gruppe und Katalog.
Ausgeschlossene Styles (Beschriftungen, Hintergrund) entfallen. Styles, die der Katalog
nicht kennt (eigene Ergänzungen, neue Basemap-Version), bilden je einen Eintrag in der
Gruppe „Sonstiges“.
"""
eintraege: dict[str, LegendenEintrag] = {}
for stil_name in getroffene_stile:
if katalog.ist_ausgeschlossen(stil_name):
continue
daten = katalog.eintrag_fuer(stil_name)
if daten is None:
schluessel = f"stil:{stil_name}"
eintrag = eintraege.setdefault(schluessel, LegendenEintrag(
schluessel=schluessel,
bezeichnung=stil_name.replace("_", " "),
gruppe=GRUPPE_SONSTIGES,
symbol_stile=[stil_name],
bekannt=False,
))
else:
eintrag = eintraege.setdefault(daten["schluessel"], LegendenEintrag(
schluessel=daten["schluessel"],
bezeichnung=daten.get("bezeichnung", daten["schluessel"]),
gruppe=daten.get("gruppe", GRUPPE_SONSTIGES),
symbol_stile=list(daten.get("symbol_stile", [])),
))
eintrag.treffer.append(stil_name)
return sorted(eintraege.values(), key=katalog.sortierschluessel)
def lies_auswahl() -> dict[str, list[str]]:
"""Liest die zuletzt im Dialog getroffene Auswahl aus der Projektvariable."""
roh = get_variable(AUSWAHL_VAR, scope="project") or ""
try:
daten = json.loads(roh) if roh else {}
except (TypeError, ValueError):
daten = {}
if not isinstance(daten, dict):
daten = {}
return {
"ausgeblendet": [str(s) for s in daten.get("ausgeblendet", [])],
"eingeblendet": [str(s) for s in daten.get("eingeblendet", [])],
}
def vorauswahl(eintraege: Iterable[LegendenEintrag], gespeichert: dict[str, list[str]] | None = None) -> set[str]:
"""Bestimmt die vorausgewählten Einträge für den Dialog.
Zuletzt abgewählte Einträge bleiben abgewählt, zuletzt gewählte bleiben gewählt. Sonst gilt:
Katalogeinträge sind gewählt, unbekannte Styles („Sonstiges“) abgewählt.
"""
gespeichert = gespeichert or {"ausgeblendet": [], "eingeblendet": []}
aus = set(gespeichert.get("ausgeblendet", []))
ein = set(gespeichert.get("eingeblendet", []))
auswahl = set()
for eintrag in eintraege:
if eintrag.schluessel in aus:
continue
if eintrag.bekannt or eintrag.schluessel in ein:
auswahl.add(eintrag.schluessel)
return auswahl
def merke_auswahl(eintraege: Iterable[LegendenEintrag], gewaehlt: Iterable[str]) -> None:
"""Speichert die Dialogauswahl in der Projektvariable (ergänzt die bisherige Auswahl)."""
gewaehlt = set(gewaehlt)
bisher = lies_auswahl()
aus = set(bisher["ausgeblendet"])
ein = set(bisher["eingeblendet"])
for eintrag in eintraege:
if eintrag.schluessel in gewaehlt:
aus.discard(eintrag.schluessel)
ein.add(eintrag.schluessel)
else:
ein.discard(eintrag.schluessel)
aus.add(eintrag.schluessel)
set_variable(AUSWAHL_VAR, json.dumps({"ausgeblendet": sorted(aus), "eingeblendet": sorted(ein)}),
scope="project")
def lies_auswertebereich() -> str:
wert = (get_variable(AUSWERTEBEREICH_VAR, scope="project") or "").strip()
return wert if wert in AUSWERTEBEREICHE else AUSWERTEBEREICH_VERFAHRENSGEBIET
def setze_auswertebereich(bereich: str) -> None:
if bereich not in AUSWERTEBEREICHE:
raise ValueError(f"Unbekannter Auswertebereich: {bereich}")
set_variable(AUSWERTEBEREICH_VAR, bereich, scope="project")
def legende_erzeugen_aktiv() -> bool:
"""Ob beim Erstellen einer Vorlage eine Basemap-Legende erzeugt wird (Standard: ja)."""
return (get_variable(LEGENDE_ERZEUGEN_VAR, scope="project") or "1").strip() != "0"
def setze_legende_erzeugen(aktiv: bool) -> None:
set_variable(LEGENDE_ERZEUGEN_VAR, "1" if aktiv else "0", scope="project")
# ---------------------------------------------------------
# Basemap-Layer erkennen
# ---------------------------------------------------------
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
def ist_basemap_layer(layer: Any) -> bool:
"""Erkennt den basemap.de-Vektorkachel-Layer.
Maßgeblich ist ein Vektorkachel-Layer (hat ``tileMatrixSet``), dessen Quelle die
basemap.de-Kennung enthält; der Layername dient nur als Rückfall, weil Nutzer Layer
umbenennen.
"""
if layer is None or not callable(getattr(layer, "tileMatrixSet", None)):
return False
quelle = str(_aufruf(layer, "source", "") or "").lower()
if BASEMAP_URL_KENNUNG in quelle:
return True
return BASEMAP_NAME_RUECKFALL in str(_aufruf(layer, "name", "") or "").lower()
def sichtbare_layer_der_karte(map_item: Any, project: Any) -> list[Any]:
"""Liefert die Layer, die eine Layoutkarte darstellt (Kartenthema, feste Layerliste oder Layerbaum)."""
if _aufruf(map_item, "followVisibilityPreset", False):
thema = _aufruf(map_item, "followVisibilityPresetName", "")
sammlung = _aufruf(project, "mapThemeCollection")
if thema and sammlung is not None:
try:
return list(sammlung.mapThemeVisibleLayers(thema))
except Exception:
pass
if _aufruf(map_item, "keepLayerSet", False):
layer = _aufruf(map_item, "layers", []) or []
if layer:
return list(layer)
wurzel = _aufruf(project, "layerTreeRoot")
knoten = _aufruf(wurzel, "findLayers", []) or []
return [k.layer() for k in knoten if _aufruf(k, "isVisible", False) and _aufruf(k, "layer") is not None]
def finde_basemap_layer(layer: Iterable[Any]) -> list[Any]:
return [l for l in layer if ist_basemap_layer(l)]
# ---------------------------------------------------------
# Auswertebereich
# ---------------------------------------------------------
def auswertegeometrie(
bereich: str,
map_item: Any,
project: Any,
ziel_crs: Any,
) -> tuple[Any | None, str]:
"""Bestimmt die Auswertegeometrie im Ziel-KBS.
Returns
-------
(geometrie, tatsächlicher Bereich)
Ohne Verfahrensgebiet wird auf den Druckbereich zurückgefallen.
"""
from sn_basis.functions.verfahrensgebiet_manager import get_verfahrensgebiet_layer_id
geometrie, quell_crs, genutzt = None, None, AUSWERTEBEREICH_DRUCKBEREICH
if bereich == AUSWERTEBEREICH_VERFAHRENSGEBIET:
layer_id = get_verfahrensgebiet_layer_id()
vg_layer = project.mapLayer(layer_id) if layer_id else None
if vg_layer is not None:
teile = [f.geometry() for f in vg_layer.getFeatures() if f.hasGeometry()]
if teile:
geometrie = QgsGeometry.unaryUnion(teile)
quell_crs = vg_layer.crs()
genutzt = AUSWERTEBEREICH_VERFAHRENSGEBIET
if geometrie is None:
geometrie = QgsGeometry.fromRect(map_item.extent())
quell_crs = map_item.crs()
if geometrie is None or geometrie.isEmpty():
return None, genutzt
geometrie = QgsGeometry(geometrie)
geometrie.transform(QgsCoordinateTransform(quell_crs, ziel_crs, project))
return geometrie, genutzt
# ---------------------------------------------------------
# Ablauf
# ---------------------------------------------------------
class BasemapLegende:
"""Erzeugt bzw. aktualisiert die Basemap-Legende eines Drucklayouts.
Dialog, Fortschritt und Rückfragen werden injiziert, damit der Ablauf ohne QGIS-Oberfläche
testbar bleibt.
"""
def __init__(
self,
project: Any | None = None,
katalog: Katalog | None = None,
dialog: Callable[..., list[str] | None] | None = None,
fortschritt_fabrik: Callable[..., Any] | None = None,
bestaetigen: Callable[[str, str], bool] | None = None,
melden: Callable[[str, str], None] | None = None,
) -> None:
from sn_basis.functions.dialog_wrapper import ask_mehrfachauswahl, ask_yes_no, create_progress_dialog
from sn_basis.functions.message_wrapper import info
self.project = project or QgsProject.instance()
self.katalog = katalog or Katalog.laden()
self.dialog = dialog or ask_mehrfachauswahl
self.fortschritt_fabrik = fortschritt_fabrik or create_progress_dialog
self.bestaetigen = bestaetigen or (lambda titel, text: ask_yes_no(titel, text, default=False))
self.melden = melden or (lambda titel, text: info(titel, text))
def ermittle_eintraege(self, layer: Any, geometrie: Any, massstab: float) -> list[LegendenEintrag] | None:
"""Wertet die Kacheln aus und bildet die Legendeneinträge (``None`` bei Abbruch)."""
kachelzahl = anzahl_kacheln(layer, geometrie, massstab)
if kachelzahl >= KACHELN_NACHFRAGEN_AB and not self.bestaetigen(
"Basemap-Legende",
f"Für die Basemap-Legende müssen {kachelzahl} Kacheln ausgewertet werden. "
"Das kann einige Minuten dauern. Fortfahren?",
):
return None
fortschritt = self.fortschritt_fabrik(kachelzahl, "Basemap-Legende", "Kacheln werden ausgewertet …")
def melde_fortschritt(nummer: int, gesamt: int) -> bool:
fortschritt.set_value(nummer)
return not fortschritt.is_canceled()
try:
objekte = sammle_objekte(layer, geometrie, massstab, fortschritt=melde_fortschritt)
finally:
fortschritt.close()
if objekte is None:
return None
_, darstellungszoom, kachelzoom_bruch = zoomstufen(layer, massstab)
getroffen = ermittle_getroffene_stile(
objekte, stile_aus_layer(layer), darstellungszoom,
qgis_filterpruefer(kachelzoom_bruch, darstellungszoom),
)
return bilde_eintraege(getroffen, self.katalog)
def aktualisiere(self, layout: Any, bereich: str | None = None) -> dict:
"""Erzeugt bzw. ersetzt die Basemap-Legende im Layout.
Returns
-------
dict
``status`` (``ok``, ``kein_layer``, ``keine_karte``, ``keine_eintraege``, ``abgebrochen``),
``meldung`` und ``anzahl`` (gewählte Einträge).
"""
from sn_basis.modules import basemap_legende_layout as ausgabe
map_item = ausgabe.hauptkarte(layout)
if map_item is None:
return {"status": "keine_karte", "meldung": "Das Layout enthält keine Karte.", "anzahl": 0}
basemap = finde_basemap_layer(sichtbare_layer_der_karte(map_item, self.project))
if not basemap:
return {"status": "kein_layer", "meldung": "Kein sichtbarer Basemap-Layer in der Karte.", "anzahl": 0}
layer = basemap[0]
if len(basemap) > 1:
self.melden("Basemap-Legende", f"Mehrere Basemap-Layer sichtbar – ausgewertet wird „{layer.name()}“.")
bereich = bereich or lies_auswertebereich()
geometrie, genutzt = auswertegeometrie(bereich, map_item, self.project, layer.crs())
if geometrie is None:
return {"status": "keine_eintraege", "meldung": "Kein Auswertebereich vorhanden.", "anzahl": 0}
if genutzt != bereich:
self.melden("Basemap-Legende", "Kein Verfahrensgebiet gesetzt – ausgewertet wird der Druckbereich.")
massstab = map_item.scale()
eintraege = self.ermittle_eintraege(layer, geometrie, massstab)
if eintraege is None:
return {"status": "abgebrochen", "meldung": "Auswertung abgebrochen.", "anzahl": 0}
if not eintraege:
return {"status": "keine_eintraege", "meldung": "Im Auswertebereich wurden keine Basemap-Objekte gefunden.",
"anzahl": 0}
_, darstellungszoom, kachelzoom_bruch = zoomstufen(layer, massstab)
symbole = ausgabe.baue_symbole(layer, eintraege, darstellungszoom, kachelzoom_bruch)
dialog_eintraege = [
{"schluessel": e.schluessel, "text": e.bezeichnung, "gruppe": e.gruppe,
"icon": ausgabe.vorschau_icon(symbole.get(e.schluessel))}
for e in eintraege
]
gewaehlt = self.dialog(
"Basemap-Legende",
f"Im {AUSWERTEBEREICHE[genutzt].split(' (')[0]} kommen diese Kategorien der Basemap vor.\n"
"Abgewählte Kategorien erscheinen nicht in der Legende.",
dialog_eintraege,
vorauswahl(eintraege, lies_auswahl()),
)
if gewaehlt is None:
return {"status": "abgebrochen", "meldung": "Auswahl abgebrochen.", "anzahl": 0}
merke_auswahl(eintraege, gewaehlt)
gewaehlte_eintraege = [e for e in eintraege if e.schluessel in set(gewaehlt)]
hinweis = ausgabe.schreibe_legende(layout, map_item, gewaehlte_eintraege, symbole, self.project,
bereich=genutzt, massstab=massstab)
if hinweis:
self.melden("Basemap-Legende", hinweis)
anzahl = len(gewaehlte_eintraege)
return {"status": "ok", "meldung": f"{anzahl} Einträge in der Basemap-Legende.", "anzahl": anzahl}
+396
View File
@@ -0,0 +1,396 @@
"""
sn_basis/modules/basemap_legende_layout.py – Basemap-Legende im Drucklayout ausgeben
Baut aus den gewählten Legendeneinträgen (``basemap_legende``) die Legendensymbole aus den
Renderer-Styles des Projektlayers, legt dafür leere Hilfslayer an und schreibt sie in ein
eigenes Legendenelement „Legende Basemap“ unterhalb der Hauptlegende.
Warum ein eigenes Legendenelement: Die Hauptlegende filtert nach Karteninhalt
(``setLegendFilterByMapEnabled``) und würde die Hilfslayer ausblenden, weil sie nicht in der
Karte liegen (Spike 02.10.2026, QGIS 3.44). Das Basemap-Element ist ohne Kartenfilter und ohne
automatische Aktualisierung; die Hilfslayer stehen nicht im Layerbaum, bleiben aber mit dem
Projekt gespeichert. Leere Speicherlayer lösen beim Schließen des Projekts keine Rückfrage aus.
"""
from __future__ import annotations
import json
import re
from typing import Any
from sn_basis.functions.qgiscore_wrapper import (
LEGEND_STYLE_HIDDEN,
LEGEND_STYLE_SUBGROUP,
PROPERTY_TYPE_EXPRESSION,
QgsCategorizedSymbolRenderer,
QgsExpressionContext,
QgsExpressionContextScope,
QgsLayoutItem,
QgsLayoutItemLegend,
QgsLayoutItemMap,
QgsLayoutMeasurement,
QgsLayoutPoint,
QgsLayoutSize,
QgsLayoutUtils,
QgsLegendRenderer,
QgsProperty,
QgsRendererCategory,
QgsSymbolLayerUtils,
QgsUnitTypes,
QgsVectorLayer,
)
from sn_basis.functions.qt_wrapper import QSize
MM = QgsUnitTypes.LayoutMillimeters
LEGENDE_ID = "Legende Basemap"
LEGENDE_TITEL = "Kartengrundlage (basemap.de)"
HAUPTKARTE_ID = "Hauptkarte"
HAUPTLEGENDE_ID = "Legende"
QUELLENANGABE_ID = "Quellenangabe"
ABSTAND_MM = 4.0
# Mindestplatz, der über der Basemap-Legende für die Hauptlegende frei bleiben soll.
PLATZ_HAUPTLEGENDE_MM = 40.0
RAHMENSTAERKE_MM = 0.5
# Layout-Eigenschaften
LAYOUT_KENNZEICHEN = "sn_basemap_legende"
LAYOUT_BEREICH = "sn_basemap_bereich"
LAYOUT_MASSSTAB = "sn_basemap_massstab"
LAYOUT_EINTRAEGE = "sn_basemap_eintraege"
LAYOUT_HILFSLAYER = "sn_basemap_hilfslayer"
# Eigenschaft der Hilfslayer (Wert: Layoutname)
HILFSLAYER_KENNUNG = "sn_basemap_legende"
_GEOMETRIE_JE_SYMBOLTYP = {0: "Point", 1: "LineString", 2: "Polygon"}
_ZOOM_VARIABLEN = {"vector_tile_zoom", "zoom_level"}
_VARIABLE = re.compile(r"@([A-Za-z_][A-Za-z0-9_]*)")
def _enum_int(wert: Any) -> int:
try:
return int(wert)
except (TypeError, ValueError):
return int(getattr(wert, "value", -1))
def _aufruf(objekt: Any, methode: str, *args: Any, standard: Any = None) -> Any:
funktion = getattr(objekt, methode, None)
if not callable(funktion):
return standard
try:
return funktion(*args)
except Exception:
return standard
# ---------------------------------------------------------
# Layout-Kennzeichen
# ---------------------------------------------------------
def markiere_layout(layout: Any) -> None:
"""Kennzeichnet ein Layout als Nutzer der Basemap-Legende."""
layout.setCustomProperty(LAYOUT_KENNZEICHEN, 1)
def nutzt_basemap_legende(layout: Any) -> bool:
return str(_aufruf(layout, "customProperty", LAYOUT_KENNZEICHEN, 0, standard=0)).lower() in ("1", "true")
def layouts_mit_basemap_legende(project: Any) -> list[str]:
"""Namen der Drucklayouts des Projekts, die die Basemap-Legende nutzen."""
verwaltung = _aufruf(project, "layoutManager")
layouts = _aufruf(verwaltung, "printLayouts", standard=[]) or []
return [l.name() for l in layouts if nutzt_basemap_legende(l)]
def hauptkarte(layout: Any) -> Any | None:
"""Die Karte, auf die sich die Legende bezieht (ID „Hauptkarte“, sonst die erste Karte)."""
karte = _aufruf(layout, "itemById", HAUPTKARTE_ID)
if karte is not None:
return karte
for element in _aufruf(layout, "items", standard=[]) or []:
if isinstance(element, QgsLayoutItemMap):
return element
return None
# ---------------------------------------------------------
# Symbole
# ---------------------------------------------------------
def friere_zoom_ein(symbol: Any, zoom_level: int, vector_tile_zoom: float) -> None:
"""Schreibt zoomabhängige Werte (Strichbreite, Deckkraft …) für die Druck-Zoomstufe fest.
Die Basemap-Styles nutzen ``@vector_tile_zoom``; diese Variable gibt es in der Legende nicht.
Ohne Festschreiben bleiben z. B. gestrichelte Linien in der Legende unsichtbar, weil ihr
Strichmuster zoomabhängig berechnet wird. ``@symbol_color`` wird mit der Farbe der jeweiligen
Symbolebene belegt; lokale Variablen wie ``@element`` (``array_foreach``) wertet QGIS selbst aus.
"""
for symbol_layer in symbol.symbolLayers():
kontext = QgsExpressionContext()
scope = QgsExpressionContextScope()
scope.setVariable("vector_tile_zoom", vector_tile_zoom)
scope.setVariable("zoom_level", zoom_level)
farbe = _aufruf(symbol_layer, "color")
if farbe is not None:
scope.setVariable("symbol_color", farbe)
kontext.appendScope(scope)
eigenschaften = symbol_layer.dataDefinedProperties()
for schluessel in eigenschaften.propertyKeys():
eigenschaft = eigenschaften.property(schluessel)
if eigenschaft.propertyType() != PROPERTY_TYPE_EXPRESSION:
continue
if not set(_VARIABLE.findall(eigenschaft.expressionString())) & _ZOOM_VARIABLEN:
continue
ergebnis = eigenschaft.value(kontext, None)
wert, ok = ergebnis if isinstance(ergebnis, tuple) else (ergebnis, ergebnis is not None)
if ok and wert is not None:
eigenschaften.setProperty(schluessel, QgsProperty.fromValue(wert))
unter = symbol_layer.subSymbol()
if unter is not None:
friere_zoom_ein(unter, zoom_level, vector_tile_zoom)
def waehle_symbolnamen(eintrag: Any, aktive: dict[str, int]) -> list[str]:
"""Style-Namen für das Legendensymbol, in Zeichenreihenfolge des Renderers.
Bevorzugt die ``symbol_stile`` des Katalogs, soweit sie bei der Zoomstufe aktiv sind; sonst
den ersten tatsächlich getroffenen Style.
"""
namen = [n for n in eintrag.symbol_stile if n in aktive]
if not namen:
namen = [n for n in eintrag.treffer if n in aktive][:1]
return sorted(dict.fromkeys(namen), key=lambda n: aktive[n])
def baue_symbole(layer: Any, eintraege: list, zoom_level: int, vector_tile_zoom: float) -> dict[str, Any]:
"""Setzt je Legendeneintrag ein Symbol aus den Renderer-Styles des Projektlayers zusammen."""
stile = layer.renderer().styles()
aktive = {s.styleName(): i for i, s in enumerate(stile) if s.isActive(zoom_level) and s.symbol() is not None}
je_name = {s.styleName(): s for s in stile}
symbole: dict[str, Any] = {}
for eintrag in eintraege:
basis = None
for name in waehle_symbolnamen(eintrag, aktive):
symbol = je_name[name].symbol().clone()
if basis is None:
basis = symbol
continue
for symbol_layer in symbol.symbolLayers():
# Unverträgliche Ebenen (z. B. Punktsymbol in einer Füllung) lehnt QGIS ab.
basis.appendSymbolLayer(symbol_layer.clone())
if basis is not None:
friere_zoom_ein(basis, zoom_level, vector_tile_zoom)
symbole[eintrag.schluessel] = basis
return symbole
def vorschau_icon(symbol: Any) -> Any:
if symbol is None:
return None
return _aufruf(QgsSymbolLayerUtils, "symbolPreviewIcon", symbol, QSize(16, 16))
# ---------------------------------------------------------
# Hilfslayer
# ---------------------------------------------------------
def entferne_hilfslayer(project: Any, layout: Any) -> None:
"""Entfernt die Hilfslayer einer früheren Basemap-Legende dieses Layouts aus dem Projekt."""
roh = str(_aufruf(layout, "customProperty", LAYOUT_HILFSLAYER, "", standard="") or "")
for layer_id in [i for i in roh.split(",") if i]:
if _aufruf(project, "mapLayer", layer_id) is not None:
project.removeMapLayer(layer_id)
def erzeuge_hilfslayer(project: Any, layout_name: str, crs_authid: str, eintraege: list,
symbole: dict[str, Any]) -> list[tuple[str, list[Any]]]:
"""Legt je Gruppe und Geometrietyp einen leeren, kategorisierten Speicherlayer an.
Returns
-------
list[tuple[str, list]]
Gruppen in Legendenreihenfolge mit ihren Hilfslayern.
"""
gruppen: dict[str, dict[int, list]] = {}
for eintrag in eintraege:
symbol = symbole.get(eintrag.schluessel)
if symbol is None:
continue
typ = _enum_int(symbol.type())
if typ not in _GEOMETRIE_JE_SYMBOLTYP:
continue
gruppen.setdefault(eintrag.gruppe, {}).setdefault(typ, []).append(
QgsRendererCategory(eintrag.schluessel, symbol, eintrag.bezeichnung))
ergebnis: list[tuple[str, list[Any]]] = []
for gruppe, je_typ in gruppen.items():
layer_liste = []
for typ in sorted(je_typ, reverse=True): # Flächen, Linien, Punkte
layer = QgsVectorLayer(f"{_GEOMETRIE_JE_SYMBOLTYP[typ]}?crs={crs_authid}&field=eintrag:string",
f"Basemap-Legende {layout_name}: {gruppe}", "memory")
layer.setRenderer(QgsCategorizedSymbolRenderer("eintrag", je_typ[typ]))
layer.setCustomProperty("skipMemoryLayersCheck", 1)
layer.setCustomProperty(HILFSLAYER_KENNUNG, layout_name)
project.addMapLayer(layer, False)
layer_liste.append(layer)
ergebnis.append((gruppe, layer_liste))
return ergebnis
# ---------------------------------------------------------
# Legendenelement
# ---------------------------------------------------------
def _rechteck_mm(element: Any) -> tuple[float, float, float, float] | None:
"""Links, oben, Breite, Höhe eines Layoutelements in mm – unabhängig von seinem Bezugspunkt."""
rechteck = _aufruf(element, "sceneBoundingRect")
if rechteck is not None:
return rechteck.x(), rechteck.y(), rechteck.width(), rechteck.height()
position = _aufruf(element, "positionWithUnits")
groesse = _aufruf(element, "sizeWithUnits")
if position is None or groesse is None:
return None
return position.x(), position.y(), groesse.width(), groesse.height()
def _neues_legendenelement(layout: Any) -> Any:
legende = QgsLayoutItemLegend(layout)
legende.setId(LEGENDE_ID)
_aufruf(legende, "setObjectName", LEGENDE_ID)
_aufruf(legende, "setFrameEnabled", True)
_aufruf(legende, "setBackgroundEnabled", False)
_aufruf(legende, "setFrameStrokeWidth", _layout_messung(RAHMENSTAERKE_MM))
layout.addLayoutItem(legende)
return legende
def _layout_messung(wert_mm: float) -> Any:
return QgsLayoutMeasurement(wert_mm, MM)
def mindestgroesse(legende: Any, layout: Any, spalten: int) -> tuple[float, float] | None:
"""Platzbedarf der Legende in mm bei ``spalten`` Spalten, ohne sie zu zeichnen.
``adjustBoxSize`` wirkt in QGIS erst, nachdem das Element einmal gezeichnet wurde; für die
Platzierung beim Anlegen wird der Bedarf deshalb über ``QgsLegendRenderer`` berechnet.
"""
einstellungen = _aufruf(legende, "legendSettings")
kontext = _aufruf(QgsLayoutUtils, "createRenderContextForLayout", layout, None)
if einstellungen is None or kontext is None:
return None
einstellungen.setColumnCount(spalten)
einstellungen.setSplitLayer(True)
einstellungen.setEqualColumnWidth(True)
try:
groesse = QgsLegendRenderer(legende.model(), einstellungen).minimumSize(kontext)
except Exception:
return None
return groesse.width(), groesse.height()
def platziere(layout: Any, legende: Any) -> str:
"""Setzt die Basemap-Legende direkt über die Quellenangabe, in die Spalte der Hauptlegende.
Die Legende ist unten verankert und wächst nach oben. Unter der Hauptlegende zu verankern geht
nicht: deren Höhe steht erst nach dem Rendern fest, weil sie nach Karteninhalt filtert. Bis zur
Oberkante der Hauptlegende bleibt ``PLATZ_HAUPTLEGENDE_MM`` frei; reicht der Platz nicht, wird
die Legende zwei- oder dreispaltig, solange sie nicht über den Seitenrand hinausragt.
Returns
-------
str
Hinweis, wenn der Platz auch dreispaltig nicht reicht, sonst leer.
"""
haupt = _rechteck_mm(_aufruf(layout, "itemById", HAUPTLEGENDE_ID))
karte = _rechteck_mm(hauptkarte(layout))
quelle = _rechteck_mm(_aufruf(layout, "itemById", QUELLENANGABE_ID))
if haupt is not None:
x, oben = haupt[0], haupt[1]
elif karte is not None:
x, oben = karte[0] + karte[2] + 10.0, karte[1]
else:
x, oben = 10.0, 10.0
if quelle is not None:
unten = quelle[1] - ABSTAND_MM
elif karte is not None:
unten = karte[1] + karte[3]
else:
unten = oben + 200.0
verfuegbar = unten - oben - PLATZ_HAUPTLEGENDE_MM
seite = _aufruf(_aufruf(_aufruf(layout, "pageCollection"), "page", 0), "pageSize")
max_breite = (seite.width() - x - 10.0) if seite is not None else float("inf")
# Wenigste Spalten, mit denen die Höhe passt – aber nie breiter als bis zum Seitenrand.
spalten, groesse = 1, mindestgroesse(legende, layout, 1)
while groesse is not None and groesse[1] > verfuegbar and spalten < 3:
breiter = mindestgroesse(legende, layout, spalten + 1)
if breiter is None or breiter[0] > max_breite:
break
spalten, groesse = spalten + 1, breiter
_aufruf(legende, "setColumnCount", spalten)
_aufruf(legende, "setSplitLayer", True)
_aufruf(legende, "setEqualColumnWidth", True)
unten_links = getattr(getattr(QgsLayoutItem, "ReferencePoint", QgsLayoutItem), "LowerLeft", None)
if unten_links is not None:
_aufruf(legende, "setReferencePoint", unten_links)
breite, hoehe = groesse if groesse is not None else (180.0, 20.0)
legende.attemptMove(QgsLayoutPoint(x, unten, MM))
legende.attemptResize(QgsLayoutSize(breite, hoehe, MM))
if groesse is not None and hoehe > verfuegbar:
return ("Die Basemap-Legende braucht mehr Platz, als neben der Karte frei ist. Bitte im Layout "
"anpassen oder weniger Kategorien wählen.")
return ""
def schreibe_legende(layout: Any, map_item: Any, eintraege: list, symbole: dict[str, Any], project: Any,
*, bereich: str, massstab: float) -> str:
"""Schreibt bzw. ersetzt die Basemap-Legende des Layouts.
Ohne gewählte Einträge wird ein vorhandenes Basemap-Legendenelement entfernt.
Returns
-------
str
Hinweis für den Nutzer oder leer.
"""
entferne_hilfslayer(project, layout)
legende = _aufruf(layout, "itemById", LEGENDE_ID)
markiere_layout(layout)
layout.setCustomProperty(LAYOUT_BEREICH, bereich)
layout.setCustomProperty(LAYOUT_MASSSTAB, float(massstab))
layout.setCustomProperty(LAYOUT_EINTRAEGE, json.dumps([e.schluessel for e in eintraege]))
if not eintraege:
if legende is not None:
layout.removeLayoutItem(legende)
layout.setCustomProperty(LAYOUT_HILFSLAYER, "")
return ""
crs = _aufruf(map_item, "crs")
gruppen = erzeuge_hilfslayer(project, layout.name(), _aufruf(crs, "authid", standard="") or "EPSG:25833",
eintraege, symbole)
layout.setCustomProperty(LAYOUT_HILFSLAYER, ",".join(l.id() for _, ls in gruppen for l in ls))
neu = legende is None
if neu:
legende = _neues_legendenelement(layout)
_aufruf(legende, "setLocked", False)
legende.setLinkedMap(map_item)
legende.setAutoUpdateModel(False)
legende.setLegendFilterByMapEnabled(False)
legende.setTitle(LEGENDE_TITEL)
wurzel = legende.model().rootGroup()
wurzel.removeAllChildren()
for gruppe, layer_liste in gruppen:
for nummer, layer in enumerate(layer_liste):
knoten = wurzel.addLayer(layer)
_aufruf(knoten, "setName", gruppe)
QgsLegendRenderer.setNodeLegendStyle(knoten, LEGEND_STYLE_SUBGROUP if nummer == 0 else LEGEND_STYLE_HIDDEN)
_aufruf(legende, "refresh")
hinweis = platziere(layout, legende) if neu else ""
if not neu:
_aufruf(legende, "adjustBoxSize")
_aufruf(legende, "setLocked", True)
return hinweis
+23
View File
@@ -981,6 +981,7 @@ class PrintLayout:
kartenname_auswahl: str = "",
thema: str = "",
fit_extent_to_map: bool = True,
basemap_legende: bool = False,
) -> Any:
layout_manager = self.project.layoutManager()
existing_layout = layout_manager.layoutByName(name)
@@ -1082,7 +1083,29 @@ class PrintLayout:
kartenname_auswahl,
)
if basemap_legende:
self._erzeuge_basemap_legende(layout)
layout_manager.addLayout(layout)
self._current_layout = None
open_layout_designer(layout)
return layout
def _erzeuge_basemap_legende(self, layout: Any) -> None:
"""Kennzeichnet das Layout und erzeugt die Basemap-Legende (Basis #88).
Ein Fehler bei der Basemap-Legende darf das Anlegen der Vorlage nicht verhindern; er wird
als Warnung gemeldet.
"""
from sn_basis.functions.message_wrapper import warning
from sn_basis.modules.basemap_legende import BasemapLegende
from sn_basis.modules.basemap_legende_layout import markiere_layout
markiere_layout(layout)
try:
ergebnis = BasemapLegende(self.project).aktualisiere(layout)
except Exception as fehler:
warning("Basemap-Legende", f"Die Basemap-Legende konnte nicht erzeugt werden: {fehler}")
return
if ergebnis.get("status") == "keine_eintraege":
warning("Basemap-Legende", ergebnis.get("meldung", ""))
+9
View File
@@ -69,6 +69,8 @@ DRUCKRECHTECK_LAYER_NAME = "Druckrechteck"
KARTENNAME_38 = "§38"
KARTENNAME_41 = "§41"
KARTENNAME_BAUFREIGABE = "Anlage zur Baufreigabe"
# Layouts, die beim Anlegen eine Basemap-Legende erhalten (Basis #88). Neue Layouts hier aufnehmen.
LAYOUTS_MIT_BASEMAP_LEGENDE = frozenset({KARTENNAME_38, KARTENNAME_41})
MASSSTAB_WIE_KARTENFENSTER = "Wie Kartenfenster"
THEMA_WIE_KARTENFENSTER = "wie kartenfenster"
@@ -1188,6 +1190,12 @@ class PrintLogic:
fallback_map_h = max(map_height_mm, fallback_page_h - STANDARD_LAYOUT_FIXHOEHE_MM, 50.0)
return fallback_page_w, fallback_page_h, fallback_map_w, fallback_map_h, True
def _basemap_legende_erzeugen(self, kartenname_auswahl: str) -> bool:
"""Ob die neue Vorlage eine Basemap-Legende bekommt (Layoutart und Schalter im Druck-Tab)."""
from sn_basis.modules.basemap_legende import legende_erzeugen_aktiv
return kartenname_auswahl in LAYOUTS_MIT_BASEMAP_LEGENDE and legende_erzeugen_aktiv()
def druckvorlage_anlegen(
self,
layer: object | None,
@@ -1326,6 +1334,7 @@ class PrintLogic:
kartenname_auswahl=kartenname_auswahl,
thema=thema,
fit_extent_to_map=(druckbereich_auswahl != DRUCKBEREICH_VERFAHRENSGEBIET) or force_fit_extent_for_min_a4,
basemap_legende=self._basemap_legende_erzeugen(kartenname_auswahl),
)
except ValueError as exc:
self.pruefmanager.zeige_hinweis("Vorlage anlegen", str(exc))
+186
View File
@@ -0,0 +1,186 @@
"""
Tests für modules/basemap_kacheln.py – kachelweise Auswertung des Basemap-Layers (Basis #88).
Layer, Kachelmatrix und Geometrie sind Fakes; ``selectByGeometry`` liefert je Kachel vorbereitete
Objekte. Die echte Auswertung gegen basemap.de ist im QGIS-Test geprüft.
"""
import unittest
from unittest.mock import patch
from sn_basis.modules.basemap_kacheln import anzahl_kacheln, sammle_objekte, stile_aus_layer, zoomstufen
class _Rechteck:
def __init__(self, x0, y0, x1, y1):
self.werte = (x0, y0, x1, y1)
def xMinimum(self): return self.werte[0]
def yMinimum(self): return self.werte[1]
def xMaximum(self): return self.werte[2]
def yMaximum(self): return self.werte[3]
def width(self): return self.werte[2] - self.werte[0]
class _Bereich:
def __init__(self, spalten, zeilen):
self._spalten, self._zeilen = spalten, zeilen
def startColumn(self): return self._spalten[0]
def endColumn(self): return self._spalten[1]
def startRow(self): return self._zeilen[0]
def endRow(self): return self._zeilen[1]
class _Matrix:
def __init__(self, bereich):
self._bereich = bereich
def tileRangeFromExtent(self, ausdehnung):
return self._bereich
def tileExtent(self, kachel):
return _Rechteck(kachel.column() * 10, kachel.row() * 10, kachel.column() * 10 + 10, kachel.row() * 10 + 10)
class _MatrixSet:
def __init__(self, bereich):
self._bereich = bereich
self.gefragt = []
def scaleToZoomLevel(self, massstab, begrenzen=True):
self.gefragt.append((massstab, begrenzen))
return 15 if begrenzen else 16
def scaleToZoom(self, massstab):
return 15.8
def tileMatrix(self, zoom):
return _Matrix(self._bereich)
class _Geometrie:
def __init__(self, leer=False):
self._leer = leer
def boundingBox(self):
return _Rechteck(0, 0, 20, 10)
def intersection(self, andere):
return _Geometrie(self._leer)
def isEmpty(self):
return self._leer
class _Feld:
def __init__(self, name): self._name = name
def name(self): return self._name
class _Merkmal:
def __init__(self, layer, geometrietyp, **attribute):
self._werte = dict(attribute, tile_layer=layer, tile_zoom=15)
self._typ = geometrietyp
def fields(self): return [_Feld(n) for n in self._werte]
def __getitem__(self, name): return self._werte[name]
def geometry(self): return type("G", (), {"type": lambda s: self._typ})()
class _Layer:
def __init__(self, bereich, merkmale_je_aufruf):
self.matrix = _MatrixSet(bereich)
self._merkmale = list(merkmale_je_aufruf)
self._aktuell = []
self.auswahl_aufgehoben = 0
self.aufrufe = 0
def tileMatrixSet(self): return self.matrix
def selectByGeometry(self, geometrie, kontext, verhalten, beziehung):
self.aufrufe += 1
self._aktuell = self._merkmale.pop(0) if self._merkmale else []
def selectedFeatures(self): return self._aktuell
def removeSelection(self): self.auswahl_aufgehoben += 1
class _GeometrieFabrik:
"""Ersatz für QgsGeometry.fromRect (fehlt im Mock-Modus)."""
@staticmethod
def fromRect(rechteck):
return rechteck
@patch("sn_basis.modules.basemap_kacheln.QgsGeometry", _GeometrieFabrik)
class TestSammleObjekte(unittest.TestCase):
def test_gleiche_objekte_aus_mehreren_kacheln_werden_entdoppelt(self):
"""Prüft, dass Objekte mit gleichem Quell-Layer, Geometrietyp und Attributen über Kachelgrenzen hinweg nur einmal gezählt werden."""
strasse = _Merkmal("Verkehrslinie", 1, klasse="Kreisstraße")
layer = _Layer(_Bereich((0, 1), (0, 0)), [
[strasse, _Merkmal("Vegetationsflaeche", 2, klasse="Wald")],
[_Merkmal("Verkehrslinie", 1, klasse="Kreisstraße")],
])
objekte = sammle_objekte(layer, _Geometrie(), 5000.0)
self.assertEqual(layer.aufrufe, 2)
self.assertEqual(sorted(o.layer_name for o in objekte), ["Vegetationsflaeche", "Verkehrslinie"])
kreis = next(o for o in objekte if o.layer_name == "Verkehrslinie")
self.assertEqual(kreis.attribute, {"klasse": "Kreisstraße"})
self.assertIs(kreis.merkmal, strasse)
self.assertEqual(layer.auswahl_aufgehoben, 1)
def test_kacheln_ausserhalb_der_geometrie_werden_uebersprungen(self):
"""Prüft, dass Kacheln ohne Schnitt mit dem Auswertebereich nicht abgefragt werden."""
layer = _Layer(_Bereich((0, 2), (0, 1)), [])
self.assertEqual(sammle_objekte(layer, _Geometrie(leer=True), 5000.0), [])
self.assertEqual(layer.aufrufe, 0)
def test_abbruch_liefert_none_und_hebt_auswahl_auf(self):
"""Prüft, dass ein Abbruch über den Fortschritt None liefert und die Auswahl auf dem Basemap-Layer trotzdem aufgehoben wird."""
layer = _Layer(_Bereich((0, 3), (0, 0)), [[], [], [], []])
fortschritt = []
def melde(nummer, gesamt):
fortschritt.append((nummer, gesamt))
return nummer < 2
self.assertIsNone(sammle_objekte(layer, _Geometrie(), 5000.0, fortschritt=melde))
self.assertEqual(fortschritt, [(1, 4), (2, 4)])
self.assertEqual(layer.aufrufe, 1)
self.assertEqual(layer.auswahl_aufgehoben, 1)
def test_anzahl_kacheln_aus_kachelbereich(self):
"""Prüft, dass die Kachelzahl aus dem Kachelbereich der Geometrie berechnet wird."""
self.assertEqual(anzahl_kacheln(_Layer(_Bereich((3, 5), (7, 10)), []), _Geometrie(), 5000.0), 12)
class TestZoomUndStile(unittest.TestCase):
def test_zoomstufen_wie_im_qgis_renderer(self):
"""Prüft, dass Kachelzoom begrenzt, Darstellungszoom unbegrenzt und der gebrochene Zoom aus dem Maßstab bestimmt werden."""
layer = _Layer(_Bereich((0, 0), (0, 0)), [])
self.assertEqual(zoomstufen(layer, 5000.0), (15, 16, 15.8))
self.assertEqual(layer.matrix.gefragt, [(5000.0, True), (5000.0, False)])
def test_stile_aus_layer_liest_renderer_styles(self):
"""Prüft, dass Name, Quell-Layer, Geometrietyp, Zoombereich, Schalter und Filter der Renderer-Styles übernommen werden."""
class Stil:
def styleName(self): return "Decker_Kreisstr"
def layerName(self): return "Verkehrslinie"
def geometryType(self): return 1
def minZoomLevel(self): return 9
def maxZoomLevel(self): return 21
def isEnabled(self): return False
def filterExpression(self): return "\"klasse\" IS 'Kreisstraße'"
layer = type("L", (), {"renderer": lambda s: type("R", (), {"styles": lambda s: [Stil()]})()})()
stil = stile_aus_layer(layer)[0]
self.assertEqual((stil.name, stil.layer_name, stil.geometrie, stil.min_zoom, stil.max_zoom, stil.aktiv),
("Decker_Kreisstr", "Verkehrslinie", 1, 9, 21, False))
self.assertIn("Kreisstraße", stil.filter)
if __name__ == "__main__":
unittest.main()
+327
View File
@@ -0,0 +1,327 @@
"""
Tests für modules/basemap_legende.py – Zuordnung, Auswahl und Ablauf der Basemap-Legende (Basis #88).
Die Kachelauswertung und die Layoutausgabe werden durch Fakes ersetzt; die echte Auswertung
gegen basemap.de ist im QGIS-Test geprüft (siehe Pull Request).
"""
import json
import unittest
from unittest.mock import patch
from sn_basis.functions.variable_wrapper import get_variable, set_variable
from sn_basis.modules import basemap_legende as bl
from sn_basis.modules.basemap_legende import (
AUSWAHL_VAR,
AUSWERTEBEREICH_DRUCKBEREICH,
AUSWERTEBEREICH_VAR,
AUSWERTEBEREICH_VERFAHRENSGEBIET,
BasemapLegende,
Katalog,
LEGENDE_ERZEUGEN_VAR,
LegendenEintrag,
ObjektInfo,
StilInfo,
bilde_eintraege,
ermittle_getroffene_stile,
ist_basemap_layer,
legende_erzeugen_aktiv,
lies_auswahl,
lies_auswertebereich,
merke_auswahl,
setze_auswertebereich,
sichtbare_layer_der_karte,
stil_ist_aktiv,
vorauswahl,
)
KATALOG = Katalog({
"gruppen": ["Verkehr", "Vegetation"],
"nie_in_legende": ["Hintergrund"],
"nie_in_legende_praefixe": ["Nummer_"],
"eintraege": [
{"schluessel": "kreis", "bezeichnung": "Kreisstraße", "gruppe": "Verkehr",
"treffer_stile": ["Kontur_Kreisstr", "Decker_Kreisstr", "Bruecke_Kreisstr_Decker"],
"symbol_stile": ["Kontur_Kreisstr", "Decker_Kreisstr"]},
{"schluessel": "wald", "bezeichnung": "Wald", "gruppe": "Vegetation",
"treffer_stile": ["VegetationsF_Wald"], "symbol_stile": ["VegetationsF_Wald"]},
{"schluessel": "weg", "bezeichnung": "Wirtschaftsweg", "gruppe": "Verkehr",
"treffer_stile": ["Kontur_Wirtschaftsweg"], "symbol_stile": ["Kontur_Wirtschaftsweg"]},
],
})
def _filter_ueber_attribute(filter_text, objekt):
"""Einfache Filterprüfung für Tests: Filter der Form klasse=Wert."""
feld, wert = filter_text.split("=")
return objekt.attribute.get(feld) == wert
def _leere_variablen():
for schluessel in (AUSWAHL_VAR, AUSWERTEBEREICH_VAR, LEGENDE_ERZEUGEN_VAR):
set_variable(schluessel, "", scope="project")
class TestStileUndEintraege(unittest.TestCase):
def test_stil_aktiv_nur_eingeschaltet_und_im_zoombereich(self):
"""Prüft, dass ein Style nur aktiv ist, wenn er eingeschaltet ist und die Zoomstufe im Bereich liegt (-1 = unbegrenzt)."""
stil = StilInfo("A", "L", 1, min_zoom=11, max_zoom=14)
self.assertTrue(stil_ist_aktiv(stil, 11))
self.assertFalse(stil_ist_aktiv(stil, 15))
self.assertFalse(stil_ist_aktiv(StilInfo("A", "L", 1, aktiv=False), 12))
self.assertTrue(stil_ist_aktiv(StilInfo("A", "L", 1), 20))
def test_getroffene_stile_pruefen_layer_geometrie_zoom_und_filter(self):
"""Prüft, dass ein Style nur Objekte seines Quell-Layers und Geometrietyps zählt, deren Filter zutrifft, und nur bei passender Zoomstufe."""
stile = [
StilInfo("Decker_Kreisstr", "Verkehrslinie", 1, filter="klasse=Kreisstraße"),
StilInfo("Decker_Bundesstr", "Verkehrslinie", 1, filter="klasse=Bundesstraße"),
StilInfo("VegetationsF_Wald", "Vegetationsflaeche", 2, filter="klasse=Wald"),
StilInfo("Nur_klein", "Verkehrslinie", 1, max_zoom=10),
StilInfo("Alle_Layer", "", 2),
]
objekte = [
ObjektInfo("Verkehrslinie", 1, {"klasse": "Kreisstraße"}),
ObjektInfo("Vegetationsflaeche", 2, {"klasse": "Wald"}),
ObjektInfo("Vegetationsflaeche", 1, {"klasse": "Wald"}), # Linie: passt nicht zum Flächenstyle
]
getroffen = ermittle_getroffene_stile(objekte, stile, 15, _filter_ueber_attribute)
self.assertEqual(getroffen, {"Decker_Kreisstr": 1, "VegetationsF_Wald": 1, "Alle_Layer": 1})
def test_eintraege_fassen_styles_zusammen_und_sortieren_nach_gruppe(self):
"""Prüft, dass mehrere Styles eines Katalogeintrags einen Eintrag ergeben und die Einträge nach Gruppen- und Katalogreihenfolge sortiert sind."""
eintraege = bilde_eintraege(
["VegetationsF_Wald", "Kontur_Wirtschaftsweg", "Decker_Kreisstr", "Bruecke_Kreisstr_Decker"], KATALOG)
self.assertEqual([e.schluessel for e in eintraege], ["kreis", "weg", "wald"])
self.assertEqual(eintraege[0].treffer, ["Decker_Kreisstr", "Bruecke_Kreisstr_Decker"])
self.assertEqual(eintraege[0].symbol_stile, ["Kontur_Kreisstr", "Decker_Kreisstr"])
def test_ausgeschlossene_und_unbekannte_styles(self):
"""Prüft, dass ausgeschlossene Styles entfallen und unbekannte Styles als nicht bekannter Eintrag in „Sonstiges“ landen."""
eintraege = bilde_eintraege(["Hintergrund", "Nummer_B6", "Eigener_Style"], KATALOG)
self.assertEqual(len(eintraege), 1)
self.assertEqual(eintraege[0].schluessel, "stil:Eigener_Style")
self.assertEqual(eintraege[0].gruppe, "Sonstiges")
self.assertFalse(eintraege[0].bekannt)
class TestAuswahlUndEinstellungen(unittest.TestCase):
def setUp(self):
_leere_variablen()
def tearDown(self):
_leere_variablen()
def test_vorauswahl_bekannte_an_unbekannte_aus(self):
"""Prüft, dass ohne gespeicherte Auswahl Katalogeinträge vorausgewählt und unbekannte Styles abgewählt sind."""
eintraege = [LegendenEintrag("kreis", "Kreisstraße", "Verkehr", []),
LegendenEintrag("stil:X", "X", "Sonstiges", [], bekannt=False)]
self.assertEqual(vorauswahl(eintraege), {"kreis"})
def test_auswahl_wird_gemerkt_und_vorbelegt(self):
"""Prüft, dass ab- und angewählte Einträge in der Projektvariable gespeichert und beim nächsten Mal so vorbelegt werden."""
eintraege = [LegendenEintrag("kreis", "Kreisstraße", "Verkehr", []),
LegendenEintrag("stil:X", "X", "Sonstiges", [], bekannt=False)]
merke_auswahl(eintraege, ["stil:X"])
self.assertEqual(lies_auswahl(), {"ausgeblendet": ["kreis"], "eingeblendet": ["stil:X"]})
self.assertEqual(vorauswahl(eintraege, lies_auswahl()), {"stil:X"})
def test_merken_ergaenzt_die_bisherige_auswahl(self):
"""Prüft, dass das Merken die Auswahl nicht angezeigter Einträge aus früheren Läufen behält."""
merke_auswahl([LegendenEintrag("wald", "Wald", "Vegetation", [])], [])
merke_auswahl([LegendenEintrag("kreis", "Kreisstraße", "Verkehr", [])], ["kreis"])
self.assertEqual(lies_auswahl()["ausgeblendet"], ["wald"])
def test_kaputte_projektvariable_ergibt_leere_auswahl(self):
"""Prüft, dass eine nicht lesbare Projektvariable als leere Auswahl gilt."""
set_variable(AUSWAHL_VAR, "{kein json", scope="project")
self.assertEqual(lies_auswahl(), {"ausgeblendet": [], "eingeblendet": []})
def test_auswertebereich_standard_und_umstellung(self):
"""Prüft, dass der Auswertebereich standardmäßig das Verfahrensgebiet ist, umgestellt werden kann und unbekannte Werte abgewiesen werden."""
self.assertEqual(lies_auswertebereich(), AUSWERTEBEREICH_VERFAHRENSGEBIET)
setze_auswertebereich(AUSWERTEBEREICH_DRUCKBEREICH)
self.assertEqual(lies_auswertebereich(), AUSWERTEBEREICH_DRUCKBEREICH)
with self.assertRaises(ValueError):
setze_auswertebereich("irgendwo")
def test_legende_erzeugen_standard_an(self):
"""Prüft, dass das Erzeugen der Basemap-Legende standardmäßig eingeschaltet ist und abgeschaltet werden kann."""
self.assertTrue(legende_erzeugen_aktiv())
bl.setze_legende_erzeugen(False)
self.assertFalse(legende_erzeugen_aktiv())
class _FakeVektorkachelLayer:
def __init__(self, quelle="", name="Layer"):
self._quelle, self._name = quelle, name
def tileMatrixSet(self):
return None
def source(self):
return self._quelle
def name(self):
return self._name
def crs(self):
return "EPSG:3857"
class _FakeVektorLayer:
def source(self):
return "https://sgx.geodatenzentrum.de/gdz_basemapde_vektor/wms"
def name(self):
return "Basemap.de WMS"
class TestLayerErkennung(unittest.TestCase):
def test_erkennung_ueber_quelle_und_namen(self):
"""Prüft, dass der Basemap-Layer an Vektorkacheln mit basemap.de-Quelle erkannt wird, der Name nur als Rückfall dient und andere Layertypen nicht zählen."""
self.assertTrue(ist_basemap_layer(_FakeVektorkachelLayer(
"type=xyz&url=https://sgx.geodatenzentrum.de/gdz_basemapde_vektor/tiles/v2/{z}/{x}/{y}.pbf")))
self.assertTrue(ist_basemap_layer(_FakeVektorkachelLayer("type=mbtiles", "Basemap.de - Farbe")))
self.assertFalse(ist_basemap_layer(_FakeVektorkachelLayer("type=xyz&url=https://andere.de", "OSM")))
self.assertFalse(ist_basemap_layer(_FakeVektorLayer()))
self.assertFalse(ist_basemap_layer(None))
def test_sichtbare_layer_aus_thema_feste_liste_oder_layerbaum(self):
"""Prüft, dass die Layer der Karte aus dem Kartenthema, sonst aus der festen Layerliste, sonst aus dem sichtbaren Layerbaum kommen."""
class Karte:
def __init__(self, thema="", feste=None):
self._thema, self._feste = thema, feste or []
def followVisibilityPreset(self): return bool(self._thema)
def followVisibilityPresetName(self): return self._thema
def keepLayerSet(self): return bool(self._feste)
def layers(self): return self._feste
class Knoten:
def __init__(self, layer, sichtbar): self._layer, self._sichtbar = layer, sichtbar
def layer(self): return self._layer
def isVisible(self): return self._sichtbar
class Projekt:
def mapThemeCollection(self):
return type("T", (), {"mapThemeVisibleLayers": lambda s, t: ["thema-" + t]})()
def layerTreeRoot(self):
return type("W", (), {"findLayers": lambda s: [Knoten("a", True), Knoten("b", False)]})()
self.assertEqual(sichtbare_layer_der_karte(Karte(thema="Plan"), Projekt()), ["thema-Plan"])
self.assertEqual(sichtbare_layer_der_karte(Karte(feste=["x"]), Projekt()), ["x"])
self.assertEqual(sichtbare_layer_der_karte(Karte(), Projekt()), ["a"])
class _FakeAusgabe:
"""Ersatz für basemap_legende_layout im Ablauftest."""
def __init__(self, karte):
self.karte = karte
self.geschrieben = None
def hauptkarte(self, layout):
return self.karte
def baue_symbole(self, layer, eintraege, zoom, vtz):
return {e.schluessel: f"symbol-{e.schluessel}" for e in eintraege}
def vorschau_icon(self, symbol):
return None
def schreibe_legende(self, layout, karte, eintraege, symbole, projekt, *, bereich, massstab):
self.geschrieben = {"eintraege": [e.schluessel for e in eintraege], "bereich": bereich, "massstab": massstab}
return ""
class _FakeKarte:
def scale(self):
return 5000.0
class TestAblauf(unittest.TestCase):
def setUp(self):
_leere_variablen()
self.dialog_aufrufe = []
self.meldungen = []
def tearDown(self):
_leere_variablen()
def _legende(self, dialog_ergebnis=("kreis",)):
def dialog(titel, hinweis, eintraege, vorauswahl):
self.dialog_aufrufe.append((eintraege, set(vorauswahl)))
return None if dialog_ergebnis is None else list(dialog_ergebnis)
return BasemapLegende(project=object(), katalog=KATALOG, dialog=dialog,
fortschritt_fabrik=lambda *a: None, bestaetigen=lambda *a: True,
melden=lambda titel, text: self.meldungen.append(text))
def _starte(self, legende, basemap=True, genutzt=AUSWERTEBEREICH_VERFAHRENSGEBIET, eintraege=None):
ausgabe = _FakeAusgabe(_FakeKarte())
layer = [_FakeVektorkachelLayer("gdz_basemapde_vektor", "Basemap.de - Farbe")] if basemap else []
eintraege = eintraege if eintraege is not None else bilde_eintraege(
["Decker_Kreisstr", "VegetationsF_Wald"], KATALOG)
with patch.dict("sys.modules", {"sn_basis.modules.basemap_legende_layout": ausgabe}), \
patch("sn_basis.modules.basemap_legende.sichtbare_layer_der_karte", return_value=layer), \
patch("sn_basis.modules.basemap_legende.auswertegeometrie", return_value=("geometrie", genutzt)), \
patch("sn_basis.modules.basemap_legende.zoomstufen", return_value=(15, 16, 15.8)), \
patch.object(BasemapLegende, "ermittle_eintraege", return_value=eintraege):
import sn_basis.modules as paket
with patch.object(paket, "basemap_legende_layout", ausgabe, create=True):
ergebnis = legende.aktualisiere(layout="layout")
return ergebnis, ausgabe
def test_ohne_basemap_layer_passiert_nichts(self):
"""Prüft, dass ohne sichtbaren Basemap-Layer weder Dialog noch Legende entstehen."""
ergebnis, ausgabe = self._starte(self._legende(), basemap=False)
self.assertEqual(ergebnis["status"], "kein_layer")
self.assertEqual(self.dialog_aufrufe, [])
self.assertIsNone(ausgabe.geschrieben)
def test_gewaehlte_eintraege_werden_geschrieben_und_gemerkt(self):
"""Prüft, dass nur die im Dialog gewählten Einträge in die Legende geschrieben und die Auswahl gespeichert wird."""
ergebnis, ausgabe = self._starte(self._legende(dialog_ergebnis=("kreis",)))
self.assertEqual(ergebnis["status"], "ok")
self.assertEqual(ausgabe.geschrieben["eintraege"], ["kreis"])
self.assertEqual(ausgabe.geschrieben["massstab"], 5000.0)
eintraege, vorbelegt = self.dialog_aufrufe[0]
self.assertEqual([e["gruppe"] for e in eintraege], ["Verkehr", "Vegetation"])
self.assertEqual(vorbelegt, {"kreis", "wald"})
self.assertEqual(json.loads(get_variable(AUSWAHL_VAR, scope="project"))["ausgeblendet"], ["wald"])
def test_abbruch_im_dialog_aendert_nichts(self):
"""Prüft, dass ein Abbruch im Auswahldialog die Legende nicht verändert und keine Auswahl speichert."""
ergebnis, ausgabe = self._starte(self._legende(dialog_ergebnis=None))
self.assertEqual(ergebnis["status"], "abgebrochen")
self.assertIsNone(ausgabe.geschrieben)
self.assertFalse(get_variable(AUSWAHL_VAR, scope="project"))
def test_rueckfall_auf_druckbereich_wird_gemeldet(self):
"""Prüft, dass ohne Verfahrensgebiet auf den Druckbereich zurückgefallen und das gemeldet wird."""
ergebnis, ausgabe = self._starte(self._legende(), genutzt=AUSWERTEBEREICH_DRUCKBEREICH)
self.assertEqual(ergebnis["status"], "ok")
self.assertEqual(ausgabe.geschrieben["bereich"], AUSWERTEBEREICH_DRUCKBEREICH)
self.assertTrue(any("Druckbereich" in m for m in self.meldungen))
def test_ohne_objekte_keine_legende(self):
"""Prüft, dass ohne Basemap-Objekte im Auswertebereich weder Dialog noch Legende entstehen."""
ergebnis, ausgabe = self._starte(self._legende(), eintraege=[])
self.assertEqual(ergebnis["status"], "keine_eintraege")
self.assertEqual(self.dialog_aufrufe, [])
self.assertIsNone(ausgabe.geschrieben)
def test_viele_kacheln_werden_vorher_bestaetigt(self):
"""Prüft, dass vor einer Auswertung mit sehr vielen Kacheln nachgefragt wird und eine Ablehnung die Auswertung abbricht."""
legende = BasemapLegende(project=object(), katalog=KATALOG, dialog=lambda *a: [],
fortschritt_fabrik=lambda *a: self.fail("keine Auswertung erwartet"),
bestaetigen=lambda *a: False, melden=lambda *a: None)
with patch("sn_basis.modules.basemap_legende.anzahl_kacheln", return_value=bl.KACHELN_NACHFRAGEN_AB):
self.assertIsNone(legende.ermittle_eintraege(object(), object(), 5000.0))
if __name__ == "__main__":
unittest.main()
+226
View File
@@ -0,0 +1,226 @@
"""
Tests für modules/basemap_legende_layout.py – Basemap-Legende im Drucklayout (Basis #88).
Layout, Legende und Projekt sind Fakes. Aussehen der Symbole und Platzierung im echten Layout
sind im QGIS-Test geprüft (siehe Pull Request).
"""
import json
import unittest
from unittest.mock import patch
from sn_basis.modules import basemap_legende_layout as bll
from sn_basis.modules.basemap_legende import LegendenEintrag
def _wert(objekt, name):
"""Liest x/y/width/height – im Mock-Modus Attribute, in QGIS Methoden."""
wert = getattr(objekt, name)
return wert() if callable(wert) else wert
class _Rechteck:
def __init__(self, x, y, b, h): self.w = (x, y, b, h)
def x(self): return self.w[0]
def y(self): return self.w[1]
def width(self): return self.w[2]
def height(self): return self.w[3]
class _Element:
def __init__(self, x=0.0, y=0.0, b=0.0, h=0.0):
self.rechteck = _Rechteck(x, y, b, h)
def sceneBoundingRect(self): return self.rechteck
class _Seite:
def __init__(self, breite): self._breite = breite
def pageSize(self): return type("S", (), {"width": lambda s: self._breite})()
class _Layout:
def __init__(self, elemente=None, breite=841.0, name="Plan"):
self.elemente = dict(elemente or {})
self.eigenschaften = {}
self.entfernt = []
self._breite, self._name = breite, name
def itemById(self, element_id): return self.elemente.get(element_id)
def items(self): return list(self.elemente.values())
def setCustomProperty(self, k, v): self.eigenschaften[k] = v
def customProperty(self, k, standard=None): return self.eigenschaften.get(k, standard)
def removeLayoutItem(self, element): self.entfernt.append(element)
def name(self): return self._name
def pageCollection(self): return type("P", (), {"page": lambda s, i: _Seite(self._breite)})()
class _Legende(_Element):
"""Legende, deren Platzbedarf je Spaltenzahl vorgegeben ist."""
def __init__(self):
super().__init__()
self.spalten = 1
self.bewegt = None
self.groesse = None
def setColumnCount(self, n): self.spalten = n
def setSplitLayer(self, wert): pass
def setEqualColumnWidth(self, wert): pass
def setReferencePoint(self, punkt): pass
def attemptMove(self, punkt): self.bewegt = punkt
def attemptResize(self, groesse): self.groesse = groesse
class TestKennzeichenUndKarte(unittest.TestCase):
def test_layout_kennzeichen_setzen_und_erkennen(self):
"""Prüft, dass ein markiertes Layout als Nutzer der Basemap-Legende erkannt und in der Projektliste geführt wird."""
markiert, normal = _Layout(name="§41 Plan"), _Layout(name="Baufreigabe")
bll.markiere_layout(markiert)
self.assertTrue(bll.nutzt_basemap_legende(markiert))
self.assertFalse(bll.nutzt_basemap_legende(normal))
projekt = type("P", (), {"layoutManager": lambda s: type("M", (), {
"printLayouts": lambda s: [markiert, normal]})()})()
self.assertEqual(bll.layouts_mit_basemap_legende(projekt), ["§41 Plan"])
def test_hauptkarte_ueber_id(self):
"""Prüft, dass die Karte mit der ID „Hauptkarte“ gefunden wird."""
karte = _Element()
self.assertIs(bll.hauptkarte(_Layout({"Hauptkarte": karte})), karte)
self.assertIsNone(bll.hauptkarte(_Layout()))
class TestSymbolwahl(unittest.TestCase):
def test_symbolnamen_aus_aktiven_symbol_stilen_in_renderer_reihenfolge(self):
"""Prüft, dass die Symbol-Styles des Katalogs in Zeichenreihenfolge genommen und inaktive übergangen werden."""
eintrag = LegendenEintrag("kreis", "Kreisstraße", "Verkehr", ["Decker_Kreisstr", "Kontur_Kreisstr"],
treffer=["Bruecke_Kreisstr_Decker"])
aktive = {"Kontur_Kreisstr": 3, "Decker_Kreisstr": 7, "Bruecke_Kreisstr_Decker": 9}
self.assertEqual(bll.waehle_symbolnamen(eintrag, aktive), ["Kontur_Kreisstr", "Decker_Kreisstr"])
def test_ohne_aktive_symbol_stile_erster_getroffener_style(self):
"""Prüft, dass ohne aktiven Symbol-Style der erste tatsächlich getroffene Style das Legendensymbol liefert."""
eintrag = LegendenEintrag("kreis", "Kreisstraße", "Verkehr", ["Decker_Kreisstr"],
treffer=["Bruecke_Kreisstr_Decker", "Tunnel_Kreisstr"])
aktive = {"Bruecke_Kreisstr_Decker": 9, "Tunnel_Kreisstr": 2}
self.assertEqual(bll.waehle_symbolnamen(eintrag, aktive), ["Bruecke_Kreisstr_Decker"])
class _Symbol:
def __init__(self, typ): self._typ = typ
def type(self): return self._typ
class _Projekt:
def __init__(self):
self.layer = {}
def addMapLayer(self, layer, in_baum=True):
layer._in_baum = in_baum
self.layer[layer.id()] = layer
def mapLayer(self, layer_id): return self.layer.get(layer_id)
def removeMapLayer(self, layer_id): self.layer.pop(layer_id, None)
class _Speicherlayer:
zaehler = 0
def __init__(self, uri, name, provider):
_Speicherlayer.zaehler += 1
self.uri, self._name, self._id = uri, name, f"hl{_Speicherlayer.zaehler}"
self.eigenschaften = {}
self.renderer = None
def id(self): return self._id
def setRenderer(self, renderer): self.renderer = renderer
def setCustomProperty(self, k, v): self.eigenschaften[k] = v
class TestHilfslayer(unittest.TestCase):
@patch("sn_basis.modules.basemap_legende_layout.QgsVectorLayer", _Speicherlayer)
def test_je_gruppe_und_geometrietyp_ein_leerer_hilfslayer(self):
"""Prüft, dass je Gruppe und Geometrietyp ein Hilfslayer außerhalb des Layerbaums entsteht, Flächen vor Linien, ohne Rückfrage beim Schließen."""
eintraege = [LegendenEintrag("kreis", "Kreisstraße", "Verkehr", []),
LegendenEintrag("platz", "Platz", "Verkehr", []),
LegendenEintrag("wald", "Wald", "Vegetation", []),
LegendenEintrag("ohne", "Ohne Symbol", "Vegetation", [])]
symbole = {"kreis": _Symbol(1), "platz": _Symbol(2), "wald": _Symbol(2)}
projekt = _Projekt()
gruppen = bll.erzeuge_hilfslayer(projekt, "Plan", "EPSG:25833", eintraege, symbole)
self.assertEqual([g for g, _ in gruppen], ["Verkehr", "Vegetation"])
verkehr = gruppen[0][1]
self.assertTrue(verkehr[0].uri.startswith("Polygon?crs=EPSG:25833"))
self.assertTrue(verkehr[1].uri.startswith("LineString?"))
self.assertEqual([k.label() for k in verkehr[1].renderer.categories()], ["Kreisstraße"])
self.assertEqual(len(gruppen[1][1]), 1)
for layer in projekt.layer.values():
self.assertFalse(layer._in_baum)
self.assertEqual(layer.eigenschaften["skipMemoryLayersCheck"], 1)
self.assertEqual(layer.eigenschaften[bll.HILFSLAYER_KENNUNG], "Plan")
def test_alte_hilfslayer_werden_entfernt(self):
"""Prüft, dass die Hilfslayer einer früheren Basemap-Legende des Layouts aus dem Projekt entfernt werden."""
projekt = _Projekt()
alt = _Speicherlayer("Point", "alt", "memory")
projekt.addMapLayer(alt, False)
layout = _Layout()
layout.setCustomProperty(bll.LAYOUT_HILFSLAYER, f"{alt.id()},gibt-es-nicht")
bll.entferne_hilfslayer(projekt, layout)
self.assertEqual(projekt.layer, {})
def test_ohne_eintraege_wird_vorhandene_basemap_legende_entfernt(self):
"""Prüft, dass ohne gewählte Einträge eine vorhandene Basemap-Legende entfernt und die Auswahl am Layout vermerkt wird."""
vorhandene = _Legende()
layout = _Layout({bll.LEGENDE_ID: vorhandene})
hinweis = bll.schreibe_legende(layout, _Element(), [], {}, _Projekt(), bereich="druckbereich", massstab=2500)
self.assertEqual(hinweis, "")
self.assertEqual(layout.entfernt, [vorhandene])
self.assertEqual(layout.eigenschaften[bll.LAYOUT_BEREICH], "druckbereich")
self.assertEqual(json.loads(layout.eigenschaften[bll.LAYOUT_EINTRAEGE]), [])
self.assertTrue(bll.nutzt_basemap_legende(layout))
class TestPlatzierung(unittest.TestCase):
def _layout(self, breite=841.0):
return _Layout({
bll.HAUPTLEGENDE_ID: _Element(380, 10, 180, 50),
bll.QUELLENANGABE_ID: _Element(380, 198, 180, 20),
bll.HAUPTKARTE_ID: _Element(10, 10, 360, 310),
}, breite=breite)
def _platziere(self, layout, bedarf):
legende = _Legende()
with patch.object(bll, "mindestgroesse", side_effect=lambda l, lay, n: bedarf[n]):
hinweis = bll.platziere(layout, legende)
return legende, hinweis
def test_einspaltig_ueber_der_quellenangabe(self):
"""Prüft, dass die Basemap-Legende einspaltig unten an der Quellenangabe verankert wird, wenn der Platz reicht."""
legende, hinweis = self._platziere(self._layout(), {1: (100.0, 120.0)})
self.assertEqual(hinweis, "")
self.assertEqual(legende.spalten, 1)
self.assertEqual((_wert(legende.bewegt, "x"), _wert(legende.bewegt, "y")), (380, 198 - bll.ABSTAND_MM))
self.assertEqual((_wert(legende.groesse, "width"), _wert(legende.groesse, "height")), (100.0, 120.0))
def test_mehrspaltig_wenn_hoehe_nicht_reicht(self):
"""Prüft, dass die Legende zwei- oder dreispaltig wird, wenn sie einspaltig nicht zwischen Hauptlegende und Quellenangabe passt."""
legende, hinweis = self._platziere(self._layout(), {1: (100.0, 300.0), 2: (200.0, 150.0), 3: (300.0, 100.0)})
self.assertEqual(hinweis, "")
self.assertEqual(legende.spalten, 3)
def test_nie_breiter_als_der_seitenrand_mit_hinweis(self):
"""Prüft, dass keine Spaltenzahl gewählt wird, die über den Seitenrand ragt, und dann ein Hinweis kommt."""
legende, hinweis = self._platziere(self._layout(breite=600.0),
{1: (100.0, 300.0), 2: (200.0, 160.0), 3: (300.0, 100.0)})
self.assertEqual(legende.spalten, 2)
self.assertIn("mehr Platz", hinweis)
if __name__ == "__main__":
unittest.main()