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>
397 lines
16 KiB
Python
397 lines
16 KiB
Python
"""
|
||
sn_basis/modules/basemap_legende_layout.py – Basemap-Legende im Drucklayout ausgeben
|
||
|
||
Baut aus den gewählten Legendeneinträgen (``basemap_legende``) die Legendensymbole aus den
|
||
Renderer-Styles des Projektlayers, legt dafür leere Hilfslayer an und schreibt sie in ein
|
||
eigenes Legendenelement „Legende Basemap“ unterhalb der Hauptlegende.
|
||
|
||
Warum ein eigenes Legendenelement: Die Hauptlegende filtert nach Karteninhalt
|
||
(``setLegendFilterByMapEnabled``) und würde die Hilfslayer ausblenden, weil sie nicht in der
|
||
Karte liegen (Spike 02.10.2026, QGIS 3.44). Das Basemap-Element ist ohne Kartenfilter und ohne
|
||
automatische Aktualisierung; die Hilfslayer stehen nicht im Layerbaum, bleiben aber mit dem
|
||
Projekt gespeichert. Leere Speicherlayer lösen beim Schließen des Projekts keine Rückfrage aus.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from typing import Any
|
||
|
||
from sn_basis.functions.qgiscore_wrapper import (
|
||
LEGEND_STYLE_HIDDEN,
|
||
LEGEND_STYLE_SUBGROUP,
|
||
PROPERTY_TYPE_EXPRESSION,
|
||
QgsCategorizedSymbolRenderer,
|
||
QgsExpressionContext,
|
||
QgsExpressionContextScope,
|
||
QgsLayoutItem,
|
||
QgsLayoutItemLegend,
|
||
QgsLayoutItemMap,
|
||
QgsLayoutMeasurement,
|
||
QgsLayoutPoint,
|
||
QgsLayoutSize,
|
||
QgsLayoutUtils,
|
||
QgsLegendRenderer,
|
||
QgsProperty,
|
||
QgsRendererCategory,
|
||
QgsSymbolLayerUtils,
|
||
QgsUnitTypes,
|
||
QgsVectorLayer,
|
||
)
|
||
from sn_basis.functions.qt_wrapper import QSize
|
||
|
||
MM = QgsUnitTypes.LayoutMillimeters
|
||
|
||
LEGENDE_ID = "Legende Basemap"
|
||
LEGENDE_TITEL = "Kartengrundlage (basemap.de)"
|
||
HAUPTKARTE_ID = "Hauptkarte"
|
||
HAUPTLEGENDE_ID = "Legende"
|
||
QUELLENANGABE_ID = "Quellenangabe"
|
||
ABSTAND_MM = 4.0
|
||
# Mindestplatz, der über der Basemap-Legende für die Hauptlegende frei bleiben soll.
|
||
PLATZ_HAUPTLEGENDE_MM = 40.0
|
||
RAHMENSTAERKE_MM = 0.5
|
||
|
||
# Layout-Eigenschaften
|
||
LAYOUT_KENNZEICHEN = "sn_basemap_legende"
|
||
LAYOUT_BEREICH = "sn_basemap_bereich"
|
||
LAYOUT_MASSSTAB = "sn_basemap_massstab"
|
||
LAYOUT_EINTRAEGE = "sn_basemap_eintraege"
|
||
LAYOUT_HILFSLAYER = "sn_basemap_hilfslayer"
|
||
# Eigenschaft der Hilfslayer (Wert: Layoutname)
|
||
HILFSLAYER_KENNUNG = "sn_basemap_legende"
|
||
|
||
_GEOMETRIE_JE_SYMBOLTYP = {0: "Point", 1: "LineString", 2: "Polygon"}
|
||
_ZOOM_VARIABLEN = {"vector_tile_zoom", "zoom_level"}
|
||
_VARIABLE = re.compile(r"@([A-Za-z_][A-Za-z0-9_]*)")
|
||
|
||
|
||
def _enum_int(wert: Any) -> int:
|
||
try:
|
||
return int(wert)
|
||
except (TypeError, ValueError):
|
||
return int(getattr(wert, "value", -1))
|
||
|
||
|
||
def _aufruf(objekt: Any, methode: str, *args: Any, standard: Any = None) -> Any:
|
||
funktion = getattr(objekt, methode, None)
|
||
if not callable(funktion):
|
||
return standard
|
||
try:
|
||
return funktion(*args)
|
||
except Exception:
|
||
return standard
|
||
|
||
|
||
# ---------------------------------------------------------
|
||
# Layout-Kennzeichen
|
||
# ---------------------------------------------------------
|
||
|
||
def markiere_layout(layout: Any) -> None:
|
||
"""Kennzeichnet ein Layout als Nutzer der Basemap-Legende."""
|
||
layout.setCustomProperty(LAYOUT_KENNZEICHEN, 1)
|
||
|
||
|
||
def nutzt_basemap_legende(layout: Any) -> bool:
|
||
return str(_aufruf(layout, "customProperty", LAYOUT_KENNZEICHEN, 0, standard=0)).lower() in ("1", "true")
|
||
|
||
|
||
def layouts_mit_basemap_legende(project: Any) -> list[str]:
|
||
"""Namen der Drucklayouts des Projekts, die die Basemap-Legende nutzen."""
|
||
verwaltung = _aufruf(project, "layoutManager")
|
||
layouts = _aufruf(verwaltung, "printLayouts", standard=[]) or []
|
||
return [l.name() for l in layouts if nutzt_basemap_legende(l)]
|
||
|
||
|
||
def hauptkarte(layout: Any) -> Any | None:
|
||
"""Die Karte, auf die sich die Legende bezieht (ID „Hauptkarte“, sonst die erste Karte)."""
|
||
karte = _aufruf(layout, "itemById", HAUPTKARTE_ID)
|
||
if karte is not None:
|
||
return karte
|
||
for element in _aufruf(layout, "items", standard=[]) or []:
|
||
if isinstance(element, QgsLayoutItemMap):
|
||
return element
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------
|
||
# Symbole
|
||
# ---------------------------------------------------------
|
||
|
||
def friere_zoom_ein(symbol: Any, zoom_level: int, vector_tile_zoom: float) -> None:
|
||
"""Schreibt zoomabhängige Werte (Strichbreite, Deckkraft …) für die Druck-Zoomstufe fest.
|
||
|
||
Die Basemap-Styles nutzen ``@vector_tile_zoom``; diese Variable gibt es in der Legende nicht.
|
||
Ohne Festschreiben bleiben z. B. gestrichelte Linien in der Legende unsichtbar, weil ihr
|
||
Strichmuster zoomabhängig berechnet wird. ``@symbol_color`` wird mit der Farbe der jeweiligen
|
||
Symbolebene belegt; lokale Variablen wie ``@element`` (``array_foreach``) wertet QGIS selbst aus.
|
||
"""
|
||
for symbol_layer in symbol.symbolLayers():
|
||
kontext = QgsExpressionContext()
|
||
scope = QgsExpressionContextScope()
|
||
scope.setVariable("vector_tile_zoom", vector_tile_zoom)
|
||
scope.setVariable("zoom_level", zoom_level)
|
||
farbe = _aufruf(symbol_layer, "color")
|
||
if farbe is not None:
|
||
scope.setVariable("symbol_color", farbe)
|
||
kontext.appendScope(scope)
|
||
eigenschaften = symbol_layer.dataDefinedProperties()
|
||
for schluessel in eigenschaften.propertyKeys():
|
||
eigenschaft = eigenschaften.property(schluessel)
|
||
if eigenschaft.propertyType() != PROPERTY_TYPE_EXPRESSION:
|
||
continue
|
||
if not set(_VARIABLE.findall(eigenschaft.expressionString())) & _ZOOM_VARIABLEN:
|
||
continue
|
||
ergebnis = eigenschaft.value(kontext, None)
|
||
wert, ok = ergebnis if isinstance(ergebnis, tuple) else (ergebnis, ergebnis is not None)
|
||
if ok and wert is not None:
|
||
eigenschaften.setProperty(schluessel, QgsProperty.fromValue(wert))
|
||
unter = symbol_layer.subSymbol()
|
||
if unter is not None:
|
||
friere_zoom_ein(unter, zoom_level, vector_tile_zoom)
|
||
|
||
|
||
def waehle_symbolnamen(eintrag: Any, aktive: dict[str, int]) -> list[str]:
|
||
"""Style-Namen für das Legendensymbol, in Zeichenreihenfolge des Renderers.
|
||
|
||
Bevorzugt die ``symbol_stile`` des Katalogs, soweit sie bei der Zoomstufe aktiv sind; sonst
|
||
den ersten tatsächlich getroffenen Style.
|
||
"""
|
||
namen = [n for n in eintrag.symbol_stile if n in aktive]
|
||
if not namen:
|
||
namen = [n for n in eintrag.treffer if n in aktive][:1]
|
||
return sorted(dict.fromkeys(namen), key=lambda n: aktive[n])
|
||
|
||
|
||
def baue_symbole(layer: Any, eintraege: list, zoom_level: int, vector_tile_zoom: float) -> dict[str, Any]:
|
||
"""Setzt je Legendeneintrag ein Symbol aus den Renderer-Styles des Projektlayers zusammen."""
|
||
stile = layer.renderer().styles()
|
||
aktive = {s.styleName(): i for i, s in enumerate(stile) if s.isActive(zoom_level) and s.symbol() is not None}
|
||
je_name = {s.styleName(): s for s in stile}
|
||
symbole: dict[str, Any] = {}
|
||
for eintrag in eintraege:
|
||
basis = None
|
||
for name in waehle_symbolnamen(eintrag, aktive):
|
||
symbol = je_name[name].symbol().clone()
|
||
if basis is None:
|
||
basis = symbol
|
||
continue
|
||
for symbol_layer in symbol.symbolLayers():
|
||
# Unverträgliche Ebenen (z. B. Punktsymbol in einer Füllung) lehnt QGIS ab.
|
||
basis.appendSymbolLayer(symbol_layer.clone())
|
||
if basis is not None:
|
||
friere_zoom_ein(basis, zoom_level, vector_tile_zoom)
|
||
symbole[eintrag.schluessel] = basis
|
||
return symbole
|
||
|
||
|
||
def vorschau_icon(symbol: Any) -> Any:
|
||
if symbol is None:
|
||
return None
|
||
return _aufruf(QgsSymbolLayerUtils, "symbolPreviewIcon", symbol, QSize(16, 16))
|
||
|
||
|
||
# ---------------------------------------------------------
|
||
# Hilfslayer
|
||
# ---------------------------------------------------------
|
||
|
||
def entferne_hilfslayer(project: Any, layout: Any) -> None:
|
||
"""Entfernt die Hilfslayer einer früheren Basemap-Legende dieses Layouts aus dem Projekt."""
|
||
roh = str(_aufruf(layout, "customProperty", LAYOUT_HILFSLAYER, "", standard="") or "")
|
||
for layer_id in [i for i in roh.split(",") if i]:
|
||
if _aufruf(project, "mapLayer", layer_id) is not None:
|
||
project.removeMapLayer(layer_id)
|
||
|
||
|
||
def erzeuge_hilfslayer(project: Any, layout_name: str, crs_authid: str, eintraege: list,
|
||
symbole: dict[str, Any]) -> list[tuple[str, list[Any]]]:
|
||
"""Legt je Gruppe und Geometrietyp einen leeren, kategorisierten Speicherlayer an.
|
||
|
||
Returns
|
||
-------
|
||
list[tuple[str, list]]
|
||
Gruppen in Legendenreihenfolge mit ihren Hilfslayern.
|
||
"""
|
||
gruppen: dict[str, dict[int, list]] = {}
|
||
for eintrag in eintraege:
|
||
symbol = symbole.get(eintrag.schluessel)
|
||
if symbol is None:
|
||
continue
|
||
typ = _enum_int(symbol.type())
|
||
if typ not in _GEOMETRIE_JE_SYMBOLTYP:
|
||
continue
|
||
gruppen.setdefault(eintrag.gruppe, {}).setdefault(typ, []).append(
|
||
QgsRendererCategory(eintrag.schluessel, symbol, eintrag.bezeichnung))
|
||
|
||
ergebnis: list[tuple[str, list[Any]]] = []
|
||
for gruppe, je_typ in gruppen.items():
|
||
layer_liste = []
|
||
for typ in sorted(je_typ, reverse=True): # Flächen, Linien, Punkte
|
||
layer = QgsVectorLayer(f"{_GEOMETRIE_JE_SYMBOLTYP[typ]}?crs={crs_authid}&field=eintrag:string",
|
||
f"Basemap-Legende {layout_name}: {gruppe}", "memory")
|
||
layer.setRenderer(QgsCategorizedSymbolRenderer("eintrag", je_typ[typ]))
|
||
layer.setCustomProperty("skipMemoryLayersCheck", 1)
|
||
layer.setCustomProperty(HILFSLAYER_KENNUNG, layout_name)
|
||
project.addMapLayer(layer, False)
|
||
layer_liste.append(layer)
|
||
ergebnis.append((gruppe, layer_liste))
|
||
return ergebnis
|
||
|
||
|
||
# ---------------------------------------------------------
|
||
# Legendenelement
|
||
# ---------------------------------------------------------
|
||
|
||
def _rechteck_mm(element: Any) -> tuple[float, float, float, float] | None:
|
||
"""Links, oben, Breite, Höhe eines Layoutelements in mm – unabhängig von seinem Bezugspunkt."""
|
||
rechteck = _aufruf(element, "sceneBoundingRect")
|
||
if rechteck is not None:
|
||
return rechteck.x(), rechteck.y(), rechteck.width(), rechteck.height()
|
||
position = _aufruf(element, "positionWithUnits")
|
||
groesse = _aufruf(element, "sizeWithUnits")
|
||
if position is None or groesse is None:
|
||
return None
|
||
return position.x(), position.y(), groesse.width(), groesse.height()
|
||
|
||
|
||
def _neues_legendenelement(layout: Any) -> Any:
|
||
legende = QgsLayoutItemLegend(layout)
|
||
legende.setId(LEGENDE_ID)
|
||
_aufruf(legende, "setObjectName", LEGENDE_ID)
|
||
_aufruf(legende, "setFrameEnabled", True)
|
||
_aufruf(legende, "setBackgroundEnabled", False)
|
||
_aufruf(legende, "setFrameStrokeWidth", _layout_messung(RAHMENSTAERKE_MM))
|
||
layout.addLayoutItem(legende)
|
||
return legende
|
||
|
||
|
||
def _layout_messung(wert_mm: float) -> Any:
|
||
return QgsLayoutMeasurement(wert_mm, MM)
|
||
|
||
|
||
def mindestgroesse(legende: Any, layout: Any, spalten: int) -> tuple[float, float] | None:
|
||
"""Platzbedarf der Legende in mm bei ``spalten`` Spalten, ohne sie zu zeichnen.
|
||
|
||
``adjustBoxSize`` wirkt in QGIS erst, nachdem das Element einmal gezeichnet wurde; für die
|
||
Platzierung beim Anlegen wird der Bedarf deshalb über ``QgsLegendRenderer`` berechnet.
|
||
"""
|
||
einstellungen = _aufruf(legende, "legendSettings")
|
||
kontext = _aufruf(QgsLayoutUtils, "createRenderContextForLayout", layout, None)
|
||
if einstellungen is None or kontext is None:
|
||
return None
|
||
einstellungen.setColumnCount(spalten)
|
||
einstellungen.setSplitLayer(True)
|
||
einstellungen.setEqualColumnWidth(True)
|
||
try:
|
||
groesse = QgsLegendRenderer(legende.model(), einstellungen).minimumSize(kontext)
|
||
except Exception:
|
||
return None
|
||
return groesse.width(), groesse.height()
|
||
|
||
|
||
def platziere(layout: Any, legende: Any) -> str:
|
||
"""Setzt die Basemap-Legende direkt über die Quellenangabe, in die Spalte der Hauptlegende.
|
||
|
||
Die Legende ist unten verankert und wächst nach oben. Unter der Hauptlegende zu verankern geht
|
||
nicht: deren Höhe steht erst nach dem Rendern fest, weil sie nach Karteninhalt filtert. Bis zur
|
||
Oberkante der Hauptlegende bleibt ``PLATZ_HAUPTLEGENDE_MM`` frei; reicht der Platz nicht, wird
|
||
die Legende zwei- oder dreispaltig, solange sie nicht über den Seitenrand hinausragt.
|
||
|
||
Returns
|
||
-------
|
||
str
|
||
Hinweis, wenn der Platz auch dreispaltig nicht reicht, sonst leer.
|
||
"""
|
||
haupt = _rechteck_mm(_aufruf(layout, "itemById", HAUPTLEGENDE_ID))
|
||
karte = _rechteck_mm(hauptkarte(layout))
|
||
quelle = _rechteck_mm(_aufruf(layout, "itemById", QUELLENANGABE_ID))
|
||
if haupt is not None:
|
||
x, oben = haupt[0], haupt[1]
|
||
elif karte is not None:
|
||
x, oben = karte[0] + karte[2] + 10.0, karte[1]
|
||
else:
|
||
x, oben = 10.0, 10.0
|
||
if quelle is not None:
|
||
unten = quelle[1] - ABSTAND_MM
|
||
elif karte is not None:
|
||
unten = karte[1] + karte[3]
|
||
else:
|
||
unten = oben + 200.0
|
||
verfuegbar = unten - oben - PLATZ_HAUPTLEGENDE_MM
|
||
seite = _aufruf(_aufruf(_aufruf(layout, "pageCollection"), "page", 0), "pageSize")
|
||
max_breite = (seite.width() - x - 10.0) if seite is not None else float("inf")
|
||
|
||
# Wenigste Spalten, mit denen die Höhe passt – aber nie breiter als bis zum Seitenrand.
|
||
spalten, groesse = 1, mindestgroesse(legende, layout, 1)
|
||
while groesse is not None and groesse[1] > verfuegbar and spalten < 3:
|
||
breiter = mindestgroesse(legende, layout, spalten + 1)
|
||
if breiter is None or breiter[0] > max_breite:
|
||
break
|
||
spalten, groesse = spalten + 1, breiter
|
||
_aufruf(legende, "setColumnCount", spalten)
|
||
_aufruf(legende, "setSplitLayer", True)
|
||
_aufruf(legende, "setEqualColumnWidth", True)
|
||
|
||
unten_links = getattr(getattr(QgsLayoutItem, "ReferencePoint", QgsLayoutItem), "LowerLeft", None)
|
||
if unten_links is not None:
|
||
_aufruf(legende, "setReferencePoint", unten_links)
|
||
breite, hoehe = groesse if groesse is not None else (180.0, 20.0)
|
||
legende.attemptMove(QgsLayoutPoint(x, unten, MM))
|
||
legende.attemptResize(QgsLayoutSize(breite, hoehe, MM))
|
||
if groesse is not None and hoehe > verfuegbar:
|
||
return ("Die Basemap-Legende braucht mehr Platz, als neben der Karte frei ist. Bitte im Layout "
|
||
"anpassen oder weniger Kategorien wählen.")
|
||
return ""
|
||
|
||
|
||
def schreibe_legende(layout: Any, map_item: Any, eintraege: list, symbole: dict[str, Any], project: Any,
|
||
*, bereich: str, massstab: float) -> str:
|
||
"""Schreibt bzw. ersetzt die Basemap-Legende des Layouts.
|
||
|
||
Ohne gewählte Einträge wird ein vorhandenes Basemap-Legendenelement entfernt.
|
||
|
||
Returns
|
||
-------
|
||
str
|
||
Hinweis für den Nutzer oder leer.
|
||
"""
|
||
entferne_hilfslayer(project, layout)
|
||
legende = _aufruf(layout, "itemById", LEGENDE_ID)
|
||
markiere_layout(layout)
|
||
layout.setCustomProperty(LAYOUT_BEREICH, bereich)
|
||
layout.setCustomProperty(LAYOUT_MASSSTAB, float(massstab))
|
||
layout.setCustomProperty(LAYOUT_EINTRAEGE, json.dumps([e.schluessel for e in eintraege]))
|
||
|
||
if not eintraege:
|
||
if legende is not None:
|
||
layout.removeLayoutItem(legende)
|
||
layout.setCustomProperty(LAYOUT_HILFSLAYER, "")
|
||
return ""
|
||
|
||
crs = _aufruf(map_item, "crs")
|
||
gruppen = erzeuge_hilfslayer(project, layout.name(), _aufruf(crs, "authid", standard="") or "EPSG:25833",
|
||
eintraege, symbole)
|
||
layout.setCustomProperty(LAYOUT_HILFSLAYER, ",".join(l.id() for _, ls in gruppen for l in ls))
|
||
|
||
neu = legende is None
|
||
if neu:
|
||
legende = _neues_legendenelement(layout)
|
||
_aufruf(legende, "setLocked", False)
|
||
legende.setLinkedMap(map_item)
|
||
legende.setAutoUpdateModel(False)
|
||
legende.setLegendFilterByMapEnabled(False)
|
||
legende.setTitle(LEGENDE_TITEL)
|
||
wurzel = legende.model().rootGroup()
|
||
wurzel.removeAllChildren()
|
||
for gruppe, layer_liste in gruppen:
|
||
for nummer, layer in enumerate(layer_liste):
|
||
knoten = wurzel.addLayer(layer)
|
||
_aufruf(knoten, "setName", gruppe)
|
||
QgsLegendRenderer.setNodeLegendStyle(knoten, LEGEND_STYLE_SUBGROUP if nummer == 0 else LEGEND_STYLE_HIDDEN)
|
||
_aufruf(legende, "refresh")
|
||
hinweis = platziere(layout, legende) if neu else ""
|
||
if not neu:
|
||
_aufruf(legende, "adjustBoxSize")
|
||
_aufruf(legende, "setLocked", True)
|
||
return hinweis
|