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:
@@ -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]
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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", ""))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user