Merge pull request 'Feature/88 basemap legende' (#89) from feature/88-basemap-legende into unstable
Reviewed-on: #89
This commit was merged in pull request #89.
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
- Basemap-Legende für Drucklayouts (#88): Beim Erstellen einer §38-/§41-Vorlage wird ausgewertet, welche Objekte des basemap.de-Vektorkachel-Layers im Verfahrensgebiet (wahlweise im Druckbereich) tatsächlich dargestellt werden; ein Auswahldialog lässt Kategorien abwählen, die gewählten erscheinen mit der Signatur des Projektlayers in einem eigenen Legendenelement „Kartengrundlage (basemap.de)“ über der Quellenangabe. Zuordnung von 434 Basemap-Styles zu 190 Legendeneinträgen (Beschriftungen, Nummernschilder und Hintergrund ausgenommen) in `data/basemap_legende.json` (erzeugt mit `modules/basemap_katalog.py`, fachlich nachbearbeitet)
|
||||
- Neuer einklappbarer Bereich „Basemap“ im Druck-Tab: Auswertebereich, Basemap-Legende beim Erstellen an/aus, „Basemap-Legende aktualisieren“ für bestehende Vorlagen und lokale Stilvorlagen des Basemap-Layers (mehrere Varianten, laden, speichern, als Standard markieren, löschen) – z. B. einmal Hausnummern ausschalten und in jedem Projekt wieder laden (#88)
|
||||
- Neuer allgemeiner Dialog `ask_mehrfachauswahl` (Liste mit Häkchen, Gruppen, Suche, Symbolvorschau) in `functions/dialog_wrapper.py` (#88)
|
||||
- Bugfix: `Navigation.remove_all()` ruft jetzt `deleteLater()` auf Menü und Toolbar auf, bevor die Referenzen verworfen werden – vorher blieb die Toolbar beim Unload als Kind von main_window bestehen und der Plugin Reloader meldete beim Neuladen "removing duplicated widget(s) not cleaned up by the plugin during unload: LnoSachsenToolbar" (#72)
|
||||
- Restliche „ungeklärt"-Funktionen aus dem Prüfstand entschieden: Tests für Dateipruefer (`_ist_leer`, Priorität in `_handle_leere_eingabe`), Datenschreiber (`_store_style_in_gpkg` – Tabelle anlegen, Default-Stil zurücksetzen, Geometriespalte übernehmen) und Pruefmanager (`_handle_verfahrensgebiet_layer_existiert` „entfernen"/„ersetzen"); Linkpruefer (`_ist_url`, `_pruefe_url`, `_pruefe_dateipfad`), `Pruefmanager._handle_datei_existiert` und `Pruefmanager._ask_use_or_replace_pufferlayer` (toter Code, Bug #60) bekamen den Vermerk „von Test abgesehen" – sn_basis steht jetzt bei 0 ungeklärten Funktionen (#70)
|
||||
- Kategorie „vielleicht testen" abgeschlossen: 30 weitere Funktionen bekamen Tests (os_wrapper, qgiscore_wrapper, qgisui_wrapper, DataGrabber, pruef_ergebnis, Navigation, SettingsTab, PrintTab – Zustandslogik, Signal-Handling, Atlas-/Theme-Verwaltung), `PrintTab._build_ui` bekam den Vermerk „Test ausgesetzt, zunächst Refactor" (#67)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,418 @@
|
||||
"""
|
||||
sn_basis/modules/basemap_katalog.py – Legendenkatalog für basemap.de erzeugen
|
||||
|
||||
Erzeugt aus der Style-JSON von basemap.de (``bm_web_col.json``) den Legendenkatalog
|
||||
``data/basemap_legende.json``. Der Katalog ordnet jede Style-ID genau einem
|
||||
Legendeneintrag zu (z. B. gehören ``Kontur_Kreisstr``, ``Decker_Kreisstr`` und
|
||||
``Bruecke_Kreisstr_Decker`` zum Eintrag „Kreisstraße“) und legt fest, welche
|
||||
Styles zusammen das Legendensymbol bilden.
|
||||
|
||||
Das Modul nutzt nur die Standardbibliothek und lässt sich auch eigenständig
|
||||
aufrufen, um den Katalog nach einer neuen Basemap-Stilversion zu erneuern::
|
||||
|
||||
python modules/basemap_katalog.py # lädt die Style-JSON und schreibt data/basemap_legende.json
|
||||
python modules/basemap_katalog.py --stil datei.json
|
||||
|
||||
Bestehende fachliche Nachbearbeitung (Bezeichnungen, Gruppen, Reihenfolge,
|
||||
Zuordnung einzelner Style-IDs) bleibt bei einem erneuten Lauf erhalten.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
STIL_URL = "https://sgx.geodatenzentrum.de/gdz_basemapde_vektor/styles/bm_web_col.json"
|
||||
|
||||
# Style-IDs, die nie in die Legende gehören (Flächenfüllung des Kartenhintergrunds).
|
||||
NIE_IN_LEGENDE = ["Hintergrund"]
|
||||
|
||||
# Präfixe von Style-IDs, die nie in die Legende gehören: Beschriftungen und Straßennummernschilder
|
||||
# (``Nummer_*`` kommen als Symbol mit Schildhintergrund im Renderer an).
|
||||
NIE_IN_LEGENDE_PRAEFIXE = ["Name_", "Nummer_"]
|
||||
|
||||
# Bedingungen, die einen eigenen Legendeneintrag begründen. Alle anderen Filterbedingungen
|
||||
# (bauwerk, fahrbahn, anzahl, fahrbahnachse …) sind Darstellungsvarianten desselben Eintrags.
|
||||
BEDEUTUNGSTRAGENDE_ATTRIBUTE = ("klasse", "zustand", "merkmal", "zone", "art")
|
||||
|
||||
# Teile einer Style-ID, die nur eine Darstellungsvariante kennzeichnen.
|
||||
VARIANTEN_KENNUNGEN = (
|
||||
"Bruecke", "Bruecken", "Tunnel", "Staumauer", "Staudamm", "getrennt", "getr", "Fahrbahnachse",
|
||||
"FSZ1", "einblenden", "Kontur", "Decker", "Fuellung", "ohne", "Linie", "Punkte", "Kreise", "Enden",
|
||||
"Versatz", "Symbol", "L", "F", "P",
|
||||
)
|
||||
|
||||
# Rollen von Style-Layern, die zusammen ein Legendensymbol bilden (je Rolle ein Style).
|
||||
SYMBOL_ROLLEN = ("Kontur", "Decker", "Fuellung", "Linie", "Punkte", "Kreise", "Enden", "Versatz")
|
||||
|
||||
# Objektfamilie je Quell-Layer: Fläche, Linie und Punkt derselben Objektart bilden einen Eintrag.
|
||||
_FAMILIE_JE_QUELL_LAYER = {
|
||||
"Bauwerksflaeche": "Bauwerk", "Bauwerkslinie": "Bauwerk", "Bauwerkspunkt": "Bauwerk",
|
||||
"Gewaesserflaeche": "Gewaesser", "Gewaesserlinie": "Gewaesser", "Gewaesserpunkt": "Gewaesser",
|
||||
"Reliefflaeche": "Relief", "Relieflinie": "Relief", "Reliefpunkt": "Relief",
|
||||
"Historische_Flaeche": "Historisch", "Historische_Linie": "Historisch", "Historischer_Punkt": "Historisch",
|
||||
"Besondere_Flaeche": "Besonders", "Besondere_Linie": "Besonders", "Besonderer_Punkt": "Besonders",
|
||||
"Vegetationsflaeche": "Vegetation", "Vegetationslinie": "Vegetation", "Vegetationspunkt": "Vegetation",
|
||||
"Verkehrsflaeche": "Verkehr", "Verkehrslinie": "Verkehr", "Verkehrspunkt": "Verkehr",
|
||||
"Gebaeudeflaeche": "Gebaeude", "Gebaeudepunkt": "Gebaeude",
|
||||
"Grenze_Flaeche": "Grenze", "Grenze_Linie": "Grenze", "Grenze_Punkt": "Grenze",
|
||||
"Siedlungsflaeche": "Siedlung", "Weitere_Nutzung_Flaeche": "Siedlung",
|
||||
"Versorgungslinie": "Versorgung", "Versorgungspunkt": "Versorgung",
|
||||
}
|
||||
|
||||
GRUPPEN = [
|
||||
"Verkehr",
|
||||
"Gewässer",
|
||||
"Vegetation",
|
||||
"Siedlung und Nutzung",
|
||||
"Gebäude",
|
||||
"Bauwerke",
|
||||
"Relief",
|
||||
"Grenzen und Schutzgebiete",
|
||||
"Historisches",
|
||||
"Versorgung",
|
||||
"Sonstiges",
|
||||
]
|
||||
|
||||
_GRUPPE_JE_QUELL_LAYER = {
|
||||
"Verkehrslinie": "Verkehr",
|
||||
"Verkehrsflaeche": "Verkehr",
|
||||
"Verkehrspunkt": "Verkehr",
|
||||
"Gewaesserlinie": "Gewässer",
|
||||
"Gewaesserflaeche": "Gewässer",
|
||||
"Gewaesserpunkt": "Gewässer",
|
||||
"Vegetationsflaeche": "Vegetation",
|
||||
"Vegetationslinie": "Vegetation",
|
||||
"Vegetationspunkt": "Vegetation",
|
||||
"Siedlungsflaeche": "Siedlung und Nutzung",
|
||||
"Weitere_Nutzung_Flaeche": "Siedlung und Nutzung",
|
||||
"Gebaeudeflaeche": "Gebäude",
|
||||
"Gebaeudepunkt": "Gebäude",
|
||||
"Bauwerksflaeche": "Bauwerke",
|
||||
"Bauwerkslinie": "Bauwerke",
|
||||
"Bauwerkspunkt": "Bauwerke",
|
||||
"Barrierenlinie": "Bauwerke",
|
||||
"Reliefflaeche": "Relief",
|
||||
"Relieflinie": "Relief",
|
||||
"Reliefpunkt": "Relief",
|
||||
"Grenze_Flaeche": "Grenzen und Schutzgebiete",
|
||||
"Grenze_Linie": "Grenzen und Schutzgebiete",
|
||||
"Grenze_Punkt": "Grenzen und Schutzgebiete",
|
||||
"Historische_Flaeche": "Historisches",
|
||||
"Historische_Linie": "Historisches",
|
||||
"Historischer_Punkt": "Historisches",
|
||||
"Besondere_Flaeche": "Historisches",
|
||||
"Besondere_Linie": "Historisches",
|
||||
"Besonderer_Punkt": "Historisches",
|
||||
"Versorgungslinie": "Versorgung",
|
||||
"Versorgungspunkt": "Versorgung",
|
||||
}
|
||||
|
||||
# Ersetzungen, um aus ASCII-Style-IDs lesbare deutsche Bezeichnungen zu bilden.
|
||||
_UMLAUT_ERSETZUNGEN = (
|
||||
("Strasse", "Straße"), ("Str", "Straße"), ("strasse", "straße"), ("Fuss", "Fuß"), ("fuss", "fuß"),
|
||||
("Gewaesser", "Gewässer"), ("gewaesser", "gewässer"), ("Flaeche", "Fläche"), ("flaeche", "fläche"),
|
||||
("Gruen", "Grün"), ("gruen", "grün"), ("Gehoelz", "Gehölz"), ("Bruecke", "Brücke"),
|
||||
("Uebung", "Übung"), ("uebung", "übung"), ("Foerder", "Förder"), ("foerder", "förder"),
|
||||
("Behaelter", "Behälter"), ("Schiess", "Schieß"), ("oeffentlich", "öffentlich"),
|
||||
("Daemme", "Dämme"), ("Fliess", "Fließ"), ("Geroell", "Geröll"), ("Dueker", "Düker"),
|
||||
("Gaenger", "Gänger"), ("gaenger", "gänger"), ("Uebergang", "Übergang"), ("Hoehe", "Höhe"),
|
||||
("hoehe", "höhe"), ("Schleuse", "Schleuse"), ("Faehr", "Fähr"), ("Muehle", "Mühle"),
|
||||
("Sumpf", "Sumpf"), ("Huegel", "Hügel"), ("huegel", "hügel"), ("Aussichts", "Aussichts"),
|
||||
("Schoepf", "Schöpf"), ("Gelaende", "Gelände"), ("gelaende", "gelände"), ("Oe", "Ö"),
|
||||
)
|
||||
|
||||
_ZUSTAND_KURZ = {
|
||||
"Im Bau": "im Bau",
|
||||
"Außer Betrieb, stillgelegt, verlassen": "außer Betrieb",
|
||||
}
|
||||
|
||||
|
||||
def lade_json(pfad: str) -> dict:
|
||||
with open(pfad, encoding="utf-8") as datei:
|
||||
return json.load(datei)
|
||||
|
||||
|
||||
def _sammle_bedingungen(filter_ausdruck: Any, bedingungen: dict[str, set[str]]) -> None:
|
||||
"""Sammelt ``==``/``in``-Bedingungen eines Mapbox-Filters (rekursiv über ``all``/``any``)."""
|
||||
if not isinstance(filter_ausdruck, list) or not filter_ausdruck:
|
||||
return
|
||||
operator = filter_ausdruck[0]
|
||||
if operator in ("==", "in") and len(filter_ausdruck) >= 3 and isinstance(filter_ausdruck[1], str):
|
||||
bedingungen.setdefault(filter_ausdruck[1], set()).update(str(w) for w in filter_ausdruck[2:])
|
||||
elif operator in ("all", "any"):
|
||||
for teil in filter_ausdruck[1:]:
|
||||
_sammle_bedingungen(teil, bedingungen)
|
||||
|
||||
|
||||
def ist_renderer_stil(stil: dict) -> bool:
|
||||
"""Ob ein Style-Layer in QGIS als Renderer-Style (und nicht als Beschriftung) ankommt.
|
||||
|
||||
Reine Beschriftungen (``symbol`` mit ``text-field`` ohne ``icon-image``) landen beim
|
||||
Import in ``QgsVectorTileBasicLabeling``; ``fill-extrusion`` (3D-Gebäude) übernimmt QGIS
|
||||
nicht. Beides erscheint nie in der Legende. Geprüft gegen QGIS 3.44 (02.10.2026).
|
||||
"""
|
||||
typ = stil.get("type")
|
||||
if typ in ("fill", "line", "circle"):
|
||||
return True
|
||||
if typ == "symbol":
|
||||
return "icon-image" in (stil.get("layout") or {})
|
||||
return False
|
||||
|
||||
|
||||
def ist_ausgeschlossen(stil_id: str, nie_in_legende: list[str], praefixe: list[str]) -> bool:
|
||||
"""Ob eine Style-ID nie in die Legende gehört (Ausschlussliste oder Präfix)."""
|
||||
return stil_id in nie_in_legende or any(stil_id.startswith(p) for p in praefixe)
|
||||
|
||||
|
||||
def normalisiere_stil_id(stil_id: str) -> str:
|
||||
"""Entfernt Variantenkennungen (Kontur, Decker, Brücke …) aus einer Style-ID."""
|
||||
teile = [t for t in stil_id.split("_")
|
||||
if t and t not in VARIANTEN_KENNUNGEN and not re.fullmatch(r"\d+m", t)]
|
||||
return "_".join(teile) or stil_id
|
||||
|
||||
|
||||
def _slug(text: str) -> str:
|
||||
text = text.lower()
|
||||
for alt, neu in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")):
|
||||
text = text.replace(alt, neu)
|
||||
return re.sub(r"[^a-z0-9]+", "_", text).strip("_")
|
||||
|
||||
|
||||
def _bezeichnung_aus_id(stil_id: str) -> str:
|
||||
name = normalisiere_stil_id(stil_id)
|
||||
teile = name.split("_")
|
||||
# Präfixe wie "SiedlungF", "VerkehrsL", "Symbol", "BauwerkP" tragen keine Bedeutung für die Legende.
|
||||
while len(teile) > 1 and (teile[0] == "Symbol" or re.fullmatch(r"[A-Z][a-z]+[FLP]", teile[0])
|
||||
or teile[0] in ("Gebaeude2D", "Gebaeudepunkt", "Verkehrsflaeche", "Gewaesser",
|
||||
"IstWeitereNutzung", "Grenzen")):
|
||||
teile = teile[1:]
|
||||
woerter = []
|
||||
for teil in teile:
|
||||
for alt, neu in _UMLAUT_ERSETZUNGEN:
|
||||
if teil == alt or teil.startswith(alt):
|
||||
teil = neu + teil[len(alt):]
|
||||
break
|
||||
woerter.append(teil)
|
||||
return " ".join(woerter)
|
||||
|
||||
|
||||
def _bezeichnung(stil_id: str, bedingungen: dict[str, set[str]]) -> str:
|
||||
klassen = sorted(bedingungen.get("klasse", set()))
|
||||
if 1 <= len(klassen) <= 2:
|
||||
text = " / ".join(klassen)
|
||||
else:
|
||||
text = _bezeichnung_aus_id(stil_id)
|
||||
if "merkmal" in bedingungen:
|
||||
text += ", nicht ständig wasserführend" if any("ständig" in m for m in bedingungen["merkmal"]) else ""
|
||||
zustaende = sorted(_ZUSTAND_KURZ.get(z, z) for z in bedingungen.get("zustand", set()))
|
||||
if zustaende:
|
||||
text += f" ({' / '.join(zustaende)})"
|
||||
return text
|
||||
|
||||
|
||||
def eintrags_schluessel(stil: dict) -> tuple[str, str]:
|
||||
"""Bildet Schlüssel und Bezeichnung des Legendeneintrags, zu dem ein Style-Layer gehört."""
|
||||
bedingungen: dict[str, set[str]] = {}
|
||||
_sammle_bedingungen(stil.get("filter"), bedingungen)
|
||||
quell_layer = stil.get("source-layer", "")
|
||||
familie = _FAMILIE_JE_QUELL_LAYER.get(quell_layer, quell_layer)
|
||||
bedeutung = {k: v for k, v in bedingungen.items() if k in BEDEUTUNGSTRAGENDE_ATTRIBUTE}
|
||||
if "klasse" in bedeutung:
|
||||
teile = [familie] + [f"{k}={'|'.join(sorted(bedeutung[k]))}" for k in sorted(bedeutung)]
|
||||
else:
|
||||
teile = [familie, normalisiere_stil_id(stil.get("id", ""))] + [
|
||||
f"{k}={'|'.join(sorted(bedeutung[k]))}" for k in sorted(bedeutung)]
|
||||
schluessel = _slug("_".join(teile))
|
||||
if len(schluessel) > 60:
|
||||
# Lange Klassenlisten kürzen, Eindeutigkeit über die normalisierte Style-ID.
|
||||
schluessel = _slug(f"{familie}_{normalisiere_stil_id(stil.get('id', ''))}")
|
||||
return schluessel, _bezeichnung(stil.get("id", ""), bedingungen)
|
||||
|
||||
|
||||
def _ist_grundvariante(stil_id: str) -> bool:
|
||||
"""Ob eine Style-ID die Normaldarstellung (keine Brücke, kein Tunnel, keine Sonderfahrbahn) zeigt."""
|
||||
teile = set(stil_id.split("_"))
|
||||
return not teile & {"Bruecke", "Bruecken", "Tunnel", "Staumauer", "getrennt", "getr", "Fahrbahnachse",
|
||||
"FSZ1", "einblenden"}
|
||||
|
||||
|
||||
def waehle_symbol_stile(treffer_stile: list[str]) -> list[str]:
|
||||
"""Wählt die Styles, die zusammen das Legendensymbol bilden.
|
||||
|
||||
Aus den Grundvarianten (keine Brücke, kein Tunnel, keine Sonderfahrbahn) wird je Rolle
|
||||
(Kontur, Decker, Füllung …) der erste Style genommen, z. B. ``Kontur_Kreisstr`` +
|
||||
``Decker_Kreisstr``. Ohne Grundvariante gelten alle Treffer-Styles als Ausgangsmenge.
|
||||
"""
|
||||
kandidaten = [s for s in treffer_stile if _ist_grundvariante(s)] or list(treffer_stile)
|
||||
gewaehlt: list[str] = []
|
||||
rollen: set[str] = set()
|
||||
for stil_id in kandidaten:
|
||||
teile = stil_id.split("_")
|
||||
rolle = next((r for r in SYMBOL_ROLLEN if r in teile), "basis")
|
||||
if rolle in rollen:
|
||||
continue
|
||||
rollen.add(rolle)
|
||||
gewaehlt.append(stil_id)
|
||||
return gewaehlt
|
||||
|
||||
|
||||
def erzeuge_katalog(stil_json: dict, bisher: dict | None = None) -> tuple[dict, list[str]]:
|
||||
"""Erzeugt den Legendenkatalog aus der Style-JSON.
|
||||
|
||||
``bisher`` ist ein bereits vorhandener (ggf. fachlich nachbearbeiteter) Katalog. Seine
|
||||
Zuordnung Style-ID → Eintrag, Bezeichnungen, Gruppen und Reihenfolge bleiben erhalten;
|
||||
neue Style-IDs werden ergänzt, entfallene entfernt.
|
||||
|
||||
Returns
|
||||
-------
|
||||
(katalog, meldungen)
|
||||
"""
|
||||
meldungen: list[str] = []
|
||||
bisher = bisher or {}
|
||||
bisherige_eintraege = {e["schluessel"]: e for e in bisher.get("eintraege", [])}
|
||||
bisherige_zuordnung = {
|
||||
stil_id: e["schluessel"] for e in bisher.get("eintraege", []) for stil_id in e.get("treffer_stile", [])
|
||||
}
|
||||
nie = list(dict.fromkeys(list(bisher.get("nie_in_legende", [])) + NIE_IN_LEGENDE))
|
||||
praefixe = list(dict.fromkeys(list(bisher.get("nie_in_legende_praefixe", [])) + NIE_IN_LEGENDE_PRAEFIXE))
|
||||
|
||||
eintraege: dict[str, dict] = {}
|
||||
reihenfolge_neu: list[str] = []
|
||||
gueltige_ids: set[str] = set()
|
||||
|
||||
for stil in stil_json.get("layers", []):
|
||||
stil_id = stil.get("id", "")
|
||||
if not stil_id or ist_ausgeschlossen(stil_id, nie, praefixe) or not ist_renderer_stil(stil):
|
||||
continue
|
||||
gueltige_ids.add(stil_id)
|
||||
schluessel, bezeichnung = eintrags_schluessel(stil)
|
||||
schluessel = bisherige_zuordnung.get(stil_id, schluessel)
|
||||
if schluessel not in eintraege:
|
||||
vorlage = bisherige_eintraege.get(schluessel, {})
|
||||
eintraege[schluessel] = {
|
||||
"schluessel": schluessel,
|
||||
"bezeichnung": vorlage.get("bezeichnung", bezeichnung),
|
||||
"gruppe": vorlage.get("gruppe", _GRUPPE_JE_QUELL_LAYER.get(stil.get("source-layer", ""), "Sonstiges")),
|
||||
"treffer_stile": [],
|
||||
"symbol_stile": [],
|
||||
}
|
||||
reihenfolge_neu.append(schluessel)
|
||||
if schluessel not in bisherige_eintraege and bisher:
|
||||
meldungen.append(f"Neuer Eintrag: {schluessel} ({bezeichnung})")
|
||||
eintraege[schluessel]["treffer_stile"].append(stil_id)
|
||||
|
||||
for eintrag in eintraege.values():
|
||||
vorlage = bisherige_eintraege.get(eintrag["schluessel"], {})
|
||||
symbol_bisher = [s for s in vorlage.get("symbol_stile", []) if s in gueltige_ids]
|
||||
if symbol_bisher:
|
||||
eintrag["symbol_stile"] = symbol_bisher
|
||||
else:
|
||||
eintrag["symbol_stile"] = waehle_symbol_stile(eintrag["treffer_stile"])
|
||||
|
||||
for stil_id, schluessel in bisherige_zuordnung.items():
|
||||
if stil_id not in gueltige_ids:
|
||||
meldungen.append(f"Style-ID entfallen: {stil_id} (war {schluessel})")
|
||||
|
||||
# Reihenfolge: bisherige Reihenfolge zuerst, neue Einträge in Reihenfolge der Style-JSON dahinter,
|
||||
# innerhalb der Gruppenreihenfolge.
|
||||
alte_reihenfolge = [e["schluessel"] for e in bisher.get("eintraege", []) if e["schluessel"] in eintraege]
|
||||
neue = [s for s in reihenfolge_neu if s not in alte_reihenfolge]
|
||||
gruppen = list(bisher.get("gruppen", GRUPPEN))
|
||||
for eintrag in eintraege.values():
|
||||
if eintrag["gruppe"] not in gruppen:
|
||||
gruppen.append(eintrag["gruppe"])
|
||||
if alte_reihenfolge:
|
||||
sortiert = alte_reihenfolge + neue
|
||||
else:
|
||||
sortiert = sorted(neue, key=lambda s: gruppen.index(eintraege[s]["gruppe"]))
|
||||
|
||||
katalog = {
|
||||
"beschreibung": (
|
||||
"Legendenkatalog für basemap.de Web Vektor. Ordnet jede Style-ID genau einem Legendeneintrag zu. "
|
||||
"Erzeugt mit modules/basemap_katalog.py, danach fachlich nachbearbeitet."
|
||||
),
|
||||
"quelle": {
|
||||
"url": STIL_URL,
|
||||
"name": stil_json.get("name", ""),
|
||||
"version": stil_json.get("version", ""),
|
||||
},
|
||||
"gruppen": gruppen,
|
||||
"nie_in_legende": nie,
|
||||
"nie_in_legende_praefixe": praefixe,
|
||||
"eintraege": [eintraege[s] for s in sortiert],
|
||||
}
|
||||
for name in [n for n in pruefe_katalog(katalog)]:
|
||||
meldungen.append(f"Katalogfehler: {name}")
|
||||
return katalog, meldungen
|
||||
|
||||
|
||||
def pruefe_katalog(katalog: dict) -> list[str]:
|
||||
"""Prüft die Konsistenz eines Katalogs und liefert eine Liste von Fehlermeldungen."""
|
||||
fehler: list[str] = []
|
||||
gesehen: dict[str, str] = {}
|
||||
schluessel_gesehen: set[str] = set()
|
||||
alle_stile = {s for e in katalog.get("eintraege", []) for s in e.get("treffer_stile", [])}
|
||||
for eintrag in katalog.get("eintraege", []):
|
||||
schluessel = eintrag.get("schluessel", "")
|
||||
if not schluessel:
|
||||
fehler.append("Eintrag ohne Schlüssel")
|
||||
continue
|
||||
if schluessel in schluessel_gesehen:
|
||||
fehler.append(f"Schlüssel doppelt: {schluessel}")
|
||||
schluessel_gesehen.add(schluessel)
|
||||
if eintrag.get("gruppe") not in katalog.get("gruppen", []):
|
||||
fehler.append(f"{schluessel}: Gruppe '{eintrag.get('gruppe')}' nicht in der Gruppenliste")
|
||||
for stil_id in eintrag.get("treffer_stile", []):
|
||||
if stil_id in gesehen:
|
||||
fehler.append(f"Style-ID {stil_id} in {gesehen[stil_id]} und {schluessel}")
|
||||
gesehen[stil_id] = schluessel
|
||||
# symbol_stile dürfen auch Styles anderer Einträge nutzen (gemeinsame Kontur von
|
||||
# „Straße im Bau“ und „Straße außer Betrieb“), müssen aber im Katalog vorkommen.
|
||||
unbekannt = set(eintrag.get("symbol_stile", [])) - alle_stile
|
||||
if unbekannt:
|
||||
fehler.append(f"{schluessel}: symbol_stile nicht im Katalog: {', '.join(sorted(unbekannt))}")
|
||||
if not eintrag.get("symbol_stile"):
|
||||
fehler.append(f"{schluessel}: keine symbol_stile")
|
||||
return fehler
|
||||
|
||||
|
||||
def _standard_zielpfad() -> str:
|
||||
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "basemap_legende.json")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Kommandozeile: Style-JSON laden, Katalog erzeugen bzw. aktualisieren und schreiben."""
|
||||
import argparse
|
||||
import urllib.request
|
||||
|
||||
parser = argparse.ArgumentParser(description="Legendenkatalog für basemap.de erzeugen oder aktualisieren.")
|
||||
parser.add_argument("--stil", help="lokale Style-JSON statt Download von basemap.de")
|
||||
parser.add_argument("--ziel", default=_standard_zielpfad(), help="Zieldatei (Standard: data/basemap_legende.json)")
|
||||
parser.add_argument("--neu", action="store_true", help="bestehenden Katalog ignorieren")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.stil:
|
||||
stil_json = lade_json(args.stil)
|
||||
else:
|
||||
with urllib.request.urlopen(STIL_URL, timeout=60) as antwort:
|
||||
stil_json = json.loads(antwort.read().decode("utf-8"))
|
||||
|
||||
bisher = None
|
||||
if not args.neu and os.path.exists(args.ziel):
|
||||
bisher = lade_json(args.ziel)
|
||||
|
||||
katalog, meldungen = erzeuge_katalog(stil_json, bisher)
|
||||
with open(args.ziel, "w", encoding="utf-8", newline="\n") as datei:
|
||||
json.dump(katalog, datei, ensure_ascii=False, indent=1)
|
||||
datei.write("\n")
|
||||
for meldung in meldungen:
|
||||
print(meldung)
|
||||
print(f"{len(katalog['eintraege'])} Einträge geschrieben: {args.ziel}")
|
||||
return 1 if any(m.startswith("Katalogfehler") for m in meldungen) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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
|
||||
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
sn_basis/modules/basemap_stilvorlagen.py – lokale Stilvorlagen für den Basemap-Layer
|
||||
|
||||
Speichert die Darstellung des Basemap-Layers (Symbologie und Beschriftung, also auch
|
||||
abgeschaltete Styles wie Hausnummern) als benannte Stilvorlage im QGIS-Profil und lädt sie
|
||||
wieder. Mehrere Varianten sind möglich; eine davon kann als Standard markiert werden.
|
||||
|
||||
Ablage: ``<QGIS-Profil>/sn_basis/basemap_stile/<name>.qml`` plus ``<name>.json`` mit
|
||||
Anzeigename, Erstellungszeit, Style-Namen der Vorlage und – optional – der Legendenauswahl.
|
||||
Der Standard steht in ``QSettings`` unter ``sn_basis/basemap_standardstil``.
|
||||
Die Vorlagen sind lokal (je Rechner und Profil) und werden nicht geteilt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sn_basis.functions.qgiscore_wrapper import QgsApplication, STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG
|
||||
from sn_basis.functions.qt_wrapper import QSettings
|
||||
from sn_basis.functions.text_wrapper import sanitize_bezeichner
|
||||
|
||||
STANDARD_SCHLUESSEL = "sn_basis/basemap_standardstil"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Stilvorlage:
|
||||
name: str # Dateiname ohne Endung
|
||||
anzeigename: str
|
||||
pfad: str # QML-Datei
|
||||
erstellt: str = ""
|
||||
standard: bool = False
|
||||
|
||||
|
||||
def standard_ordner() -> str:
|
||||
"""Ordner der Stilvorlagen im aktiven QGIS-Profil (außerhalb von QGIS im Benutzerordner)."""
|
||||
try:
|
||||
basis = QgsApplication.qgisSettingsDirPath() or ""
|
||||
except Exception:
|
||||
basis = ""
|
||||
if not basis:
|
||||
basis = os.path.join(os.path.expanduser("~"), ".sn_basis")
|
||||
return os.path.join(basis, "sn_basis", "basemap_stile")
|
||||
|
||||
|
||||
def _stilnamen(layer: Any) -> list[str]:
|
||||
try:
|
||||
return [s.styleName() for s in layer.renderer().styles()]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
class BasemapStilvorlagen:
|
||||
"""Verwaltet die lokalen Stilvorlagen des Basemap-Layers."""
|
||||
|
||||
def __init__(self, ordner: str | None = None, einstellungen: Any | None = None) -> None:
|
||||
self.ordner = ordner or standard_ordner()
|
||||
self.einstellungen = einstellungen if einstellungen is not None else QSettings()
|
||||
|
||||
# --- Abfragen -------------------------------------------------------
|
||||
|
||||
def _pfade(self, name: str) -> tuple[str, str]:
|
||||
basis = os.path.join(self.ordner, name)
|
||||
return basis + ".qml", basis + ".json"
|
||||
|
||||
def _metadaten(self, name: str) -> dict:
|
||||
_, json_pfad = self._pfade(name)
|
||||
try:
|
||||
with open(json_pfad, encoding="utf-8") as datei:
|
||||
daten = json.load(datei)
|
||||
return daten if isinstance(daten, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def dateiname(anzeigename: str) -> str:
|
||||
return sanitize_bezeichner(anzeigename.strip())
|
||||
|
||||
def standard(self) -> str:
|
||||
return str(self.einstellungen.value(STANDARD_SCHLUESSEL, "") or "")
|
||||
|
||||
def liste(self) -> list[Stilvorlage]:
|
||||
"""Alle Stilvorlagen, alphabetisch nach Anzeigename. Fehlende Begleitdatei wird toleriert."""
|
||||
if not os.path.isdir(self.ordner):
|
||||
return []
|
||||
standard = self.standard()
|
||||
vorlagen = []
|
||||
for datei in os.listdir(self.ordner):
|
||||
if not datei.lower().endswith(".qml"):
|
||||
continue
|
||||
name = datei[:-4]
|
||||
meta = self._metadaten(name)
|
||||
vorlagen.append(Stilvorlage(
|
||||
name=name,
|
||||
anzeigename=str(meta.get("anzeigename") or name),
|
||||
pfad=os.path.join(self.ordner, datei),
|
||||
erstellt=str(meta.get("erstellt", "")),
|
||||
standard=(name == standard),
|
||||
))
|
||||
return sorted(vorlagen, key=lambda v: v.anzeigename.lower())
|
||||
|
||||
def vorhanden(self, anzeigename: str) -> bool:
|
||||
return os.path.exists(self._pfade(self.dateiname(anzeigename))[0])
|
||||
|
||||
def legendenauswahl(self, name: str) -> dict | None:
|
||||
"""In der Vorlage gespeicherte Legendenauswahl (``ausgeblendet``/``eingeblendet``) oder ``None``."""
|
||||
auswahl = self._metadaten(name).get("legende_auswahl")
|
||||
return auswahl if isinstance(auswahl, dict) else None
|
||||
|
||||
def pruefe_passung(self, layer: Any, name: str) -> list[str]:
|
||||
"""Style-Namen der Vorlage, die der Layer nicht kennt (z. B. Vorlage aus „Grau“ für „Farbe“)."""
|
||||
vorlage = self._metadaten(name).get("stile")
|
||||
if not isinstance(vorlage, list):
|
||||
return []
|
||||
vorhanden = set(_stilnamen(layer))
|
||||
return [s for s in vorlage if s not in vorhanden]
|
||||
|
||||
# --- Ändern ---------------------------------------------------------
|
||||
|
||||
def speichern(self, layer: Any, anzeigename: str, legende_auswahl: dict | None = None) -> Stilvorlage:
|
||||
"""Speichert die aktuelle Darstellung des Layers; eine gleichnamige Vorlage wird ersetzt.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
Bei leerem Namen oder wenn QGIS die Datei nicht schreiben konnte.
|
||||
"""
|
||||
name = self.dateiname(anzeigename)
|
||||
if not name:
|
||||
raise ValueError("Bitte einen Namen für die Stilvorlage angeben.")
|
||||
os.makedirs(self.ordner, exist_ok=True)
|
||||
qml_pfad, json_pfad = self._pfade(name)
|
||||
ergebnis = layer.saveNamedStyle(qml_pfad, STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG)
|
||||
meldung, ok = ergebnis if isinstance(ergebnis, tuple) else ("", bool(ergebnis))
|
||||
if not ok:
|
||||
raise ValueError(f"Die Stilvorlage konnte nicht gespeichert werden: {meldung}")
|
||||
erstellt = datetime.now().isoformat(timespec="seconds")
|
||||
meta = {
|
||||
"anzeigename": anzeigename.strip(),
|
||||
"erstellt": erstellt,
|
||||
"layer": layer.name(),
|
||||
"stile": _stilnamen(layer),
|
||||
}
|
||||
if legende_auswahl:
|
||||
meta["legende_auswahl"] = legende_auswahl
|
||||
with open(json_pfad, "w", encoding="utf-8") as datei:
|
||||
json.dump(meta, datei, ensure_ascii=False, indent=1)
|
||||
return Stilvorlage(name, anzeigename.strip(), qml_pfad, erstellt, name == self.standard())
|
||||
|
||||
def laden(self, layer: Any, name: str) -> None:
|
||||
"""Ersetzt Symbologie und Beschriftung des Layers durch die Vorlage.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
Wenn die Vorlage fehlt oder QGIS sie nicht laden konnte.
|
||||
"""
|
||||
qml_pfad, _ = self._pfade(name)
|
||||
if not os.path.exists(qml_pfad):
|
||||
raise ValueError(f"Die Stilvorlage „{name}“ gibt es nicht mehr.")
|
||||
ergebnis = layer.loadNamedStyle(qml_pfad, STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG)
|
||||
meldung, ok = ergebnis if isinstance(ergebnis, tuple) else ("", bool(ergebnis))
|
||||
if not ok:
|
||||
raise ValueError(f"Die Stilvorlage konnte nicht geladen werden: {meldung}")
|
||||
nachzeichnen = getattr(layer, "triggerRepaint", None)
|
||||
if callable(nachzeichnen):
|
||||
nachzeichnen()
|
||||
|
||||
def loeschen(self, name: str) -> None:
|
||||
for pfad in self._pfade(name):
|
||||
if os.path.exists(pfad):
|
||||
os.remove(pfad)
|
||||
if self.standard() == name:
|
||||
self.einstellungen.remove(STANDARD_SCHLUESSEL)
|
||||
|
||||
def setze_standard(self, name: str) -> None:
|
||||
if not os.path.exists(self._pfade(name)[0]):
|
||||
raise ValueError(f"Die Stilvorlage „{name}“ gibt es nicht.")
|
||||
self.einstellungen.setValue(STANDARD_SCHLUESSEL, name)
|
||||
@@ -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,174 @@
|
||||
"""
|
||||
Tests für die Anbindung der Basemap-Legende an Druckablauf, Dialog und Druck-Tab (Basis #88):
|
||||
Mehrfachauswahl-Dialog, PrintLogic/PrintLayout und Basemap-Bereich im Druck-Tab.
|
||||
"""
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from sn_basis.functions.dialog_wrapper import ask_mehrfachauswahl
|
||||
from sn_basis.functions.variable_wrapper import get_variable, set_variable
|
||||
from sn_basis.modules import basemap_legende as bl
|
||||
from sn_basis.modules.print_layout import PrintLayout
|
||||
from sn_basis.modules.print_logic import (
|
||||
KARTENNAME_38,
|
||||
KARTENNAME_41,
|
||||
KARTENNAME_BAUFREIGABE,
|
||||
LAYOUTS_MIT_BASEMAP_LEGENDE,
|
||||
PrintLogic,
|
||||
)
|
||||
from sn_basis.ui.tabs.print_tab import PrintTab
|
||||
from sn_basis.ui.widgets.basemap_bereich import BasemapBereich
|
||||
|
||||
|
||||
class TestMehrfachauswahl(unittest.TestCase):
|
||||
|
||||
def test_mock_modus_liefert_vorauswahl_in_listenreihenfolge(self):
|
||||
"""Prüft, dass der Mehrfachauswahl-Dialog im Mock-Modus die vorausgewählten Schlüssel in Listenreihenfolge liefert."""
|
||||
eintraege = [{"schluessel": s, "text": s.upper()} for s in ("a", "b", "c")]
|
||||
self.assertEqual(ask_mehrfachauswahl("T", "H", eintraege, {"c", "a"}), ["a", "c"])
|
||||
|
||||
|
||||
class TestDruckanbindung(unittest.TestCase):
|
||||
|
||||
def tearDown(self):
|
||||
set_variable(bl.LEGENDE_ERZEUGEN_VAR, "", scope="project")
|
||||
|
||||
def test_basemap_legende_nur_fuer_38_und_41_und_bei_eingeschaltetem_schalter(self):
|
||||
"""Prüft, dass nur §-38- und §-41-Vorlagen eine Basemap-Legende bekommen und nur, wenn der Schalter im Druck-Tab an ist."""
|
||||
logik = PrintLogic(pruefmanager=MagicMock())
|
||||
self.assertEqual(LAYOUTS_MIT_BASEMAP_LEGENDE, {KARTENNAME_38, KARTENNAME_41})
|
||||
self.assertTrue(logik._basemap_legende_erzeugen(KARTENNAME_41))
|
||||
self.assertFalse(logik._basemap_legende_erzeugen(KARTENNAME_BAUFREIGABE))
|
||||
bl.setze_legende_erzeugen(False)
|
||||
self.assertFalse(logik._basemap_legende_erzeugen(KARTENNAME_38))
|
||||
|
||||
def test_layout_wird_markiert_und_legende_erzeugt(self):
|
||||
"""Prüft, dass PrintLayout das Layout als Basemap-Nutzer kennzeichnet und die Basemap-Legende erzeugen lässt."""
|
||||
layout = MagicMock()
|
||||
with patch("sn_basis.modules.basemap_legende.BasemapLegende") as klasse:
|
||||
klasse.return_value.aktualisiere.return_value = {"status": "ok"}
|
||||
PrintLayout(project=MagicMock())._erzeuge_basemap_legende(layout)
|
||||
layout.setCustomProperty.assert_any_call("sn_basemap_legende", 1)
|
||||
klasse.return_value.aktualisiere.assert_called_once_with(layout)
|
||||
|
||||
def test_fehler_der_basemap_legende_verhindert_die_vorlage_nicht(self):
|
||||
"""Prüft, dass ein Fehler beim Erzeugen der Basemap-Legende nur als Warnung gemeldet wird und keine Ausnahme durchreicht."""
|
||||
with patch("sn_basis.modules.basemap_legende.BasemapLegende") as klasse, \
|
||||
patch("sn_basis.functions.message_wrapper.warning") as warnung:
|
||||
klasse.return_value.aktualisiere.side_effect = RuntimeError("Netz weg")
|
||||
PrintLayout(project=MagicMock())._erzeuge_basemap_legende(MagicMock())
|
||||
warnung.assert_called_once()
|
||||
self.assertIn("Netz weg", warnung.call_args[0][1])
|
||||
|
||||
def test_druck_tab_aktualisiert_basemap_bereich_falls_vorhanden(self):
|
||||
"""Prüft, dass der Druck-Tab den Basemap-Bereich neu einliest und ohne Bereich nichts tut."""
|
||||
tab = PrintTab.__new__(PrintTab)
|
||||
tab._basemap_bereich_aktualisieren() # ohne Bereich: keine Ausnahme
|
||||
tab._basemap_bereich = MagicMock()
|
||||
tab._basemap_bereich_aktualisieren()
|
||||
tab._basemap_bereich.aktualisieren.assert_called_once()
|
||||
|
||||
|
||||
def _bereich(stil_name="Ohne_Hausnummern", layer=None, layout_name="§41"):
|
||||
bereich = BasemapBereich.__new__(BasemapBereich)
|
||||
bereich._project = MagicMock()
|
||||
bereich._stilvorlagen = MagicMock()
|
||||
bereich._stil_combo = MagicMock()
|
||||
bereich._stil_combo.currentData.return_value = stil_name
|
||||
bereich._stil_combo.currentText.return_value = stil_name
|
||||
bereich._layout_combo = MagicMock()
|
||||
bereich._layout_combo.currentText.return_value = layout_name
|
||||
bereich._bereich_combo = MagicMock()
|
||||
bereich.basemap_layer = MagicMock(return_value=layer)
|
||||
bereich.aktualisieren = MagicMock()
|
||||
return bereich
|
||||
|
||||
|
||||
class TestBasemapBereich(unittest.TestCase):
|
||||
|
||||
def tearDown(self):
|
||||
for schluessel in (bl.AUSWAHL_VAR, bl.AUSWERTEBEREICH_VAR):
|
||||
set_variable(schluessel, "", scope="project")
|
||||
|
||||
def test_auswertebereich_wird_gespeichert(self):
|
||||
"""Prüft, dass die Wahl des Auswertebereichs in der Projektvariable landet."""
|
||||
bereich = _bereich()
|
||||
bereich._bereich_combo.currentData.return_value = bl.AUSWERTEBEREICH_DRUCKBEREICH
|
||||
bereich._on_bereich_geaendert(1)
|
||||
self.assertEqual(bl.lies_auswertebereich(), bl.AUSWERTEBEREICH_DRUCKBEREICH)
|
||||
|
||||
@patch("sn_basis.ui.widgets.basemap_bereich.warning")
|
||||
def test_aktualisieren_ohne_vorlage_warnt(self, warnung):
|
||||
"""Prüft, dass „Basemap-Legende aktualisieren“ ohne vorhandene Vorlage warnt und nichts auswertet."""
|
||||
bereich = _bereich()
|
||||
bereich._project.layoutManager.return_value.layoutByName.return_value = None
|
||||
with patch("sn_basis.modules.basemap_legende.BasemapLegende") as klasse:
|
||||
bereich._on_legende_aktualisieren()
|
||||
klasse.assert_not_called()
|
||||
warnung.assert_called_once()
|
||||
|
||||
@patch("sn_basis.ui.widgets.basemap_bereich.success")
|
||||
def test_aktualisieren_wertet_gewaehlte_vorlage_aus(self, erfolg):
|
||||
"""Prüft, dass „Basemap-Legende aktualisieren“ die gewählte Vorlage auswertet und den Erfolg meldet."""
|
||||
bereich = _bereich()
|
||||
layout = bereich._project.layoutManager.return_value.layoutByName.return_value
|
||||
with patch("sn_basis.modules.basemap_legende.BasemapLegende") as klasse:
|
||||
klasse.return_value.aktualisiere.return_value = {"status": "ok", "meldung": "3 Einträge"}
|
||||
bereich._on_legende_aktualisieren()
|
||||
klasse.return_value.aktualisiere.assert_called_once_with(layout)
|
||||
erfolg.assert_called_once()
|
||||
|
||||
@patch("sn_basis.ui.widgets.basemap_bereich.ask_yes_no", return_value=True)
|
||||
@patch("sn_basis.ui.widgets.basemap_bereich.success")
|
||||
def test_stil_laden_uebernimmt_legendenauswahl(self, _erfolg, _frage):
|
||||
"""Prüft, dass das Laden einer Stilvorlage den Layerstil ersetzt und ihre Legendenauswahl in das Projekt übernimmt."""
|
||||
layer = MagicMock()
|
||||
bereich = _bereich(layer=layer)
|
||||
bereich._stilvorlagen.pruefe_passung.return_value = []
|
||||
bereich._stilvorlagen.legendenauswahl.return_value = {"ausgeblendet": ["wald"], "eingeblendet": []}
|
||||
bereich._on_stil_laden()
|
||||
bereich._stilvorlagen.laden.assert_called_once_with(layer, "Ohne_Hausnummern")
|
||||
self.assertEqual(json.loads(get_variable(bl.AUSWAHL_VAR, scope="project"))["ausgeblendet"], ["wald"])
|
||||
|
||||
@patch("sn_basis.ui.widgets.basemap_bereich.ask_yes_no", side_effect=[True, False])
|
||||
def test_stil_laden_bricht_bei_fremder_vorlage_ab(self, _frage):
|
||||
"""Prüft, dass eine Vorlage mit unbekannten Styles nur nach Bestätigung geladen wird."""
|
||||
bereich = _bereich(layer=MagicMock())
|
||||
bereich._stilvorlagen.pruefe_passung.return_value = ["Fremder_Style"]
|
||||
bereich._on_stil_laden()
|
||||
bereich._stilvorlagen.laden.assert_not_called()
|
||||
|
||||
@patch("sn_basis.ui.widgets.basemap_bereich.ask_text", return_value=("Ohne Hausnummern", True))
|
||||
@patch("sn_basis.ui.widgets.basemap_bereich.ask_yes_no", return_value=False)
|
||||
def test_vorhandene_vorlage_wird_nur_nach_rueckfrage_ersetzt(self, _frage, _text):
|
||||
"""Prüft, dass eine gleichnamige Stilvorlage ohne Bestätigung nicht überschrieben wird."""
|
||||
bereich = _bereich(layer=MagicMock())
|
||||
bereich._stilvorlagen.vorhanden.return_value = True
|
||||
bereich._on_stil_speichern()
|
||||
bereich._stilvorlagen.speichern.assert_not_called()
|
||||
|
||||
@patch("sn_basis.ui.widgets.basemap_bereich.ask_text", return_value=("Neu", True))
|
||||
@patch("sn_basis.ui.widgets.basemap_bereich.success")
|
||||
def test_speichern_mit_aktueller_legendenauswahl(self, _erfolg, _text):
|
||||
"""Prüft, dass eine neue Stilvorlage zusammen mit der aktuellen Legendenauswahl gespeichert wird."""
|
||||
layer = MagicMock()
|
||||
bereich = _bereich(layer=layer)
|
||||
bereich._stilvorlagen.vorhanden.return_value = False
|
||||
bl.merke_auswahl([bl.LegendenEintrag("wald", "Wald", "Vegetation", [])], [])
|
||||
bereich._on_stil_speichern()
|
||||
bereich._stilvorlagen.speichern.assert_called_once_with(
|
||||
layer, "Neu", {"ausgeblendet": ["wald"], "eingeblendet": []})
|
||||
bereich.aktualisieren.assert_called_once()
|
||||
|
||||
@patch("sn_basis.ui.widgets.basemap_bereich.warning")
|
||||
def test_stil_speichern_ohne_basemap_layer_warnt(self, warnung):
|
||||
"""Prüft, dass ohne Basemap-Layer keine Stilvorlage gespeichert, sondern gewarnt wird."""
|
||||
bereich = _bereich(layer=None)
|
||||
bereich._on_stil_speichern()
|
||||
bereich._stilvorlagen.speichern.assert_not_called()
|
||||
warnung.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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,174 @@
|
||||
"""
|
||||
Tests für modules/basemap_katalog.py – Legendenkatalog aus der basemap.de-Style-JSON (Basis #88).
|
||||
|
||||
Die Style-JSON wird nicht geladen; die Tests arbeiten mit einem kleinen Ausschnitt mit den
|
||||
typischen Mustern (Kontur/Decker, Brücke, Beschriftung, Symbol, Hintergrund).
|
||||
"""
|
||||
import copy
|
||||
import unittest
|
||||
|
||||
from sn_basis.modules.basemap_katalog import (
|
||||
erzeuge_katalog,
|
||||
ist_ausgeschlossen,
|
||||
ist_renderer_stil,
|
||||
lade_json,
|
||||
normalisiere_stil_id,
|
||||
pruefe_katalog,
|
||||
waehle_symbol_stile,
|
||||
_standard_zielpfad,
|
||||
)
|
||||
|
||||
_KREIS = ["==", "klasse", "Kreisstraße"]
|
||||
|
||||
STIL_AUSSCHNITT = {
|
||||
"name": "Ausschnitt",
|
||||
"version": 8,
|
||||
"layers": [
|
||||
{"id": "Hintergrund", "type": "fill", "source-layer": "Hintergrund"},
|
||||
{"id": "VegetationsF_Wald", "type": "fill", "source-layer": "Vegetationsflaeche",
|
||||
"filter": ["==", "klasse", "Wald"]},
|
||||
{"id": "Kontur_Kreisstr", "type": "line", "source-layer": "Verkehrslinie",
|
||||
"filter": ["all", _KREIS, ["!has", "bauwerk"]]},
|
||||
{"id": "Kontur_Kreisstr_getrennt", "type": "line", "source-layer": "Verkehrslinie",
|
||||
"filter": ["all", _KREIS, ["==", "fahrbahn", "Getrennt"]]},
|
||||
{"id": "Decker_Kreisstr", "type": "line", "source-layer": "Verkehrslinie",
|
||||
"filter": ["all", _KREIS, ["!has", "bauwerk"]]},
|
||||
{"id": "Bruecke_Kreisstr_Decker", "type": "line", "source-layer": "Verkehrslinie",
|
||||
"filter": ["all", _KREIS, ["==", "bauwerk", "Brücke"]]},
|
||||
{"id": "ImBau_Decker_Kreisstr", "type": "line", "source-layer": "Verkehrslinie",
|
||||
"filter": ["all", _KREIS, ["==", "zustand", "Im Bau"]]},
|
||||
{"id": "Name_Kreisstr", "type": "symbol", "source-layer": "Verkehrslinie",
|
||||
"layout": {"text-field": "{name}"}, "filter": _KREIS},
|
||||
{"id": "Nummer_Kreisstr", "type": "symbol", "source-layer": "Verkehrslinie",
|
||||
"layout": {"text-field": "{nummer}", "icon-image": "schild"}, "filter": _KREIS},
|
||||
{"id": "Symbol_BauwerkP_Kran", "type": "symbol", "source-layer": "Bauwerkspunkt",
|
||||
"layout": {"icon-image": "kran"}, "filter": ["==", "klasse", "Kran"]},
|
||||
{"id": "Symbol_BauwerkF_Kran", "type": "symbol", "source-layer": "Bauwerksflaeche",
|
||||
"layout": {"icon-image": "kran"}, "filter": ["==", "klasse", "Kran"]},
|
||||
{"id": "Gebaeude3D", "type": "fill-extrusion", "source-layer": "Gebaeudeflaeche"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _eintrag(katalog, stil_id):
|
||||
return next(e for e in katalog["eintraege"] if stil_id in e["treffer_stile"])
|
||||
|
||||
|
||||
class TestRendererStil(unittest.TestCase):
|
||||
|
||||
def test_beschriftung_und_3d_sind_keine_renderer_stile(self):
|
||||
"""Prüft, dass reine Beschriftungen und 3D-Gebäude nicht als Renderer-Style gelten, Icon-Symbole und Flächen schon."""
|
||||
stile = {s["id"]: s for s in STIL_AUSSCHNITT["layers"]}
|
||||
self.assertFalse(ist_renderer_stil(stile["Name_Kreisstr"]))
|
||||
self.assertFalse(ist_renderer_stil(stile["Gebaeude3D"]))
|
||||
self.assertTrue(ist_renderer_stil(stile["Symbol_BauwerkP_Kran"]))
|
||||
self.assertTrue(ist_renderer_stil(stile["VegetationsF_Wald"]))
|
||||
|
||||
def test_ausschluss_ueber_liste_und_praefix(self):
|
||||
"""Prüft, dass Style-IDs über die Ausschlussliste und über Präfixe (Nummernschilder) ausgeschlossen werden."""
|
||||
self.assertTrue(ist_ausgeschlossen("Hintergrund", ["Hintergrund"], ["Nummer_"]))
|
||||
self.assertTrue(ist_ausgeschlossen("Nummer_Kreisstr", [], ["Nummer_"]))
|
||||
self.assertFalse(ist_ausgeschlossen("Decker_Kreisstr", ["Hintergrund"], ["Nummer_"]))
|
||||
|
||||
def test_normalisierung_entfernt_variantenkennungen(self):
|
||||
"""Prüft, dass Variantenkennungen wie Kontur, Brücke oder Breitenangaben beim Normalisieren einer Style-ID entfallen."""
|
||||
self.assertEqual(normalisiere_stil_id("Bruecke_Kreisstr_getrennt_Decker"), "Kreisstr")
|
||||
self.assertEqual(normalisiere_stil_id("Gewaesser_L_Breite_12m"), "Gewaesser_Breite")
|
||||
|
||||
|
||||
class TestErzeugeKatalog(unittest.TestCase):
|
||||
|
||||
def test_kontur_decker_und_bruecke_bilden_einen_eintrag(self):
|
||||
"""Prüft, dass Kontur-, Decker-, Fahrbahn- und Brückenvarianten derselben Straßenklasse genau einen Legendeneintrag bilden."""
|
||||
katalog, _ = erzeuge_katalog(STIL_AUSSCHNITT)
|
||||
kreis = _eintrag(katalog, "Kontur_Kreisstr")
|
||||
self.assertEqual(kreis["bezeichnung"], "Kreisstraße")
|
||||
self.assertEqual(set(kreis["treffer_stile"]),
|
||||
{"Kontur_Kreisstr", "Kontur_Kreisstr_getrennt", "Decker_Kreisstr", "Bruecke_Kreisstr_Decker"})
|
||||
self.assertEqual(kreis["symbol_stile"], ["Kontur_Kreisstr", "Decker_Kreisstr"])
|
||||
|
||||
def test_zustand_im_bau_wird_eigener_eintrag(self):
|
||||
"""Prüft, dass „im Bau“ einen eigenen Legendeneintrag mit Zustandsangabe in der Bezeichnung bekommt."""
|
||||
katalog, _ = erzeuge_katalog(STIL_AUSSCHNITT)
|
||||
im_bau = _eintrag(katalog, "ImBau_Decker_Kreisstr")
|
||||
self.assertNotIn("Kontur_Kreisstr", im_bau["treffer_stile"])
|
||||
self.assertIn("im Bau", im_bau["bezeichnung"])
|
||||
|
||||
def test_punkt_und_flaeche_derselben_objektart_werden_zusammengefasst(self):
|
||||
"""Prüft, dass das Kran-Symbol aus Bauwerkspunkt und Bauwerksfläche in einem Eintrag landet."""
|
||||
katalog, _ = erzeuge_katalog(STIL_AUSSCHNITT)
|
||||
kran = _eintrag(katalog, "Symbol_BauwerkP_Kran")
|
||||
self.assertIn("Symbol_BauwerkF_Kran", kran["treffer_stile"])
|
||||
|
||||
def test_ausgeschlossene_stile_fehlen_im_katalog(self):
|
||||
"""Prüft, dass Hintergrund, Beschriftungen, Nummernschilder und 3D-Gebäude keinem Eintrag zugeordnet werden."""
|
||||
katalog, _ = erzeuge_katalog(STIL_AUSSCHNITT)
|
||||
alle = {s for e in katalog["eintraege"] for s in e["treffer_stile"]}
|
||||
for stil_id in ("Hintergrund", "Name_Kreisstr", "Nummer_Kreisstr", "Gebaeude3D"):
|
||||
self.assertNotIn(stil_id, alle)
|
||||
self.assertEqual(pruefe_katalog(katalog), [])
|
||||
|
||||
def test_fachliche_nachbearbeitung_bleibt_beim_erneuten_lauf_erhalten(self):
|
||||
"""Prüft, dass Bezeichnung, Gruppe, Zusammenführung und Symbolwahl eines bestehenden Katalogs bei einem erneuten Lauf erhalten bleiben."""
|
||||
katalog, _ = erzeuge_katalog(STIL_AUSSCHNITT)
|
||||
bearbeitet = copy.deepcopy(katalog)
|
||||
kreis = _eintrag(bearbeitet, "Kontur_Kreisstr")
|
||||
im_bau = _eintrag(bearbeitet, "ImBau_Decker_Kreisstr")
|
||||
kreis["bezeichnung"] = "Kreisstraße (fachlich)"
|
||||
kreis["treffer_stile"] += im_bau["treffer_stile"]
|
||||
kreis["symbol_stile"] = ["Decker_Kreisstr"]
|
||||
bearbeitet["eintraege"].remove(im_bau)
|
||||
|
||||
erneut, meldungen = erzeuge_katalog(STIL_AUSSCHNITT, bearbeitet)
|
||||
|
||||
kreis_neu = _eintrag(erneut, "Kontur_Kreisstr")
|
||||
self.assertEqual(kreis_neu["bezeichnung"], "Kreisstraße (fachlich)")
|
||||
self.assertIn("ImBau_Decker_Kreisstr", kreis_neu["treffer_stile"])
|
||||
self.assertEqual(kreis_neu["symbol_stile"], ["Decker_Kreisstr"])
|
||||
self.assertEqual(meldungen, [])
|
||||
|
||||
def test_entfallene_style_ids_werden_gemeldet(self):
|
||||
"""Prüft, dass eine Style-ID, die in der neuen Style-JSON fehlt, als entfallen gemeldet und aus dem Katalog entfernt wird."""
|
||||
katalog, _ = erzeuge_katalog(STIL_AUSSCHNITT)
|
||||
verkleinert = copy.deepcopy(STIL_AUSSCHNITT)
|
||||
verkleinert["layers"] = [l for l in verkleinert["layers"] if l["id"] != "Bruecke_Kreisstr_Decker"]
|
||||
|
||||
erneut, meldungen = erzeuge_katalog(verkleinert, katalog)
|
||||
|
||||
self.assertTrue(any("Bruecke_Kreisstr_Decker" in m for m in meldungen))
|
||||
self.assertNotIn("Bruecke_Kreisstr_Decker", _eintrag(erneut, "Kontur_Kreisstr")["treffer_stile"])
|
||||
|
||||
|
||||
class TestSymbolwahlUndPruefung(unittest.TestCase):
|
||||
|
||||
def test_symbol_stile_je_rolle_nur_grundvariante(self):
|
||||
"""Prüft, dass für das Legendensymbol je Rolle (Kontur, Decker) nur die erste Grundvariante gewählt wird."""
|
||||
gewaehlt = waehle_symbol_stile(
|
||||
["Kontur_Kreisstr_FSZ1", "Kontur_Kreisstr", "Decker_Kreisstr", "Bruecke_Kreisstr_Decker"])
|
||||
self.assertEqual(gewaehlt, ["Kontur_Kreisstr", "Decker_Kreisstr"])
|
||||
|
||||
def test_pruefung_findet_doppelte_zuordnung_und_unbekannte_symbole(self):
|
||||
"""Prüft, dass die Katalogprüfung doppelt zugeordnete Style-IDs und Symbol-Styles außerhalb des Katalogs meldet."""
|
||||
katalog = {
|
||||
"gruppen": ["Verkehr"],
|
||||
"eintraege": [
|
||||
{"schluessel": "a", "gruppe": "Verkehr", "treffer_stile": ["X"], "symbol_stile": ["X"]},
|
||||
{"schluessel": "b", "gruppe": "Verkehr", "treffer_stile": ["X"], "symbol_stile": ["Y"]},
|
||||
],
|
||||
}
|
||||
fehler = pruefe_katalog(katalog)
|
||||
self.assertTrue(any("Style-ID X" in f for f in fehler))
|
||||
self.assertTrue(any("nicht im Katalog: Y" in f for f in fehler))
|
||||
|
||||
def test_ausgelieferter_katalog_ist_konsistent(self):
|
||||
"""Prüft, dass der ausgelieferte Katalog data/basemap_legende.json ohne Fehler geprüft wird und Kreisstraße mit Kontur und Decker enthält."""
|
||||
katalog = lade_json(_standard_zielpfad())
|
||||
self.assertEqual(pruefe_katalog(katalog), [])
|
||||
kreis = _eintrag(katalog, "Decker_Kreisstr")
|
||||
self.assertEqual(kreis["bezeichnung"], "Kreisstraße")
|
||||
self.assertEqual(kreis["symbol_stile"], ["Kontur_Kreisstr", "Decker_Kreisstr"])
|
||||
self.assertIn("Hintergrund", katalog["nie_in_legende"])
|
||||
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Tests für modules/basemap_stilvorlagen.py – lokale Stilvorlagen des Basemap-Layers (Basis #88).
|
||||
|
||||
Gespeichert wird in einem temporären Ordner; der Layer schreibt statt einer echten QML-Datei
|
||||
eine kleine Textdatei. Laden und Speichern am echten Vektorkachel-Layer ist im QGIS-Test geprüft.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from sn_basis.modules.basemap_stilvorlagen import STANDARD_SCHLUESSEL, BasemapStilvorlagen
|
||||
|
||||
|
||||
class _Einstellungen:
|
||||
"""Ersatz für QSettings – die Tests dürfen keine echten Benutzereinstellungen verändern."""
|
||||
|
||||
def __init__(self): self.daten = {}
|
||||
def value(self, k, standard=""): return self.daten.get(k, standard)
|
||||
def setValue(self, k, v): self.daten[k] = v
|
||||
def remove(self, k): self.daten.pop(k, None)
|
||||
|
||||
|
||||
class _Stil:
|
||||
def __init__(self, name): self._name = name
|
||||
def styleName(self): return self._name
|
||||
|
||||
|
||||
class _Layer:
|
||||
def __init__(self, stile=("Kontur_Kreisstr", "Decker_Kreisstr"), speichern_ok=True):
|
||||
self.stile = list(stile)
|
||||
self.geladen = None
|
||||
self.neu_gezeichnet = False
|
||||
self._speichern_ok = speichern_ok
|
||||
|
||||
def name(self): return "Basemap.de - Farbe"
|
||||
def renderer(self): return type("R", (), {"styles": lambda s: [_Stil(n) for n in self.stile]})()
|
||||
|
||||
def saveNamedStyle(self, pfad, kategorien):
|
||||
if not self._speichern_ok:
|
||||
return "kein Schreibrecht", False
|
||||
with open(pfad, "w", encoding="utf-8") as datei:
|
||||
datei.write("<qgis/>")
|
||||
return "", True
|
||||
|
||||
def loadNamedStyle(self, pfad, kategorien):
|
||||
self.geladen = pfad
|
||||
return "", True
|
||||
|
||||
def triggerRepaint(self): self.neu_gezeichnet = True
|
||||
|
||||
|
||||
class TestStilvorlagen(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.ordner = tempfile.mkdtemp()
|
||||
self.einstellungen = _Einstellungen()
|
||||
self.vorlagen = BasemapStilvorlagen(os.path.join(self.ordner, "stile"), self.einstellungen)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.ordner, ignore_errors=True)
|
||||
|
||||
def test_speichern_und_auflisten(self):
|
||||
"""Prüft, dass eine gespeicherte Vorlage mit Anzeigenamen, QML-Datei und Begleitdatei aufgelistet wird."""
|
||||
self.vorlagen.speichern(_Layer(), "Ohne Hausnummern", {"ausgeblendet": ["wald"], "eingeblendet": []})
|
||||
liste = self.vorlagen.liste()
|
||||
self.assertEqual([v.anzeigename for v in liste], ["Ohne Hausnummern"])
|
||||
self.assertEqual(liste[0].name, "Ohne_Hausnummern")
|
||||
self.assertTrue(os.path.exists(liste[0].pfad))
|
||||
self.assertEqual(self.vorlagen.legendenauswahl("Ohne_Hausnummern")["ausgeblendet"], ["wald"])
|
||||
|
||||
def test_mehrere_varianten_alphabetisch(self):
|
||||
"""Prüft, dass mehrere Varianten nebeneinander bestehen und alphabetisch nach Anzeigename sortiert werden."""
|
||||
for name in ("Zeichnung", "Abstimmung", "mit Gebäuden"):
|
||||
self.vorlagen.speichern(_Layer(), name)
|
||||
self.assertEqual([v.anzeigename for v in self.vorlagen.liste()], ["Abstimmung", "mit Gebäuden", "Zeichnung"])
|
||||
|
||||
def test_leerer_name_oder_fehler_beim_schreiben(self):
|
||||
"""Prüft, dass ein leerer Name und ein Schreibfehler von QGIS als ValueError gemeldet werden."""
|
||||
with self.assertRaises(ValueError):
|
||||
self.vorlagen.speichern(_Layer(), " ")
|
||||
with self.assertRaises(ValueError):
|
||||
self.vorlagen.speichern(_Layer(speichern_ok=False), "Test")
|
||||
|
||||
def test_laden_setzt_stil_und_zeichnet_neu(self):
|
||||
"""Prüft, dass das Laden die QML-Datei der Vorlage in den Layer lädt und neu zeichnen lässt."""
|
||||
self.vorlagen.speichern(_Layer(), "Standard LNO")
|
||||
layer = _Layer()
|
||||
self.vorlagen.laden(layer, "Standard_LNO")
|
||||
self.assertTrue(layer.geladen.endswith("Standard_LNO.qml"))
|
||||
self.assertTrue(layer.neu_gezeichnet)
|
||||
with self.assertRaises(ValueError):
|
||||
self.vorlagen.laden(layer, "gibt_es_nicht")
|
||||
|
||||
def test_passung_meldet_fremde_styles(self):
|
||||
"""Prüft, dass Style-Namen der Vorlage gemeldet werden, die der Ziel-Layer nicht kennt."""
|
||||
self.vorlagen.speichern(_Layer(stile=("A", "B", "C")), "Grau")
|
||||
self.assertEqual(self.vorlagen.pruefe_passung(_Layer(stile=("A",)), "Grau"), ["B", "C"])
|
||||
self.assertEqual(self.vorlagen.pruefe_passung(_Layer(stile=("A", "B", "C")), "Grau"), [])
|
||||
|
||||
def test_standard_setzen_und_beim_loeschen_entfernen(self):
|
||||
"""Prüft, dass eine Vorlage als Standard markiert wird und die Markierung beim Löschen der Vorlage entfällt."""
|
||||
self.vorlagen.speichern(_Layer(), "Standard LNO")
|
||||
self.vorlagen.setze_standard("Standard_LNO")
|
||||
self.assertTrue(self.vorlagen.liste()[0].standard)
|
||||
self.vorlagen.loeschen("Standard_LNO")
|
||||
self.assertEqual(self.vorlagen.liste(), [])
|
||||
self.assertEqual(self.vorlagen.standard(), "")
|
||||
self.assertNotIn(STANDARD_SCHLUESSEL, self.einstellungen.daten)
|
||||
with self.assertRaises(ValueError):
|
||||
self.vorlagen.setze_standard("Standard_LNO")
|
||||
|
||||
def test_fehlende_begleitdatei_wird_toleriert(self):
|
||||
"""Prüft, dass eine QML-Datei ohne Begleitdatei mit ihrem Dateinamen aufgelistet wird."""
|
||||
os.makedirs(self.vorlagen.ordner, exist_ok=True)
|
||||
with open(os.path.join(self.vorlagen.ordner, "Alt.qml"), "w", encoding="utf-8") as datei:
|
||||
datei.write("<qgis/>")
|
||||
self.assertEqual([v.anzeigename for v in self.vorlagen.liste()], ["Alt"])
|
||||
self.assertIsNone(self.vorlagen.legendenauswahl("Alt"))
|
||||
self.assertEqual(self.vorlagen.pruefe_passung(_Layer(), "Alt"), [])
|
||||
|
||||
def test_ohne_ordner_leere_liste(self):
|
||||
"""Prüft, dass ohne angelegten Vorlagenordner eine leere Liste zurückkommt."""
|
||||
self.assertEqual(self.vorlagen.liste(), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -22,6 +22,7 @@ from sn_basis.functions.qgiscore_wrapper import QgsProject, QgsMapLayerProxyMode
|
||||
from sn_basis.functions.qgisui_wrapper import iface, QgsMapLayerComboBox
|
||||
from sn_basis.functions.variable_wrapper import get_variable, set_variable
|
||||
from sn_basis.modules.Pruefmanager import Pruefmanager
|
||||
from sn_basis.ui.widgets.basemap_bereich import BasemapBereich
|
||||
from sn_basis.modules.DataGrabber import DataGrabber
|
||||
from sn_basis.modules.print_logic import (
|
||||
PrintLogic,
|
||||
@@ -257,6 +258,10 @@ class PrintTab(QWidget):
|
||||
self._atlas_content = atlas_content
|
||||
main_layout.addWidget(atlas_content)
|
||||
|
||||
# Basemap-Bereich (einklappbar): Basemap-Legende und Stilvorlagen (Basis #88)
|
||||
self._basemap_bereich = BasemapBereich(self)
|
||||
main_layout.addWidget(self._basemap_bereich)
|
||||
|
||||
btn_vorlage_erstellen = QPushButton("Vorlage erstellen", self)
|
||||
btn_vorlage_erstellen.clicked.connect(self._on_vorlage_erstellen)
|
||||
self._btn_vorlage_erstellen = btn_vorlage_erstellen
|
||||
@@ -445,6 +450,13 @@ class PrintTab(QWidget):
|
||||
formfaktor=self._endlosrolle_cb.isChecked() if self._endlosrolle_cb else False,
|
||||
druckbereich_auswahl=self._druckbereich_combo.currentText() if self._druckbereich_combo else DRUCKBEREICH_VERFAHRENSGEBIET,
|
||||
)
|
||||
self._basemap_bereich_aktualisieren()
|
||||
|
||||
def _basemap_bereich_aktualisieren(self) -> None:
|
||||
"""Liest den Basemap-Bereich neu ein (neue Vorlage, Projektwechsel)."""
|
||||
bereich = getattr(self, "_basemap_bereich", None)
|
||||
if bereich is not None:
|
||||
bereich.aktualisieren()
|
||||
|
||||
def _disconnect_project_signals(self) -> None:
|
||||
try:
|
||||
@@ -494,6 +506,7 @@ class PrintTab(QWidget):
|
||||
self._theme_signal_connected = False
|
||||
self._connect_theme_collection_signals()
|
||||
self._restore_state()
|
||||
self._basemap_bereich_aktualisieren()
|
||||
|
||||
def _toggle_atlas(self, checked: bool) -> None:
|
||||
if self._atlas_button:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""sn_basis/ui/widgets – wiederverwendbare Bereiche für Tabs."""
|
||||
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
sn_basis/ui/widgets/basemap_bereich.py – einklappbarer Bereich „Basemap“ im Druck-Tab
|
||||
|
||||
Einstellungen der Basemap-Legende (Auswertebereich, Erzeugen beim Anlegen der Vorlage),
|
||||
„Basemap-Legende aktualisieren“ für bestehende Vorlagen und lokale Stilvorlagen des
|
||||
Basemap-Layers (laden, speichern, als Standard markieren, löschen). Basis #88.
|
||||
|
||||
Die Handler enthalten nur Ablauf und Rückfragen; die Logik liegt in
|
||||
``modules/basemap_legende`` und ``modules/basemap_stilvorlagen``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from sn_basis.functions.dialog_wrapper import ask_text, ask_yes_no
|
||||
from sn_basis.functions.message_wrapper import info, success, warning
|
||||
from sn_basis.functions.qgiscore_wrapper import QgsProject
|
||||
from sn_basis.functions.qt_wrapper import (
|
||||
ArrowDown,
|
||||
ArrowRight,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
ToolButtonTextBesideIcon,
|
||||
)
|
||||
from sn_basis.modules import basemap_legende as legende
|
||||
from sn_basis.modules.basemap_legende_layout import layouts_mit_basemap_legende
|
||||
from sn_basis.modules.basemap_stilvorlagen import BasemapStilvorlagen
|
||||
|
||||
TITEL = "Basemap"
|
||||
KEIN_LAYER_TEXT = "Kein Basemap-Layer im Projekt"
|
||||
|
||||
|
||||
class BasemapBereich(QWidget):
|
||||
"""Einklappbarer Basemap-Bereich; standardmäßig eingeklappt."""
|
||||
|
||||
def __init__(self, parent: Optional[Any] = None, project: Any | None = None,
|
||||
stilvorlagen: BasemapStilvorlagen | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._project = project
|
||||
self._stilvorlagen = stilvorlagen
|
||||
self._build_ui()
|
||||
self.aktualisieren()
|
||||
|
||||
# --- Aufbau ---------------------------------------------------------
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
"""von Test abgesehen, weil reiner Widget-Aufbau; im QGIS-Test geprüft"""
|
||||
haupt = QVBoxLayout()
|
||||
haupt.setSpacing(4)
|
||||
haupt.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
knopf = QToolButton()
|
||||
knopf.setText(TITEL)
|
||||
knopf.setCheckable(True)
|
||||
knopf.setChecked(False)
|
||||
knopf.setToolButtonStyle(ToolButtonTextBesideIcon)
|
||||
knopf.setArrowType(ArrowRight)
|
||||
knopf.setStyleSheet("font-weight: bold; margin-top: 6px;")
|
||||
knopf.toggled.connect(self._umschalten)
|
||||
self._knopf = knopf
|
||||
haupt.addWidget(knopf)
|
||||
|
||||
inhalt = QWidget()
|
||||
spalte = QVBoxLayout()
|
||||
spalte.setSpacing(4)
|
||||
spalte.setContentsMargins(10, 4, 4, 4)
|
||||
|
||||
self._status = QLabel(KEIN_LAYER_TEXT)
|
||||
self._status.setWordWrap(True)
|
||||
spalte.addWidget(self._status)
|
||||
|
||||
spalte.addWidget(self._ueberschrift("Legende"))
|
||||
spalte.addWidget(QLabel("Auswertebereich"))
|
||||
self._bereich_combo = QComboBox(self)
|
||||
for schluessel, text in legende.AUSWERTEBEREICHE.items():
|
||||
self._bereich_combo.addItem(text, schluessel)
|
||||
self._bereich_combo.currentIndexChanged.connect(self._on_bereich_geaendert)
|
||||
spalte.addWidget(self._bereich_combo)
|
||||
|
||||
self._erzeugen_cb = QCheckBox("Basemap-Legende beim Erstellen der Vorlage erzeugen (§ 38, § 41)", self)
|
||||
self._erzeugen_cb.toggled.connect(self._on_erzeugen_geaendert)
|
||||
spalte.addWidget(self._erzeugen_cb)
|
||||
|
||||
spalte.addWidget(QLabel("Vorlage"))
|
||||
self._layout_combo = QComboBox(self)
|
||||
spalte.addWidget(self._layout_combo)
|
||||
self._aktualisieren_btn = QPushButton("Basemap-Legende aktualisieren", self)
|
||||
self._aktualisieren_btn.clicked.connect(self._on_legende_aktualisieren)
|
||||
spalte.addWidget(self._aktualisieren_btn)
|
||||
|
||||
spalte.addWidget(self._ueberschrift("Stilvorlage"))
|
||||
self._stil_combo = QComboBox(self)
|
||||
spalte.addWidget(self._stil_combo)
|
||||
zeile = QHBoxLayout()
|
||||
for text, handler in (
|
||||
("Laden", self._on_stil_laden),
|
||||
("Speichern unter …", self._on_stil_speichern),
|
||||
("Als Standard", self._on_stil_standard),
|
||||
("Löschen", self._on_stil_loeschen),
|
||||
):
|
||||
knopf_stil = QPushButton(text, self)
|
||||
knopf_stil.clicked.connect(lambda *_, h=handler: h())
|
||||
zeile.addWidget(knopf_stil)
|
||||
spalte.addLayout(zeile)
|
||||
|
||||
inhalt.setLayout(spalte)
|
||||
inhalt.setVisible(False)
|
||||
self._inhalt = inhalt
|
||||
haupt.addWidget(inhalt)
|
||||
self.setLayout(haupt)
|
||||
|
||||
@staticmethod
|
||||
def _ueberschrift(text: str) -> Any:
|
||||
beschriftung = QLabel(text)
|
||||
beschriftung.setStyleSheet("font-weight: bold; margin-top: 4px;")
|
||||
return beschriftung
|
||||
|
||||
# --- Zustand --------------------------------------------------------
|
||||
|
||||
def _projekt(self) -> Any:
|
||||
return getattr(self, "_project", None) or QgsProject.instance()
|
||||
|
||||
def _vorlagen(self) -> BasemapStilvorlagen:
|
||||
if getattr(self, "_stilvorlagen", None) is None:
|
||||
self._stilvorlagen = BasemapStilvorlagen()
|
||||
return self._stilvorlagen
|
||||
|
||||
def basemap_layer(self) -> Any | None:
|
||||
"""Erster Basemap-Layer des Projekts (sichtbar oder nicht)."""
|
||||
try:
|
||||
layer = list(self._projekt().mapLayers().values())
|
||||
except Exception:
|
||||
return None
|
||||
gefunden = legende.finde_basemap_layer(layer)
|
||||
return gefunden[0] if gefunden else None
|
||||
|
||||
def _umschalten(self, offen: bool) -> None:
|
||||
self._knopf.setArrowType(ArrowDown if offen else ArrowRight)
|
||||
self._inhalt.setVisible(offen)
|
||||
if offen:
|
||||
self.aktualisieren()
|
||||
|
||||
def aktualisieren(self) -> None:
|
||||
"""Liest Projektvariablen, Layouts und Stilvorlagen neu ein (z. B. nach Projektwechsel)."""
|
||||
layer = self.basemap_layer()
|
||||
self._status.setText(f"Basemap-Layer: {layer.name()}" if layer is not None else KEIN_LAYER_TEXT)
|
||||
|
||||
bereich = legende.lies_auswertebereich()
|
||||
self._bereich_combo.blockSignals(True)
|
||||
self._bereich_combo.setCurrentIndex(max(0, self._bereich_combo.findData(bereich)))
|
||||
self._bereich_combo.blockSignals(False)
|
||||
self._erzeugen_cb.blockSignals(True)
|
||||
self._erzeugen_cb.setChecked(legende.legende_erzeugen_aktiv())
|
||||
self._erzeugen_cb.blockSignals(False)
|
||||
|
||||
self._layout_combo.clear()
|
||||
self._layout_combo.addItems(layouts_mit_basemap_legende(self._projekt()))
|
||||
self._aktualisieren_btn.setEnabled(layer is not None and self._layout_combo.count() > 0)
|
||||
|
||||
self._stil_combo.clear()
|
||||
for vorlage in self._vorlagen().liste():
|
||||
self._stil_combo.addItem(f"{vorlage.anzeigename}{' ★' if vorlage.standard else ''}", vorlage.name)
|
||||
standard = self._vorlagen().standard()
|
||||
if standard:
|
||||
self._stil_combo.setCurrentIndex(max(0, self._stil_combo.findData(standard)))
|
||||
|
||||
# --- Handler: Legende -----------------------------------------------
|
||||
|
||||
def _on_bereich_geaendert(self, *_: Any) -> None:
|
||||
bereich = self._bereich_combo.currentData()
|
||||
if bereich in legende.AUSWERTEBEREICHE:
|
||||
legende.setze_auswertebereich(bereich)
|
||||
|
||||
def _on_erzeugen_geaendert(self, aktiv: bool) -> None:
|
||||
legende.setze_legende_erzeugen(bool(aktiv))
|
||||
|
||||
def _on_legende_aktualisieren(self) -> None:
|
||||
name = self._layout_combo.currentText()
|
||||
layout = self._projekt().layoutManager().layoutByName(name) if name else None
|
||||
if layout is None:
|
||||
warning(TITEL, "Bitte eine Vorlage mit Basemap-Legende wählen.")
|
||||
return
|
||||
try:
|
||||
ergebnis = legende.BasemapLegende(self._projekt()).aktualisiere(layout)
|
||||
except Exception as fehler:
|
||||
warning(TITEL, f"Die Basemap-Legende konnte nicht aktualisiert werden: {fehler}")
|
||||
return
|
||||
if ergebnis["status"] == "ok":
|
||||
success(TITEL, ergebnis["meldung"])
|
||||
elif ergebnis["status"] != "abgebrochen":
|
||||
info(TITEL, ergebnis["meldung"])
|
||||
|
||||
# --- Handler: Stilvorlagen ------------------------------------------
|
||||
|
||||
def _gewaehlte_vorlage(self) -> str:
|
||||
return str(self._stil_combo.currentData() or "")
|
||||
|
||||
def _on_stil_laden(self) -> None:
|
||||
layer = self.basemap_layer()
|
||||
name = self._gewaehlte_vorlage()
|
||||
if layer is None or not name:
|
||||
warning(TITEL, "Kein Basemap-Layer oder keine Stilvorlage gewählt.")
|
||||
return
|
||||
if not ask_yes_no("Stilvorlage laden",
|
||||
f"Darstellung von „{layer.name()}“ durch die Stilvorlage ersetzen?", default=False):
|
||||
return
|
||||
unbekannt = self._vorlagen().pruefe_passung(layer, name)
|
||||
if unbekannt and not ask_yes_no(
|
||||
"Stilvorlage laden",
|
||||
f"{len(unbekannt)} Styles der Vorlage gibt es in diesem Layer nicht – die Vorlage stammt "
|
||||
"vermutlich von einer anderen Basemap-Variante. Trotzdem laden?",
|
||||
default=False,
|
||||
):
|
||||
return
|
||||
try:
|
||||
self._vorlagen().laden(layer, name)
|
||||
except ValueError as fehler:
|
||||
warning(TITEL, str(fehler))
|
||||
return
|
||||
auswahl = self._vorlagen().legendenauswahl(name)
|
||||
if auswahl is not None:
|
||||
import json
|
||||
from sn_basis.functions.variable_wrapper import set_variable
|
||||
set_variable(legende.AUSWAHL_VAR, json.dumps(auswahl), scope="project")
|
||||
success(TITEL, "Stilvorlage geladen.")
|
||||
|
||||
def _on_stil_speichern(self) -> None:
|
||||
layer = self.basemap_layer()
|
||||
if layer is None:
|
||||
warning(TITEL, KEIN_LAYER_TEXT)
|
||||
return
|
||||
anzeigename, ok = ask_text("Stilvorlage speichern", "Name der Stilvorlage:",
|
||||
default_text=self._stil_combo.currentText().replace(" ★", ""))
|
||||
if not ok or not anzeigename.strip():
|
||||
return
|
||||
if self._vorlagen().vorhanden(anzeigename) and not ask_yes_no(
|
||||
"Stilvorlage speichern", f"Die Stilvorlage „{anzeigename}“ gibt es schon. Ersetzen?", default=False
|
||||
):
|
||||
return
|
||||
try:
|
||||
self._vorlagen().speichern(layer, anzeigename, legende.lies_auswahl())
|
||||
except ValueError as fehler:
|
||||
warning(TITEL, str(fehler))
|
||||
return
|
||||
self.aktualisieren()
|
||||
success(TITEL, f"Stilvorlage „{anzeigename.strip()}“ gespeichert.")
|
||||
|
||||
def _on_stil_standard(self) -> None:
|
||||
name = self._gewaehlte_vorlage()
|
||||
if not name:
|
||||
return
|
||||
try:
|
||||
self._vorlagen().setze_standard(name)
|
||||
except ValueError as fehler:
|
||||
warning(TITEL, str(fehler))
|
||||
return
|
||||
self.aktualisieren()
|
||||
|
||||
def _on_stil_loeschen(self) -> None:
|
||||
name = self._gewaehlte_vorlage()
|
||||
if not name or not ask_yes_no("Stilvorlage löschen",
|
||||
f"Stilvorlage „{self._stil_combo.currentText()}“ löschen?", default=False):
|
||||
return
|
||||
self._vorlagen().loeschen(name)
|
||||
self.aktualisieren()
|
||||
Reference in New Issue
Block a user