Files
Plugin_SN_Basis/modules/basemap_themen.py
T

246 lines
9.1 KiB
Python
Raw Normal View History

"""
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"<qgis\b[^>]*>", "<qgis>", 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)