246 lines
9.1 KiB
Python
246 lines
9.1 KiB
Python
"""
|
||||
|
|
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)
|