Neuer Abschnitt „Basemap-Layer“ im Basemap-Bereich des Druck-Tabs: Knopf „Basemap hinzufügen“ und Auswahl „Thema“ (Farbe, Grau). Vorhandene Layer werden an der Kachel-URL erkannt; Rückfrage Zusätzlich laden / Ersetzen / Abbrechen. Vor dem Themenwechsel wird über einen Prüfwert der Darstellung erkannt, ob sie ungespeichert verändert wurde. Neues Modul modules/basemap_themen.py, neue Wrapper-Funktionen ask_auswahl und get_active_layer, Stilvorlagen vermerken ihr Thema. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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)
|