Files
Plugin_SN_Basis/modules/basemap_legende.py
T
DanielandClaude Opus 5.5 b549cb8a5a Basemap-Legende: Auswertung und Legendenelement im Drucklayout (#88)
Beim Erstellen einer §38-/§41-Vorlage wertet BasemapLegende die Kacheln des sichtbaren
basemap.de-Layers im Verfahrensgebiet (wahlweise Druckbereich) kachelweise über
selectByGeometry aus, prüft die Renderer-Styles mit derselben Zoomlogik wie QGIS und
bildet daraus Legendeneinträge. Ein Mehrfachauswahl-Dialog (neu: ask_mehrfachauswahl)
lässt Kategorien abwählen; die Auswahl wird im Projekt gemerkt. Die gewählten Einträge
stehen mit der Signatur des Projektlayers (zoomabhängige Werte festgeschrieben) in
einem eigenen Legendenelement über der Quellenangabe. Ein eigenes Element ist nötig,
weil die nach Karteninhalt filternde Hauptlegende die Hilfslayer ausblenden würde.

Wrapper um die nötigen QGIS-/Qt-Symbole mit Mock ergänzt (eigener Block in
qgiscore_wrapper; QgsRectangle hatte bisher keinen Mock).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:57:09 +02:00

472 lines
19 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_legende.py – Legende für den basemap.de-Vektorkachel-Layer
QGIS erzeugt für Vektorkachel-Layer keine Legende. Dieses Modul wertet aus, welche
Objekte des Basemap-Layers im Auswertebereich (Verfahrensgebiet oder Druckbereich)
tatsächlich dargestellt werden, lässt den Nutzer Kategorien abwählen und schreibt die
gewählten Kategorien mit der Signatur des Projektlayers in ein eigenes Legendenelement
des Drucklayouts (``basemap_legende_layout``).
Ablauf und Festlegungen: Konzept ``docs/konzept/10_basemap_legende.md`` im Projektwissen,
Ticket Plugin_SN_Basis #88.
Aufbau:
- reine Logik ohne QGIS (Katalog, Zuordnung Style → Legendeneintrag, Vorauswahl) – im
Mock-Modus getestet,
- QGIS-Anbindung: Layer-Erkennung und Auswertebereich hier, Kachelauswertung über
``selectByGeometry`` in ``basemap_kacheln``,
- ``BasemapLegende.aktualisiere(layout)`` als Einstieg für Druck-Tab und Vorlagenerstellung.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from typing import Any, Callable, Iterable
from sn_basis.functions.qgiscore_wrapper import QgsCoordinateTransform, QgsGeometry, QgsProject
from sn_basis.functions.variable_wrapper import get_variable, set_variable
from sn_basis.modules.basemap_kacheln import ( # noqa: F401 – StilInfo/ObjektInfo gehören zur Schnittstelle
ObjektInfo,
StilInfo,
anzahl_kacheln,
qgis_filterpruefer,
sammle_objekte,
stile_aus_layer,
zoomstufen,
)
# ---------------------------------------------------------
# Konstanten und Schnittstellen
# ---------------------------------------------------------
BASEMAP_URL_KENNUNG = "gdz_basemapde_vektor"
BASEMAP_NAME_RUECKFALL = "basemap.de"
AUSWERTEBEREICH_VERFAHRENSGEBIET = "verfahrensgebiet"
AUSWERTEBEREICH_DRUCKBEREICH = "druckbereich"
AUSWERTEBEREICHE = {
AUSWERTEBEREICH_VERFAHRENSGEBIET: "Verfahrensgebiet",
AUSWERTEBEREICH_DRUCKBEREICH: "Druckbereich (Kartenausschnitt der Vorlage)",
}
# Projektvariablen (ohne Präfix sn_, siehe variable_wrapper)
AUSWERTEBEREICH_VAR = "basemap_auswertebereich"
LEGENDE_ERZEUGEN_VAR = "basemap_legende_erzeugen"
AUSWAHL_VAR = "basemap_legende_auswahl"
GRUPPE_SONSTIGES = "Sonstiges"
# Ab dieser Kachelzahl wird vor der Auswertung nachgefragt (je Kachel ein Netzabruf).
KACHELN_NACHFRAGEN_AB = 400
_STANDARD_KATALOG = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"data", "basemap_legende.json")
# ---------------------------------------------------------
# Datenklassen
# ---------------------------------------------------------
@dataclass
class LegendenEintrag:
"""Ein Eintrag der Basemap-Legende."""
schluessel: str
bezeichnung: str
gruppe: str
symbol_stile: list[str]
treffer: list[str] = field(default_factory=list)
bekannt: bool = True
# ---------------------------------------------------------
# Katalog und Zuordnung (ohne QGIS)
# ---------------------------------------------------------
class Katalog:
"""Legendenkatalog aus ``data/basemap_legende.json`` mit Index Style-ID → Eintrag."""
def __init__(self, daten: dict):
self.daten = daten
self.gruppen: list[str] = list(daten.get("gruppen", []))
if GRUPPE_SONSTIGES not in self.gruppen:
self.gruppen.append(GRUPPE_SONSTIGES)
self.eintraege: list[dict] = list(daten.get("eintraege", []))
self._nie = set(daten.get("nie_in_legende", []))
self._praefixe = tuple(daten.get("nie_in_legende_praefixe", []))
self._index: dict[str, dict] = {}
self._reihenfolge: dict[str, int] = {}
for position, eintrag in enumerate(self.eintraege):
self._reihenfolge[eintrag["schluessel"]] = position
for stil_id in eintrag.get("treffer_stile", []):
self._index[stil_id] = eintrag
@classmethod
def laden(cls, pfad: str | None = None) -> "Katalog":
with open(pfad or _STANDARD_KATALOG, encoding="utf-8") as datei:
return cls(json.load(datei))
def ist_ausgeschlossen(self, stil_name: str) -> bool:
return stil_name in self._nie or (bool(self._praefixe) and stil_name.startswith(self._praefixe))
def eintrag_fuer(self, stil_name: str) -> dict | None:
return self._index.get(stil_name)
def sortierschluessel(self, eintrag: LegendenEintrag) -> tuple[int, int, str]:
gruppe = self.gruppen.index(eintrag.gruppe) if eintrag.gruppe in self.gruppen else len(self.gruppen)
return gruppe, self._reihenfolge.get(eintrag.schluessel, len(self._reihenfolge)), eintrag.bezeichnung
def stil_ist_aktiv(stil: StilInfo, zoom: int) -> bool:
"""Wie ``QgsVectorTileBasicRendererStyle.isActive``: eingeschaltet und Zoomstufe im Bereich."""
return (
stil.aktiv
and (stil.min_zoom < 0 or zoom >= stil.min_zoom)
and (stil.max_zoom < 0 or zoom <= stil.max_zoom)
)
def ermittle_getroffene_stile(
objekte: Iterable[ObjektInfo],
stile: Iterable[StilInfo],
zoom: int,
filter_trifft: Callable[[str, ObjektInfo], bool],
) -> dict[str, int]:
"""Ermittelt, welche Renderer-Styles die Objekte bei der Zoomstufe tatsächlich zeichnen.
Ein Style zeichnet ein Objekt, wenn er aktiv ist, zum Quell-Layer passt (leerer
Layername = alle Layer), denselben Geometrietyp hat und sein Filterausdruck zutrifft.
Returns
-------
dict[str, int]
Style-Name → Anzahl der gezeichneten (entdoppelten) Objekte.
"""
je_layer: dict[str, list[StilInfo]] = {}
for stil in stile:
if stil_ist_aktiv(stil, zoom):
je_layer.setdefault(stil.layer_name, []).append(stil)
treffer: dict[str, int] = {}
for objekt in objekte:
for stil in je_layer.get(objekt.layer_name, []) + je_layer.get("", []):
if stil.geometrie != objekt.geometrie:
continue
if stil.filter and not filter_trifft(stil.filter, objekt):
continue
treffer[stil.name] = treffer.get(stil.name, 0) + 1
return treffer
def bilde_eintraege(getroffene_stile: Iterable[str], katalog: Katalog) -> list[LegendenEintrag]:
"""Fasst getroffene Styles zu Legendeneinträgen zusammen, sortiert nach Gruppe und Katalog.
Ausgeschlossene Styles (Beschriftungen, Hintergrund) entfallen. Styles, die der Katalog
nicht kennt (eigene Ergänzungen, neue Basemap-Version), bilden je einen Eintrag in der
Gruppe „Sonstiges“.
"""
eintraege: dict[str, LegendenEintrag] = {}
for stil_name in getroffene_stile:
if katalog.ist_ausgeschlossen(stil_name):
continue
daten = katalog.eintrag_fuer(stil_name)
if daten is None:
schluessel = f"stil:{stil_name}"
eintrag = eintraege.setdefault(schluessel, LegendenEintrag(
schluessel=schluessel,
bezeichnung=stil_name.replace("_", " "),
gruppe=GRUPPE_SONSTIGES,
symbol_stile=[stil_name],
bekannt=False,
))
else:
eintrag = eintraege.setdefault(daten["schluessel"], LegendenEintrag(
schluessel=daten["schluessel"],
bezeichnung=daten.get("bezeichnung", daten["schluessel"]),
gruppe=daten.get("gruppe", GRUPPE_SONSTIGES),
symbol_stile=list(daten.get("symbol_stile", [])),
))
eintrag.treffer.append(stil_name)
return sorted(eintraege.values(), key=katalog.sortierschluessel)
def lies_auswahl() -> dict[str, list[str]]:
"""Liest die zuletzt im Dialog getroffene Auswahl aus der Projektvariable."""
roh = get_variable(AUSWAHL_VAR, scope="project") or ""
try:
daten = json.loads(roh) if roh else {}
except (TypeError, ValueError):
daten = {}
if not isinstance(daten, dict):
daten = {}
return {
"ausgeblendet": [str(s) for s in daten.get("ausgeblendet", [])],
"eingeblendet": [str(s) for s in daten.get("eingeblendet", [])],
}
def vorauswahl(eintraege: Iterable[LegendenEintrag], gespeichert: dict[str, list[str]] | None = None) -> set[str]:
"""Bestimmt die vorausgewählten Einträge für den Dialog.
Zuletzt abgewählte Einträge bleiben abgewählt, zuletzt gewählte bleiben gewählt. Sonst gilt:
Katalogeinträge sind gewählt, unbekannte Styles („Sonstiges“) abgewählt.
"""
gespeichert = gespeichert or {"ausgeblendet": [], "eingeblendet": []}
aus = set(gespeichert.get("ausgeblendet", []))
ein = set(gespeichert.get("eingeblendet", []))
auswahl = set()
for eintrag in eintraege:
if eintrag.schluessel in aus:
continue
if eintrag.bekannt or eintrag.schluessel in ein:
auswahl.add(eintrag.schluessel)
return auswahl
def merke_auswahl(eintraege: Iterable[LegendenEintrag], gewaehlt: Iterable[str]) -> None:
"""Speichert die Dialogauswahl in der Projektvariable (ergänzt die bisherige Auswahl)."""
gewaehlt = set(gewaehlt)
bisher = lies_auswahl()
aus = set(bisher["ausgeblendet"])
ein = set(bisher["eingeblendet"])
for eintrag in eintraege:
if eintrag.schluessel in gewaehlt:
aus.discard(eintrag.schluessel)
ein.add(eintrag.schluessel)
else:
ein.discard(eintrag.schluessel)
aus.add(eintrag.schluessel)
set_variable(AUSWAHL_VAR, json.dumps({"ausgeblendet": sorted(aus), "eingeblendet": sorted(ein)}),
scope="project")
def lies_auswertebereich() -> str:
wert = (get_variable(AUSWERTEBEREICH_VAR, scope="project") or "").strip()
return wert if wert in AUSWERTEBEREICHE else AUSWERTEBEREICH_VERFAHRENSGEBIET
def setze_auswertebereich(bereich: str) -> None:
if bereich not in AUSWERTEBEREICHE:
raise ValueError(f"Unbekannter Auswertebereich: {bereich}")
set_variable(AUSWERTEBEREICH_VAR, bereich, scope="project")
def legende_erzeugen_aktiv() -> bool:
"""Ob beim Erstellen einer Vorlage eine Basemap-Legende erzeugt wird (Standard: ja)."""
return (get_variable(LEGENDE_ERZEUGEN_VAR, scope="project") or "1").strip() != "0"
def setze_legende_erzeugen(aktiv: bool) -> None:
set_variable(LEGENDE_ERZEUGEN_VAR, "1" if aktiv else "0", scope="project")
# ---------------------------------------------------------
# Basemap-Layer erkennen
# ---------------------------------------------------------
def _aufruf(objekt: Any, methode: str, standard: Any = None) -> Any:
funktion = getattr(objekt, methode, None)
if not callable(funktion):
return standard
try:
return funktion()
except Exception:
return standard
def ist_basemap_layer(layer: Any) -> bool:
"""Erkennt den basemap.de-Vektorkachel-Layer.
Maßgeblich ist ein Vektorkachel-Layer (hat ``tileMatrixSet``), dessen Quelle die
basemap.de-Kennung enthält; der Layername dient nur als Rückfall, weil Nutzer Layer
umbenennen.
"""
if layer is None or not callable(getattr(layer, "tileMatrixSet", None)):
return False
quelle = str(_aufruf(layer, "source", "") or "").lower()
if BASEMAP_URL_KENNUNG in quelle:
return True
return BASEMAP_NAME_RUECKFALL in str(_aufruf(layer, "name", "") or "").lower()
def sichtbare_layer_der_karte(map_item: Any, project: Any) -> list[Any]:
"""Liefert die Layer, die eine Layoutkarte darstellt (Kartenthema, feste Layerliste oder Layerbaum)."""
if _aufruf(map_item, "followVisibilityPreset", False):
thema = _aufruf(map_item, "followVisibilityPresetName", "")
sammlung = _aufruf(project, "mapThemeCollection")
if thema and sammlung is not None:
try:
return list(sammlung.mapThemeVisibleLayers(thema))
except Exception:
pass
if _aufruf(map_item, "keepLayerSet", False):
layer = _aufruf(map_item, "layers", []) or []
if layer:
return list(layer)
wurzel = _aufruf(project, "layerTreeRoot")
knoten = _aufruf(wurzel, "findLayers", []) or []
return [k.layer() for k in knoten if _aufruf(k, "isVisible", False) and _aufruf(k, "layer") is not None]
def finde_basemap_layer(layer: Iterable[Any]) -> list[Any]:
return [l for l in layer if ist_basemap_layer(l)]
# ---------------------------------------------------------
# Auswertebereich
# ---------------------------------------------------------
def auswertegeometrie(
bereich: str,
map_item: Any,
project: Any,
ziel_crs: Any,
) -> tuple[Any | None, str]:
"""Bestimmt die Auswertegeometrie im Ziel-KBS.
Returns
-------
(geometrie, tatsächlicher Bereich)
Ohne Verfahrensgebiet wird auf den Druckbereich zurückgefallen.
"""
from sn_basis.functions.verfahrensgebiet_manager import get_verfahrensgebiet_layer_id
geometrie, quell_crs, genutzt = None, None, AUSWERTEBEREICH_DRUCKBEREICH
if bereich == AUSWERTEBEREICH_VERFAHRENSGEBIET:
layer_id = get_verfahrensgebiet_layer_id()
vg_layer = project.mapLayer(layer_id) if layer_id else None
if vg_layer is not None:
teile = [f.geometry() for f in vg_layer.getFeatures() if f.hasGeometry()]
if teile:
geometrie = QgsGeometry.unaryUnion(teile)
quell_crs = vg_layer.crs()
genutzt = AUSWERTEBEREICH_VERFAHRENSGEBIET
if geometrie is None:
geometrie = QgsGeometry.fromRect(map_item.extent())
quell_crs = map_item.crs()
if geometrie is None or geometrie.isEmpty():
return None, genutzt
geometrie = QgsGeometry(geometrie)
geometrie.transform(QgsCoordinateTransform(quell_crs, ziel_crs, project))
return geometrie, genutzt
# ---------------------------------------------------------
# Ablauf
# ---------------------------------------------------------
class BasemapLegende:
"""Erzeugt bzw. aktualisiert die Basemap-Legende eines Drucklayouts.
Dialog, Fortschritt und Rückfragen werden injiziert, damit der Ablauf ohne QGIS-Oberfläche
testbar bleibt.
"""
def __init__(
self,
project: Any | None = None,
katalog: Katalog | None = None,
dialog: Callable[..., list[str] | None] | None = None,
fortschritt_fabrik: Callable[..., Any] | None = None,
bestaetigen: Callable[[str, str], bool] | None = None,
melden: Callable[[str, str], None] | None = None,
) -> None:
from sn_basis.functions.dialog_wrapper import ask_mehrfachauswahl, ask_yes_no, create_progress_dialog
from sn_basis.functions.message_wrapper import info
self.project = project or QgsProject.instance()
self.katalog = katalog or Katalog.laden()
self.dialog = dialog or ask_mehrfachauswahl
self.fortschritt_fabrik = fortschritt_fabrik or create_progress_dialog
self.bestaetigen = bestaetigen or (lambda titel, text: ask_yes_no(titel, text, default=False))
self.melden = melden or (lambda titel, text: info(titel, text))
def ermittle_eintraege(self, layer: Any, geometrie: Any, massstab: float) -> list[LegendenEintrag] | None:
"""Wertet die Kacheln aus und bildet die Legendeneinträge (``None`` bei Abbruch)."""
kachelzahl = anzahl_kacheln(layer, geometrie, massstab)
if kachelzahl >= KACHELN_NACHFRAGEN_AB and not self.bestaetigen(
"Basemap-Legende",
f"Für die Basemap-Legende müssen {kachelzahl} Kacheln ausgewertet werden. "
"Das kann einige Minuten dauern. Fortfahren?",
):
return None
fortschritt = self.fortschritt_fabrik(kachelzahl, "Basemap-Legende", "Kacheln werden ausgewertet …")
def melde_fortschritt(nummer: int, gesamt: int) -> bool:
fortschritt.set_value(nummer)
return not fortschritt.is_canceled()
try:
objekte = sammle_objekte(layer, geometrie, massstab, fortschritt=melde_fortschritt)
finally:
fortschritt.close()
if objekte is None:
return None
_, darstellungszoom, kachelzoom_bruch = zoomstufen(layer, massstab)
getroffen = ermittle_getroffene_stile(
objekte, stile_aus_layer(layer), darstellungszoom,
qgis_filterpruefer(kachelzoom_bruch, darstellungszoom),
)
return bilde_eintraege(getroffen, self.katalog)
def aktualisiere(self, layout: Any, bereich: str | None = None) -> dict:
"""Erzeugt bzw. ersetzt die Basemap-Legende im Layout.
Returns
-------
dict
``status`` (``ok``, ``kein_layer``, ``keine_karte``, ``keine_eintraege``, ``abgebrochen``),
``meldung`` und ``anzahl`` (gewählte Einträge).
"""
from sn_basis.modules import basemap_legende_layout as ausgabe
map_item = ausgabe.hauptkarte(layout)
if map_item is None:
return {"status": "keine_karte", "meldung": "Das Layout enthält keine Karte.", "anzahl": 0}
basemap = finde_basemap_layer(sichtbare_layer_der_karte(map_item, self.project))
if not basemap:
return {"status": "kein_layer", "meldung": "Kein sichtbarer Basemap-Layer in der Karte.", "anzahl": 0}
layer = basemap[0]
if len(basemap) > 1:
self.melden("Basemap-Legende", f"Mehrere Basemap-Layer sichtbar – ausgewertet wird „{layer.name()}“.")
bereich = bereich or lies_auswertebereich()
geometrie, genutzt = auswertegeometrie(bereich, map_item, self.project, layer.crs())
if geometrie is None:
return {"status": "keine_eintraege", "meldung": "Kein Auswertebereich vorhanden.", "anzahl": 0}
if genutzt != bereich:
self.melden("Basemap-Legende", "Kein Verfahrensgebiet gesetzt – ausgewertet wird der Druckbereich.")
massstab = map_item.scale()
eintraege = self.ermittle_eintraege(layer, geometrie, massstab)
if eintraege is None:
return {"status": "abgebrochen", "meldung": "Auswertung abgebrochen.", "anzahl": 0}
if not eintraege:
return {"status": "keine_eintraege", "meldung": "Im Auswertebereich wurden keine Basemap-Objekte gefunden.",
"anzahl": 0}
_, darstellungszoom, kachelzoom_bruch = zoomstufen(layer, massstab)
symbole = ausgabe.baue_symbole(layer, eintraege, darstellungszoom, kachelzoom_bruch)
dialog_eintraege = [
{"schluessel": e.schluessel, "text": e.bezeichnung, "gruppe": e.gruppe,
"icon": ausgabe.vorschau_icon(symbole.get(e.schluessel))}
for e in eintraege
]
gewaehlt = self.dialog(
"Basemap-Legende",
f"Im {AUSWERTEBEREICHE[genutzt].split(' (')[0]} kommen diese Kategorien der Basemap vor.\n"
"Abgewählte Kategorien erscheinen nicht in der Legende.",
dialog_eintraege,
vorauswahl(eintraege, lies_auswahl()),
)
if gewaehlt is None:
return {"status": "abgebrochen", "meldung": "Auswahl abgebrochen.", "anzahl": 0}
merke_auswahl(eintraege, gewaehlt)
gewaehlte_eintraege = [e for e in eintraege if e.schluessel in set(gewaehlt)]
hinweis = ausgabe.schreibe_legende(layout, map_item, gewaehlte_eintraege, symbole, self.project,
bereich=genutzt, massstab=massstab)
if hinweis:
self.melden("Basemap-Legende", hinweis)
anzahl = len(gewaehlte_eintraege)
return {"status": "ok", "meldung": f"{anzahl} Einträge in der Basemap-Legende.", "anzahl": anzahl}