""" sn_basis/modules/basemap_themen.py – Basemap-Layer hinzufügen und Themen wechseln (Basis #100) Begriffe: Ein **Thema** ist eine vom Dienst basemap.de als Style-JSON bereitgestellte Darstellung (Farbe, Grau). Ein **Stil** ist eine lokale Anpassung des Nutzers (``basemap_stilvorlagen``). Alle Themen nutzen dieselben Vektorkacheln; das Thema steht im Parameter ``styleUrl`` der Layerquelle. Ein Basemap-Layer gilt als schon vorhanden, wenn ein Layer mit identischer Kachel-URL im Projekt liegt. Ungespeicherte Änderungen: QGIS führt dafür kein Kennzeichen. Direkt nach dem Laden eines Themas oder Stils wird deshalb ein Prüfwert der Darstellung (Symbologie und Beschriftung) als Layer-Eigenschaft gespeichert; vor einem Themenwechsel wird er neu berechnet und verglichen. Die Eigenschaften wandern mit in die Projektdatei. """ from __future__ import annotations import hashlib import os import re import tempfile from dataclasses import dataclass from typing import Any, Callable, Iterable from urllib.parse import parse_qsl from sn_basis.functions.qgiscore_wrapper import ( QgsVectorTileLayer, STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG, VECTOR_TILES_AVAILABLE, ) KACHEL_URL = "https://sgx.geodatenzentrum.de/gdz_basemapde_vektor/tiles/v2/bm_web_de_3857/{z}/{x}/{y}.pbf" STYLE_BASIS_URL = "https://sgx.geodatenzentrum.de/gdz_basemapde_vektor/styles/" ZOOM_MIN = 0 ZOOM_MAX = 15 # Kacheln enden bei Stufe 15 (bm_web_de_3857.json) LAYERNAME = "basemap.de" THEMA_EIGENSCHAFT = "sn_basemap_thema" PRUEFWERT_EIGENSCHAFT = "sn_basemap_pruefwert" UNVERAENDERT = "unveraendert" GEAENDERT = "geaendert" UNBEKANNT = "unbekannt" @dataclass(frozen=True) class Thema: schluessel: str bezeichnung: str style_url: str # Relief (bm_web_top) bewusst nicht: braucht Höhenlinien und Schummerung aus weiteren Quellen, # die ein einzelner Vektorkachel-Layer nicht darstellt (siehe fallstricke.md). THEMEN: dict[str, Thema] = { "farbe": Thema("farbe", "Farbe", STYLE_BASIS_URL + "bm_web_col.json"), "grau": Thema("grau", "Grau", STYLE_BASIS_URL + "bm_web_gry.json"), } STANDARD_THEMA = "farbe" def _aufruf(objekt: Any, methode: str, standard: Any = None, *args: Any) -> Any: funktion = getattr(objekt, methode, None) if not callable(funktion): return standard try: return funktion(*args) except Exception: return standard def _erfolg(ergebnis: Any) -> tuple[bool, str]: """Deutet die je nach QGIS-Version unterschiedlichen Rückgaben von ``loadDefaultStyle`` & Co.""" if isinstance(ergebnis, tuple): wahrheitswerte = [w for w in ergebnis if isinstance(w, bool)] texte = [t for t in ergebnis if isinstance(t, str) and t] return (wahrheitswerte[0] if wahrheitswerte else True), (texte[0] if texte else "") if isinstance(ergebnis, bool): return ergebnis, "" return True, "" def thema(schluessel: str | None) -> Thema: """Thema zum Schlüssel; unbekannte Schlüssel fallen auf das Standardthema zurück.""" return THEMEN.get(schluessel or "", THEMEN[STANDARD_THEMA]) def layername(thema_: Thema) -> str: return f"{LAYERNAME} - {thema_.bezeichnung}" def quelle(thema_: Thema) -> str: """Datenquelle eines Vektorkachel-Layers für das Thema.""" return f"type=xyz&url={KACHEL_URL}&zmin={ZOOM_MIN}&zmax={ZOOM_MAX}&styleUrl={thema_.style_url}" # --------------------------------------------------------- # Quelle und Thema eines Layers # --------------------------------------------------------- def _parameter(layer: Any) -> dict[str, str]: text = str(_aufruf(layer, "source", "") or "") return {k: v for k, v in parse_qsl(text, keep_blank_values=True)} def _normiert(url: str) -> str: return url.strip().rstrip("/").lower() def quell_url(layer: Any) -> str: """Kachel-URL aus der Layerquelle (Parameter ``url``, URL-dekodiert), sonst leer.""" return _parameter(layer).get("url", "") def layer_mit_quelle(layer: Iterable[Any], url: str = KACHEL_URL) -> list[Any]: """Layer, deren Kachel-URL mit ``url`` übereinstimmt – unabhängig von Name und Thema.""" ziel = _normiert(url) return [l for l in layer if ziel and _normiert(quell_url(l)) == ziel] def thema_des_layers(layer: Any) -> str | None: """Schlüssel des Themas, dessen Style-JSON in der Layerquelle steht, sonst ``None``.""" style_url = _normiert(_parameter(layer).get("styleUrl", "")) for schluessel, eintrag in THEMEN.items(): if style_url and style_url == _normiert(eintrag.style_url): return schluessel return None # --------------------------------------------------------- # Prüfwert der Darstellung # --------------------------------------------------------- def pruefwert(layer: Any) -> str | None: """Prüfwert von Symbologie und Beschriftung; ``None``, wenn QGIS die Darstellung nicht ausgibt. Die QGIS-Version im Wurzelelement der QML bleibt unberücksichtigt, damit ein Update von QGIS keine Änderung vortäuscht. """ if not callable(getattr(layer, "saveNamedStyle", None)): return None deskriptor, pfad = tempfile.mkstemp(suffix=".qml", prefix="sn_basemap_") os.close(deskriptor) try: ok, _ = _erfolg(_aufruf(layer, "saveNamedStyle", None, pfad, STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG)) if not ok or not os.path.getsize(pfad): return None with open(pfad, encoding="utf-8", errors="replace") as datei: inhalt = datei.read() except OSError: return None finally: try: os.remove(pfad) except OSError: pass inhalt = re.sub(r"]*>", "", inhalt, count=1) return hashlib.sha256(inhalt.encode("utf-8")).hexdigest() def merke_stand(layer: Any, schluessel: str | None = None) -> None: """Merkt Thema und Prüfwert der aktuellen Darstellung als Ausgangsstand (nach Laden oder Speichern).""" schluessel = schluessel or thema_des_layers(layer) or "" _aufruf(layer, "setCustomProperty", None, THEMA_EIGENSCHAFT, schluessel) _aufruf(layer, "setCustomProperty", None, PRUEFWERT_EIGENSCHAFT, pruefwert(layer) or "") def aenderungsstand(layer: Any) -> str: """``UNVERAENDERT``, ``GEAENDERT`` oder ``UNBEKANNT`` (kein gemerkter Stand, Prüfwert nicht ermittelbar).""" gemerkt = str(_aufruf(layer, "customProperty", "", PRUEFWERT_EIGENSCHAFT, "") or "") if not gemerkt: return UNBEKANNT aktuell = pruefwert(layer) if aktuell is None: return UNBEKANNT return UNVERAENDERT if aktuell == gemerkt else GEAENDERT # --------------------------------------------------------- # Layer anlegen und Thema wechseln # --------------------------------------------------------- def _lade_themenstil(layer: Any) -> None: ok, meldung = _erfolg(_aufruf(layer, "loadDefaultStyle", None)) if not ok: raise ValueError(f"Das Thema konnte nicht geladen werden: {meldung or 'unbekannter Fehler'}") _aufruf(layer, "triggerRepaint") def erzeuge_layer(schluessel: str | None = None, fabrik: Callable[[str, str], Any] | None = None) -> Any: """Neuer Basemap-Layer im gewählten Thema, noch nicht im Projekt. Raises ------ ValueError Wenn Vektorkacheln nicht verfügbar sind oder der Layer ungültig ist. """ eintrag = thema(schluessel) if fabrik is None: if not VECTOR_TILES_AVAILABLE: raise ValueError("Vektorkachel-Layer sind in dieser Umgebung nicht verfügbar.") fabrik = QgsVectorTileLayer layer = fabrik(quelle(eintrag), layername(eintrag)) if not _aufruf(layer, "isValid", True): raise ValueError("Der Basemap-Dienst ist nicht erreichbar oder liefert keine gültigen Kacheln.") _lade_themenstil(layer) merke_stand(layer, eintrag.schluessel) return layer def fuege_hinzu(project: Any, layer: Any) -> None: """Fügt den Layer ganz unten im Layerbaum ein (Hintergrundkarte).""" wurzel = _aufruf(project, "layerTreeRoot") if wurzel is not None and callable(getattr(wurzel, "addLayer", None)): project.addMapLayer(layer, False) wurzel.addLayer(layer) else: project.addMapLayer(layer) def wechsle_thema(layer: Any, schluessel: str) -> None: """Stellt den Layer auf ein anderes Thema um; Layer-ID, Position und Layout-Bezüge bleiben. Der Layername wird nur angepasst, wenn er noch der automatisch vergebene ist. Raises ------ ValueError Wenn die Quelle nicht umgestellt oder das Thema nicht geladen werden konnte. """ eintrag = thema(schluessel) alter_name = str(_aufruf(layer, "name", "") or "") automatisch = alter_name in {layername(t) for t in THEMEN.values()} neuer_name = layername(eintrag) if automatisch else alter_name umstellen = getattr(layer, "setDataSource", None) if not callable(umstellen): raise ValueError("Die Quelle des Basemap-Layers lässt sich nicht umstellen.") anbieter = _aufruf(layer, "providerType", "") or "" try: umstellen(quelle(eintrag), neuer_name, anbieter, False) except TypeError: umstellen(quelle(eintrag), neuer_name, anbieter) _lade_themenstil(layer) merke_stand(layer, eintrag.schluessel)