Files
Plugin_SN_Basis/modules/basemap_themen.py
T
DanielandClaude Opus 5.5 24ad564a08 Basemap hinzufügen und Thema wechseln im Basemap-Bereich (#100)
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>
2026-10-05 21:27:35 +02:00

246 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)